Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 102 additions & 19 deletions Emby.Server.Implementations/Library/LibraryManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
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;
Expand Down Expand Up @@ -86,6 +87,7 @@ public class LibraryManager : ILibraryManager
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;
Expand Down Expand Up @@ -132,6 +134,7 @@ public class LibraryManager : ILibraryManager
/// <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(
Expand All @@ -157,6 +160,7 @@ public LibraryManager(
IPeopleRepository peopleRepository,
IPathManager pathManager,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
ILocalizationManager localization,
IMediaStreamRepository mediaStreamRepository,
Lazy<IExternalDataManager> externalDataManagerFactory)
{
Expand Down Expand Up @@ -184,6 +188,7 @@ public LibraryManager(
_peopleRepository = peopleRepository;
_pathManager = pathManager;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
_localization = localization;
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);

_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
Expand Down Expand Up @@ -3280,9 +3285,11 @@ public IEnumerable<BaseItem> FindExtras(BaseItem owner, IReadOnlyList<FileSystem
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++)
{
Expand All @@ -3296,35 +3303,50 @@ public IEnumerable<BaseItem> FindExtras(BaseItem owner, IReadOnlyList<FileSystem

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);
}
}

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);
}
}

BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder)
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);
Expand All @@ -3333,25 +3355,84 @@ public IEnumerable<BaseItem> FindExtras(BaseItem owner, IReadOnlyList<FileSystem
extra = itemById;
}

// 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.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 || extraType != ExtraType.Unknown)
if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown)
{
extra.ExtraType = extraType;
extra.ExtraType = candidate.ExtraType;
}

// Only return items that are actual extras (have ExtraType set)
// Note: OwnerId and ParentId are set by RefreshExtras, not here,
// 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;
}

return null;
}
}

/// <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)
Expand Down Expand Up @@ -3902,5 +3983,7 @@ public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStream
SetTopParentOrAncestorIds(query);
return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType);
}

private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@ protected override Video Resolve(ItemResolveArgs args)
_ => _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;
}

Expand Down Expand Up @@ -88,6 +89,7 @@ public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInf
}

extraType = extraResult.ExtraType;
extraRule = extraResult.Rule;
return isValid;
}

Expand Down
13 changes: 13 additions & 0 deletions Emby.Server.Implementations/Localization/Core/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1167,16 +1167,23 @@
: 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));

Check warning on line 1175 in Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this null-forgiving operator; nullable warnings are disabled here.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AZ-jJWAlc56Q18shfrnm&open=AZ-jJWAlc56Q18shfrnm&pullRequest=17456

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))

Check warning on line 1186 in Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this null-forgiving operator; nullable warnings are disabled here.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AZ-jJWAlc56Q18shfrnn&open=AZ-jJWAlc56Q18shfrnn&pullRequest=17456
.Distinct();

Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id);
Expand All @@ -1188,9 +1195,9 @@

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));

Check warning on line 1200 in Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this null-forgiving operator; nullable warnings are disabled here.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AZ-jJWAlc56Q18shfrno&open=AZ-jJWAlc56Q18shfrno&pullRequest=17456

Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id);

Expand All @@ -1201,9 +1208,9 @@

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));

Check warning on line 1213 in Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this null-forgiving operator; nullable warnings are disabled here.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AZ-jJWAlc56Q18shfrnp&open=AZ-jJWAlc56Q18shfrnp&pullRequest=17456

Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id);

Expand All @@ -1214,9 +1221,9 @@

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));

Check warning on line 1226 in Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this null-forgiving operator; nullable warnings are disabled here.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AZ-jJWAlc56Q18shfrnq&open=AZ-jJWAlc56Q18shfrnq&pullRequest=17456

Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id);

Expand Down
Loading
Loading