Fix extras naming and version assignment
This commit is contained in:
@@ -45,6 +45,7 @@ using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Library;
|
||||
using MediaBrowser.Model.Querying;
|
||||
@@ -86,6 +87,7 @@ namespace Emby.Server.Implementations.Library
|
||||
private readonly IPeopleRepository _peopleRepository;
|
||||
private readonly ExtraResolver _extraResolver;
|
||||
private readonly IPathManager _pathManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
|
||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||
private readonly IMediaStreamRepository _mediaStreamRepository;
|
||||
@@ -132,6 +134,7 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <param name="peopleRepository">The people repository.</param>
|
||||
/// <param name="pathManager">The path manager.</param>
|
||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||
/// <param name="localization">The localization manager.</param>
|
||||
/// <param name="mediaStreamRepository">The media stream repository.</param>
|
||||
/// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
|
||||
public LibraryManager(
|
||||
@@ -157,6 +160,7 @@ namespace Emby.Server.Implementations.Library
|
||||
IPeopleRepository peopleRepository,
|
||||
IPathManager pathManager,
|
||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
|
||||
ILocalizationManager localization,
|
||||
IMediaStreamRepository mediaStreamRepository,
|
||||
Lazy<IExternalDataManager> externalDataManagerFactory)
|
||||
{
|
||||
@@ -184,6 +188,7 @@ namespace Emby.Server.Implementations.Library
|
||||
_peopleRepository = peopleRepository;
|
||||
_pathManager = pathManager;
|
||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||
_localization = localization;
|
||||
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
|
||||
|
||||
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
||||
@@ -3280,9 +3285,11 @@ namespace Emby.Server.Implementations.Library
|
||||
var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath);
|
||||
if (ownerVideoInfo is null)
|
||||
{
|
||||
yield break;
|
||||
return [];
|
||||
}
|
||||
|
||||
var candidates = new List<ExtraCandidate>();
|
||||
|
||||
var count = filtered.Count;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
@@ -3296,35 +3303,50 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
foreach (var file in filesInSubFolderList)
|
||||
{
|
||||
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType))
|
||||
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder);
|
||||
if (extra is not null)
|
||||
{
|
||||
yield return extra;
|
||||
}
|
||||
AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder);
|
||||
}
|
||||
}
|
||||
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType))
|
||||
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule))
|
||||
{
|
||||
var extra = GetExtra(current, extraType.Value, false);
|
||||
if (extra is not null)
|
||||
{
|
||||
yield return extra;
|
||||
}
|
||||
AddCandidate(current, extraType.Value, extraRule, false);
|
||||
}
|
||||
}
|
||||
|
||||
BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder)
|
||||
var extras = new List<BaseItem>();
|
||||
var typeCounters = new Dictionary<ExtraType, int>();
|
||||
|
||||
// Order by path so that the numbering handed out below does not depend on the
|
||||
// order the file system happened to list the folder in
|
||||
foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal))
|
||||
{
|
||||
var extra = PrepareExtra(candidate);
|
||||
if (extra is not null)
|
||||
{
|
||||
extras.Add(extra);
|
||||
}
|
||||
}
|
||||
|
||||
return extras;
|
||||
|
||||
void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder)
|
||||
{
|
||||
var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType));
|
||||
if (extra is not Video && extra is not Audio)
|
||||
if (extra is Video or Audio)
|
||||
{
|
||||
return null;
|
||||
candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder));
|
||||
}
|
||||
}
|
||||
|
||||
BaseItem? PrepareExtra(ExtraCandidate candidate)
|
||||
{
|
||||
var resolved = candidate.Extra;
|
||||
var extra = resolved;
|
||||
var name = GetExtraName(candidate, ownerVideoInfo, typeCounters);
|
||||
|
||||
// Try to retrieve it from the db. If we don't find it, use the resolved version
|
||||
var itemById = GetItemById(extra.Id);
|
||||
@@ -3333,10 +3355,18 @@ namespace Emby.Server.Implementations.Library
|
||||
extra = itemById;
|
||||
}
|
||||
|
||||
// Only update extra type if it is more specific then the currently known extra type
|
||||
if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown)
|
||||
// An extra is named after its file, so the file is the source of truth. Items created
|
||||
// by older versions, or renamed by a metadata provider, are corrected here;
|
||||
// RefreshExtras persists the change.
|
||||
if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true)
|
||||
{
|
||||
extra.ExtraType = extraType;
|
||||
extra.Name = name;
|
||||
}
|
||||
|
||||
// Only update extra type if it is more specific then the currently known extra type
|
||||
if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown)
|
||||
{
|
||||
extra.ExtraType = candidate.ExtraType;
|
||||
}
|
||||
|
||||
// Only return items that are actual extras (have ExtraType set)
|
||||
@@ -3344,7 +3374,7 @@ namespace Emby.Server.Implementations.Library
|
||||
// so that RefreshExtras can detect when they need updating and set ForceSave.
|
||||
if (extra.ExtraType is not null)
|
||||
{
|
||||
extra.IsInMixedFolder = isInMixedFolder;
|
||||
extra.IsInMixedFolder = candidate.IsInMixedFolder;
|
||||
return extra;
|
||||
}
|
||||
|
||||
@@ -3352,6 +3382,57 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name to give an extra.
|
||||
/// </summary>
|
||||
/// <param name="candidate">The resolved extra.</param>
|
||||
/// <param name="ownerVideoInfo">The naming info of the owner.</param>
|
||||
/// <param name="typeCounters">Number of extras named after their type so far, per type.</param>
|
||||
/// <returns>The name.</returns>
|
||||
private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary<ExtraType, int> typeCounters)
|
||||
{
|
||||
var isNamedAfterOwner = candidate.ExtraRule.RuleType switch
|
||||
{
|
||||
ExtraRuleType.Filename => true,
|
||||
ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!isNamedAfterOwner)
|
||||
{
|
||||
return candidate.Extra.Name;
|
||||
}
|
||||
|
||||
typeCounters.TryGetValue(candidate.ExtraType, out var seen);
|
||||
typeCounters[candidate.ExtraType] = seen + 1;
|
||||
|
||||
var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType));
|
||||
|
||||
return seen == 0
|
||||
? typeName
|
||||
: string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localization.GetServerLocalizedString("NameExtraNumbered"),
|
||||
typeName,
|
||||
seen + 1);
|
||||
}
|
||||
|
||||
private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch
|
||||
{
|
||||
ExtraType.Clip => "NameExtraClip",
|
||||
ExtraType.Trailer => "NameExtraTrailer",
|
||||
ExtraType.BehindTheScenes => "NameExtraBehindTheScenes",
|
||||
ExtraType.DeletedScene => "NameExtraDeletedScene",
|
||||
ExtraType.Interview => "NameExtraInterview",
|
||||
ExtraType.Scene => "NameExtraScene",
|
||||
ExtraType.Sample => "NameExtraSample",
|
||||
ExtraType.ThemeSong => "NameExtraThemeSong",
|
||||
ExtraType.ThemeVideo => "NameExtraThemeVideo",
|
||||
ExtraType.Featurette => "NameExtraFeaturette",
|
||||
ExtraType.Short => "NameExtraShort",
|
||||
_ => "NameExtraUnknown"
|
||||
};
|
||||
|
||||
public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem)
|
||||
{
|
||||
foreach (var map in _configurationManager.Configuration.PathSubstitutions)
|
||||
@@ -3902,5 +3983,7 @@ namespace Emby.Server.Implementations.Library
|
||||
SetTopParentOrAncestorIds(query);
|
||||
return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType);
|
||||
}
|
||||
|
||||
private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
_ => _videoResolvers
|
||||
};
|
||||
|
||||
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "")
|
||||
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "")
|
||||
{
|
||||
var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot);
|
||||
if (extraResult.ExtraType is null)
|
||||
if (extraResult.ExtraType is null || extraResult.Rule is null)
|
||||
{
|
||||
extraType = null;
|
||||
extraRule = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
}
|
||||
|
||||
extraType = extraResult.ExtraType;
|
||||
extraRule = extraResult.Rule;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
"Movies": "Movies",
|
||||
"Music": "Music",
|
||||
"MusicVideos": "Music Videos",
|
||||
"NameExtraBehindTheScenes": "Behind The Scenes",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Deleted Scene",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Sample",
|
||||
"NameExtraScene": "Scene",
|
||||
"NameExtraShort": "Short",
|
||||
"NameExtraThemeSong": "Theme Song",
|
||||
"NameExtraThemeVideo": "Theme Video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameInstallFailed": "{0} installation failed",
|
||||
"NameSeasonNumber": "Season {0}",
|
||||
"NameSeasonUnknown": "Season Unknown",
|
||||
|
||||
@@ -1167,16 +1167,23 @@ public sealed partial class BaseItemRepository
|
||||
: baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder);
|
||||
}
|
||||
|
||||
// An extra is owned by the single version of an item it is named after, so an extra on any
|
||||
// version counts for the item itself
|
||||
IQueryable<Guid> WithPrimaryVersions(IQueryable<Guid> ownerIds)
|
||||
=> ownerIds.Concat(context.BaseItems
|
||||
.Where(version => version.PrimaryVersionId != null && ownerIds.Contains(version.Id))
|
||||
.Select(version => version.PrimaryVersionId!.Value));
|
||||
|
||||
if (filter.HasSpecialFeature.HasValue)
|
||||
{
|
||||
var itemsWithExtras = context.BaseItems
|
||||
var itemsWithExtras = WithPrimaryVersions(context.BaseItems
|
||||
.Where(extra => extra.OwnerId != null
|
||||
&& extra.ExtraType != null
|
||||
&& extra.ExtraType != BaseItemExtraType.Unknown
|
||||
&& extra.ExtraType != BaseItemExtraType.Trailer
|
||||
&& extra.ExtraType != BaseItemExtraType.ThemeSong
|
||||
&& extra.ExtraType != BaseItemExtraType.ThemeVideo)
|
||||
.Select(extra => extra.OwnerId!.Value)
|
||||
.Select(extra => extra.OwnerId!.Value))
|
||||
.Distinct();
|
||||
|
||||
Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id);
|
||||
@@ -1188,9 +1195,9 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.HasTrailer.HasValue)
|
||||
{
|
||||
var trailerOwnerIds = context.BaseItems
|
||||
var trailerOwnerIds = WithPrimaryVersions(context.BaseItems
|
||||
.Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null)
|
||||
.Select(extra => extra.OwnerId!.Value);
|
||||
.Select(extra => extra.OwnerId!.Value));
|
||||
|
||||
Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id);
|
||||
|
||||
@@ -1201,9 +1208,9 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.HasThemeSong.HasValue)
|
||||
{
|
||||
var themeSongOwnerIds = context.BaseItems
|
||||
var themeSongOwnerIds = WithPrimaryVersions(context.BaseItems
|
||||
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null)
|
||||
.Select(extra => extra.OwnerId!.Value);
|
||||
.Select(extra => extra.OwnerId!.Value));
|
||||
|
||||
Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id);
|
||||
|
||||
@@ -1214,9 +1221,9 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.HasThemeVideo.HasValue)
|
||||
{
|
||||
var themeVideoOwnerIds = context.BaseItems
|
||||
var themeVideoOwnerIds = WithPrimaryVersions(context.BaseItems
|
||||
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null)
|
||||
.Select(extra => extra.OwnerId!.Value);
|
||||
.Select(extra => extra.OwnerId!.Value));
|
||||
|
||||
Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id);
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
Model.Entities.ExtraType.Short
|
||||
};
|
||||
|
||||
private static readonly char[] VersionDelimiters = ['-', '_', '.'];
|
||||
private protected static readonly char[] VersionDelimiters = ['-', '_', '.'];
|
||||
|
||||
private string _sortName;
|
||||
|
||||
@@ -1543,19 +1543,33 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
|
||||
{
|
||||
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
|
||||
var newExtraIds = Array.ConvertAll(extras, x => x.Id);
|
||||
|
||||
// An extra is owned by the version it is named after, so all of them are maintained together.
|
||||
var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery()
|
||||
{
|
||||
OwnerIds = [item.Id]
|
||||
});
|
||||
OwnerIds = item.GetOwnedVersionIds()
|
||||
}).Where(e => e.ExtraType.HasValue).ToList();
|
||||
|
||||
var currentExtraIds = currentExtras.Select(e => e.Id).ToArray();
|
||||
|
||||
// Snapshot the persisted names before resolving, as FindExtras corrects the name on the
|
||||
// items it hands back and may well hand back these very instances.
|
||||
var currentExtraNames = new Dictionary<Guid, string>();
|
||||
foreach (var extra in currentExtras)
|
||||
{
|
||||
currentExtraNames[extra.Id] = extra.Name;
|
||||
}
|
||||
|
||||
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
|
||||
var newExtraIds = Array.ConvertAll(extras, x => x.Id);
|
||||
|
||||
var renamedExtraIds = extras
|
||||
.Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal))
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x));
|
||||
|
||||
if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
|
||||
if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
|
||||
{
|
||||
// The owner's dates may only have become known after its extras were created, so keep
|
||||
// them in sync even when there is nothing to refresh.
|
||||
@@ -1570,12 +1584,11 @@ namespace MediaBrowser.Controller.Entities
|
||||
return false;
|
||||
}
|
||||
|
||||
var ownerId = item.Id;
|
||||
|
||||
var tasks = extras.Select(i =>
|
||||
{
|
||||
var ownerId = item.GetOwnerIdForExtra(i);
|
||||
var subOptions = new MetadataRefreshOptions(options);
|
||||
if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty())
|
||||
if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id))
|
||||
{
|
||||
subOptions.ForceSave = true;
|
||||
}
|
||||
@@ -2920,6 +2933,25 @@ namespace MediaBrowser.Controller.Entities
|
||||
return [Id];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ids of this item and the versions of it whose extras it maintains.
|
||||
/// </summary>
|
||||
/// <returns>An array containing the version ids.</returns>
|
||||
protected virtual Guid[] GetOwnedVersionIds()
|
||||
{
|
||||
return [Id];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the id of the version an extra belongs to.
|
||||
/// </summary>
|
||||
/// <param name="extra">The extra.</param>
|
||||
/// <returns>The id of the owning version.</returns>
|
||||
protected virtual Guid GetOwnerIdForExtra(BaseItem extra)
|
||||
{
|
||||
return Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -751,6 +751,80 @@ namespace MediaBrowser.Controller.Entities
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Guid[] GetOwnedVersionIds()
|
||||
{
|
||||
// Only the versions that live beside this one in the folder this scan covers. Linked
|
||||
// versions are items of their own and maintain their extras themselves.
|
||||
return [Id, .. LibraryManager.GetLocalAlternateVersionIds(this)];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Guid GetOwnerIdForExtra(BaseItem extra)
|
||||
{
|
||||
if (string.IsNullOrEmpty(extra.Path))
|
||||
{
|
||||
return Id;
|
||||
}
|
||||
|
||||
var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path.AsSpan());
|
||||
var extraFileName = System.IO.Path.GetFileNameWithoutExtension(extra.Path.AsSpan());
|
||||
|
||||
var ownerId = Id;
|
||||
var matchedLength = MatchedVersionNameLength(Path, extraDirectory, extraFileName);
|
||||
|
||||
foreach (var versionId in LibraryManager.GetLocalAlternateVersionIds(this))
|
||||
{
|
||||
var version = LibraryManager.GetItemById(versionId);
|
||||
if (version is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// "Movie - [2160p]-trailer.mkv" belongs to "Movie - [2160p].mkv" rather than to the
|
||||
// primary version, whose name it also starts with when the primary is plain "Movie.mkv"
|
||||
var length = MatchedVersionNameLength(version.Path, extraDirectory, extraFileName);
|
||||
if (length > matchedLength)
|
||||
{
|
||||
matchedLength = length;
|
||||
ownerId = versionId;
|
||||
}
|
||||
}
|
||||
|
||||
return ownerId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets how much of an extra's file name is the name of the given version file, or 0 when the
|
||||
/// extra is not named after it.
|
||||
/// </summary>
|
||||
/// <param name="versionPath">The path of the version.</param>
|
||||
/// <param name="extraDirectory">The directory the extra lives in.</param>
|
||||
/// <param name="extraFileName">The file name of the extra, without extension.</param>
|
||||
/// <returns>The length of the match.</returns>
|
||||
private static int MatchedVersionNameLength(string versionPath, ReadOnlySpan<char> extraDirectory, ReadOnlySpan<char> extraFileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(versionPath)
|
||||
|| !System.IO.Path.GetDirectoryName(versionPath.AsSpan()).Equals(extraDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var versionFileName = System.IO.Path.GetFileNameWithoutExtension(versionPath.AsSpan());
|
||||
if (versionFileName.IsEmpty || !extraFileName.StartsWith(versionFileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The version name has to end where the extra's own name begins, so that a version
|
||||
// named "Movie - 4K" does not claim the extras of "Movie - 4Kish"
|
||||
var remainder = extraFileName[versionFileName.Length..];
|
||||
|
||||
return !remainder.IsEmpty && (remainder[0] == ' ' || Array.IndexOf(VersionDelimiters, remainder[0]) >= 0)
|
||||
? versionFileName.Length
|
||||
: 0;
|
||||
}
|
||||
|
||||
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
|
||||
{
|
||||
var primary = PrimaryVersionId.HasValue
|
||||
|
||||
@@ -436,6 +436,14 @@ namespace MediaBrowser.Providers.Manager
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extras have no identity of their own in an online database, so remote artwork for them
|
||||
// is always some other item's. Local and dynamic providers still apply, so an extra can
|
||||
// keep an embedded thumbnail or an extracted frame.
|
||||
if (item.ExtraType.HasValue && provider is IRemoteImageProvider)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _baseItemManager.IsImageFetcherEnabled(item, libraryTypeOptions, provider.Name);
|
||||
}
|
||||
|
||||
@@ -584,6 +592,14 @@ namespace MediaBrowser.Providers.Manager
|
||||
return true;
|
||||
}
|
||||
|
||||
// An extra is a local file belonging to another item and has no identity of its own in an
|
||||
// online database. Looking it up matches whatever the surrounding folder happens to be
|
||||
// called and overwrites the extra's name with a different item's title.
|
||||
if (item.ExtraType.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Artists without a folder structure that are derived from metadata have no real path in the library,
|
||||
// so GetLibraryOptions returns null. Allow all providers through rather than blocking them.
|
||||
if (item is MusicArtist && libraryTypeOptions is null)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+231
-19
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user