Fix extras naming and version assignment

This commit is contained in:
Shadowghost
2026-07-27 11:48:15 +02:00
parent dbc796b0b0
commit 79a55327dc
9 changed files with 562 additions and 59 deletions
@@ -443,4 +443,68 @@ public class BaseItemTests
Assert.Equal(1982, trailer.ProductionYear);
Assert.Equal(new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc), trailer.PremiereDate);
}
[Theory]
// An extra named after a version belongs to that version, not to the primary whose name it
// also starts with
[InlineData("/Movies/Movie/Movie - 4K-trailer.mkv", 2)]
[InlineData("/Movies/Movie/Movie - 1080p-behindthescenes.mkv", 1)]
// Named after the movie rather than one of its versions
[InlineData("/Movies/Movie/Movie-trailer.mkv", 0)]
// In an extras folder, so named after nothing in particular
[InlineData("/Movies/Movie/trailers/Official.mkv", 0)]
// A version name is only a match when it is followed by the extra's own suffix
[InlineData("/Movies/Movie/Movie - 4Kish-trailer.mkv", 0)]
public void GetOwnerIdForExtra_AssignsExtraToItsVersion(string extraPath, int expectedVersion)
{
var (primary, alt1, alt2) = SetupVersionGroup();
var expectedId = expectedVersion switch
{
1 => alt1.Id,
2 => alt2.Id,
_ => primary.Id
};
var method = typeof(Video).GetMethod("GetOwnerIdForExtra", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
var ownerId = (Guid)method!.Invoke(primary, [new Video { Id = Guid.NewGuid(), Path = extraPath }])!;
Assert.Equal(expectedId, ownerId);
}
[Fact]
public void GetExtraOwnerIds_FromAnyVersion_CoversEveryVersion()
{
var (primary, alt1, alt2) = SetupVersionGroup();
var method = typeof(Video).GetMethod("GetExtraOwnerIds", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// An extra is owned by the one version it is named after, and the extras of the movie as a
// whole are owned by the primary, so every version has to read all of them back
foreach (var version in new[] { primary, alt1, alt2 })
{
var ids = (Guid[])method!.Invoke(version, null)!;
Assert.Equal(3, ids.Length);
Assert.Contains(primary.Id, ids);
Assert.Contains(alt1.Id, ids);
Assert.Contains(alt2.Id, ids);
}
}
[Fact]
public void GetOwnedVersionIds_CoversEveryLocalVersion()
{
var (primary, alt1, alt2) = SetupVersionGroup();
var method = typeof(Video).GetMethod("GetOwnedVersionIds", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// The extras of all versions are maintained together, so all of them have to be read back
var ids = (Guid[])method!.Invoke(primary, null)!;
Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids);
}
}
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoFixture;
using AutoFixture.AutoMoq;
using Emby.Naming.Common;
@@ -17,6 +18,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Controller.Sorting;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using Moq;
using Xunit;
@@ -38,9 +40,15 @@ public class FindExtrasTests
itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
_fileSystemMock = fixture.Freeze<Mock<IFileSystem>>();
_fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path });
var strings = LoadCoreStrings();
fixture.Freeze<Mock<ILocalizationManager>>()
.Setup(l => l.GetServerLocalizedString(It.IsAny<string>()))
.Returns<string>(key => strings.TryGetValue(key, out var value) ? value : key);
_libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts(
fixture.Create<IEnumerable<IResolverIgnoreRule>>(),
new List<IItemResolver> { new AudioResolver(fixture.Create<NamingOptions>()) },
[new AudioResolver(fixture.Create<NamingOptions>())],
fixture.Create<IEnumerable<IIntroProvider>>(),
fixture.Create<IEnumerable<IBaseItemComparer>>(),
fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
@@ -51,6 +59,16 @@ public class FindExtrasTests
BaseItem.MediaSourceManager ??= fixture.Create<IMediaSourceManager>();
}
private static Dictionary<string, string> LoadCoreStrings()
{
using var stream = typeof(Emby.Server.Implementations.Library.LibraryManager).Assembly
.GetManifestResourceStream("Emby.Server.Implementations.Localization.Core.en-US.json")
?? throw new InvalidOperationException("Core localization resource is missing");
return JsonSerializer.Deserialize<Dictionary<string, string>>(stream)
?? throw new InvalidOperationException("Core localization resource is empty");
}
[Fact]
public void FindExtras_SeparateMovieFolder_FindsCorrectExtras()
{
@@ -132,60 +150,60 @@ public class FindExtrasTests
It.IsAny<string[]>(),
false,
false))
.Returns(new List<FileSystemMetadata>
{
.Returns(
[
new()
{
FullName = "/movies/Up/trailers/some trailer.mkv",
Name = "some trailer.mkv",
IsDirectory = false
}
}).Verifiable();
]).Verifiable();
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/behind the scenes",
It.IsAny<string[]>(),
false,
false))
.Returns(new List<FileSystemMetadata>
{
.Returns(
[
new()
{
FullName = "/movies/Up/behind the scenes/the making of Up.mkv",
Name = "the making of Up.mkv",
IsDirectory = false
}
}).Verifiable();
]).Verifiable();
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/theme-music",
It.IsAny<string[]>(),
false,
false))
.Returns(new List<FileSystemMetadata>
{
.Returns(
[
new()
{
FullName = "/movies/Up/theme-music/theme2.mp3",
Name = "theme2.mp3",
IsDirectory = false
}
}).Verifiable();
]).Verifiable();
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/extras",
It.IsAny<string[]>(),
false,
false))
.Returns(new List<FileSystemMetadata>
{
.Returns(
[
new()
{
FullName = "/movies/Up/extras/Honest Trailer.mkv",
Name = "Honest Trailer.mkv",
IsDirectory = false
}
}).Verifiable();
]).Verifiable();
var files = paths.Select(p => new FileSystemMetadata
{
@@ -289,15 +307,15 @@ public class FindExtrasTests
It.IsAny<string[]>(),
false,
false))
.Returns(new List<FileSystemMetadata>
{
.Returns(
[
new()
{
FullName = "/movies/Up/trailers/trailer.jpg",
Name = "trailer.jpg",
IsDirectory = false
}
}).Verifiable();
]).Verifiable();
var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList();
@@ -320,15 +338,15 @@ public class FindExtrasTests
It.IsAny<string[]>(),
false,
false))
.Returns(new List<FileSystemMetadata>
{
.Returns(
[
new()
{
FullName = "/movies/Up/trailers/Trailer 1 (2013).mkv",
Name = "Trailer 1 (2013).mkv",
IsDirectory = false
}
}).Verifiable();
]).Verifiable();
var files = paths.Select(p => new FileSystemMetadata
{
@@ -372,4 +390,198 @@ public class FindExtrasTests
Assert.Equal("/series/Dexter/trailer.mkv", extras[0].Path);
Assert.Equal("/series/Dexter/trailers/trailer2.mkv", extras[1].Path);
}
[Fact]
public void FindExtras_SameExtraInSeveralContainers_ReturnsEach()
{
var owner = new Movie { Name = "Skyscraper", Path = "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv" };
var paths = new List<string>
{
"/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv",
"/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv",
"/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4",
"/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv",
"/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"
};
var files = paths.Select(p => new FileSystemMetadata
{
FullName = p,
IsDirectory = false
}).ToList();
var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
.ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
// A container is a separate file that plays on its own, so it is a separate extra
Assert.Equal(4, extras.Count);
Assert.Equal("Behind The Scenes", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv"]);
Assert.Equal("Behind The Scenes 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"]);
Assert.Equal("Trailer", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv"]);
Assert.Equal("Trailer 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4"]);
}
[Fact]
public void FindExtras_SameExtraInSeveralResolutions_ReturnsEach()
{
var owner = new Movie { Name = "Dragon 2", Path = "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv" };
var paths = new List<string>
{
"/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv",
"/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv",
"/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"
};
var files = paths.Select(p => new FileSystemMetadata
{
FullName = p,
IsDirectory = false
}).ToList();
var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
.ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
Assert.Equal(2, extras.Count);
Assert.Equal("Trailer", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv"]);
Assert.Equal("Trailer 2", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"]);
}
[Fact]
public void FindExtras_NumberedExtras_AreKeptApart()
{
var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
var paths = new List<string>
{
"/movies/Up (2009)/Up (2009).mkv",
"/movies/Up (2009)/Up (2009)-trailer.mkv",
"/movies/Up (2009)/Up (2009)-trailer2.mkv",
"/movies/Up (2009)/Up (2009)-trailer2.mp4",
"/movies/Up (2009)/Up (2009)-trailer3.mkv"
};
var files = paths.Select(p => new FileSystemMetadata
{
FullName = p,
IsDirectory = false
}).ToList();
var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList();
Assert.Equal(4, extras.Count);
Assert.Equal("/movies/Up (2009)/Up (2009)-trailer.mkv", extras[0].Path);
Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mkv", extras[1].Path);
Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mp4", extras[2].Path);
Assert.Equal("/movies/Up (2009)/Up (2009)-trailer3.mkv", extras[3].Path);
// The index in the file name is not the number the extra is given, which counts the
// extras of a type as they are found
Assert.Equal("Trailer", extras[0].Name);
Assert.Equal("Trailer 2", extras[1].Name);
Assert.Equal("Trailer 3", extras[2].Name);
Assert.Equal("Trailer 4", extras[3].Name);
}
[Fact]
public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle()
{
var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
var paths = new List<string>
{
"/movies/Up (2009)/Up (2009).mkv",
"/movies/Up (2009)/Up (2009)-trailer.mkv",
"/movies/Up (2009)/Recording the audio-behindthescenes.mkv",
"/movies/Up (2009)/Up (2009)-behindthescenes.mkv"
};
var files = paths.Select(p => new FileSystemMetadata
{
FullName = p,
IsDirectory = false
}).ToList();
var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
.ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
Assert.Equal(3, extras.Count);
Assert.Equal("Trailer", extras["/movies/Up (2009)/Up (2009)-trailer.mkv"]);
// A descriptive file name is a real title and survives, and does not consume a number
Assert.Equal("Recording the audio", extras["/movies/Up (2009)/Recording the audio-behindthescenes.mkv"]);
Assert.Equal("Behind The Scenes", extras["/movies/Up (2009)/Up (2009)-behindthescenes.mkv"]);
}
[Fact]
public void FindExtras_ExtraInOwnFolder_IsNamedAfterItsFile()
{
var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
var paths = new List<string>
{
"/movies/Up/Up.mkv",
"/movies/Up/trailers"
};
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/trailers",
It.IsAny<string[]>(),
false,
false))
.Returns(
[
new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false },
new() { FullName = "/movies/Up/trailers/Comic-Con Reel.mkv", Name = "Comic-Con Reel.mkv", IsDirectory = false }
]).Verifiable();
var files = paths.Select(p => new FileSystemMetadata
{
FullName = p,
Name = Path.GetFileName(p),
IsDirectory = !Path.HasExtension(p)
}).ToList();
var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
.ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
_fileSystemMock.Verify();
Assert.Equal(2, extras.Count);
Assert.Equal("Teaser", extras["/movies/Up/trailers/Teaser.mkv"]);
Assert.Equal("Comic-Con Reel", extras["/movies/Up/trailers/Comic-Con Reel.mkv"]);
}
[Fact]
public void FindExtras_DistinctExtrasInSameFolder_AreKeptApart()
{
var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
var paths = new List<string>
{
"/movies/Up/Up.mkv",
"/movies/Up/trailers"
};
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/trailers",
It.IsAny<string[]>(),
false,
false))
.Returns(
[
new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false },
new() { FullName = "/movies/Up/trailers/Official.mkv", Name = "Official.mkv", IsDirectory = false },
new() { FullName = "/movies/Up/trailers/Official.mp4", Name = "Official.mp4", IsDirectory = false }
]).Verifiable();
var files = paths.Select(p => new FileSystemMetadata
{
FullName = p,
Name = Path.GetFileName(p),
IsDirectory = !Path.HasExtension(p)
}).ToList();
var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList();
_fileSystemMock.Verify();
Assert.Equal(3, extras.Count);
Assert.Equal("/movies/Up/trailers/Official.mkv", extras[0].Path);
Assert.Equal("/movies/Up/trailers/Official.mp4", extras[1].Path);
Assert.Equal("/movies/Up/trailers/Teaser.mkv", extras[2].Path);
}
}