aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations/Library
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations/Library')
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs149
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs6
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs14
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchManager.cs13
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs38
-rw-r--r--Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs8
6 files changed, 197 insertions, 31 deletions
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 983ecced02..5db3b80386 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -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;
@@ -2371,6 +2376,7 @@ namespace Emby.Server.Implementations.Library
{
altVideo.OwnerId = video.Id;
altVideo.SetPrimaryVersionId(video.Id);
+ altVideo.IsInMixedFolder = video.IsInMixedFolder;
// ResolveAlternateVersion only sees the alternate's primary file.
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
// detect its parts from sibling files so its AdditionalParts persist.
@@ -2556,6 +2562,8 @@ namespace Emby.Server.Implementations.Library
item.DateLastSaved = DateTime.UtcNow;
}
+ ForgetDroppedLocalAlternateVersions(items);
+
// Resolve and add any local alternate version items that don't exist yet
// This ensures they exist in the database when LinkedChildren are processed
var allItems = new List<BaseItem>(items);
@@ -2584,6 +2592,7 @@ namespace Emby.Server.Implementations.Library
{
altVideo.OwnerId = video.Id;
altVideo.SetPrimaryVersionId(video.Id);
+ altVideo.IsInMixedFolder = video.IsInMixedFolder;
// ResolveAlternateVersion only sees the alternate's primary file.
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
// detect its parts from sibling files so its AdditionalParts persist.
@@ -2644,6 +2653,30 @@ namespace Emby.Server.Implementations.Library
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
+ /// <summary>
+ /// Forgets the cached local alternate versions of the supplied items that they no longer list.
+ /// </summary>
+ /// <param name="items">The items about to be saved.</param>
+ private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items)
+ {
+ foreach (var video in items.OfType<Video>())
+ {
+ var videoType = video.GetType();
+ var keptIds = video.LocalAlternateVersions
+ .Where(path => !string.IsNullOrEmpty(path))
+ .Select(path => GetNewItemId(path, videoType))
+ .ToHashSet();
+
+ foreach (var versionId in GetLocalAlternateVersionIds(video))
+ {
+ if (!keptIds.Contains(versionId))
+ {
+ _cache.TryRemove(versionId, out _);
+ }
+ }
+ }
+ }
+
/// <inheritdoc />
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
{
@@ -3280,9 +3313,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 +3331,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);
+ }
+ }
+
+ 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);
@@ -3333,10 +3383,18 @@ namespace Emby.Server.Implementations.Library
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)
@@ -3344,7 +3402,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 +3410,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 +4011,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);
}
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs
index 6ba4a7bce6..a0f75e4ddb 100644
--- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs
@@ -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;
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
index 74c1f69616..d6513fc79c 100644
--- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using Emby.Server.Implementations.Playlists;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Library;
@@ -46,6 +47,19 @@ namespace Emby.Server.Implementations.Library.Resolvers
};
}
+ // Anything directly inside the internal playlists folder is a playlist, even when its
+ // playlist.xml is missing: failing to resolve here makes the library scan treat the
+ // playlist as deleted from disk and remove it, taking its items with it.
+ if (args.Parent is PlaylistsFolder)
+ {
+ return new Playlist
+ {
+ Path = args.Path,
+ Name = filename,
+ OpenAccess = true
+ };
+ }
+
// It's a directory-based playlist if the directory contains a playlist file
IEnumerable<string> filePaths;
try
diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs
index a5be3f07bd..01f9062734 100644
--- a/Emby.Server.Implementations/Library/Search/SearchManager.cs
+++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs
@@ -118,7 +118,7 @@ public class SearchManager : ISearchManager
var user = _userManager.GetUserById(query.UserId.Value);
if (user is not null)
{
- results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
+ results = await FilterByUserAccessAsync(results, user, query, cancellationToken).ConfigureAwait(false);
}
}
@@ -128,13 +128,14 @@ public class SearchManager : ISearchManager
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
IReadOnlyList<SearchResult> candidates,
User user,
+ SearchProviderQuery query,
CancellationToken cancellationToken)
{
- // SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
- // TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
- // because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
- var accessFilter = new InternalItemsQuery(user);
- _libraryManager.ConfigureUserAccess(accessFilter, user);
+ // SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds
+ // for the user's accessible libraries. The candidate ids are applied to the query below
+ // rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when
+ // ItemIds is non-empty.
+ var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager);
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
diff --git a/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs
new file mode 100644
index 0000000000..6e3f01de13
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs
@@ -0,0 +1,38 @@
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+
+namespace Emby.Server.Implementations.Library.Search;
+
+/// <summary>
+/// Builds the access filter that decides which items a search may return for a user.
+/// </summary>
+internal static class SearchQueryAccessFilter
+{
+ /// <summary>
+ /// Builds an access filter carrying the search's library access and type filters.
+ /// </summary>
+ /// <param name="user">The user the search runs for.</param>
+ /// <param name="query">The search query.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <returns>The access filter.</returns>
+ public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager)
+ {
+ // The type filters have to travel with the access filter: a by-name item belongs to no
+ // library, so it carries no TopParentId to match, and the library filter only knows to
+ // exempt it when the query says those types are wanted. A search scoped to a parent gets
+ // no exemption because a by-name item has no parent to descend from either.
+ var accessFilter = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = query.IncludeItemTypes,
+ ExcludeItemTypes = query.ExcludeItemTypes,
+ IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty()
+ };
+
+ // ConfigureUserAccess populates TopParentIds for the libraries the user may open.
+ libraryManager.ConfigureUserAccess(accessFilter, user);
+
+ return accessFilter;
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
index bc766f1c8c..c4d3b249d5 100644
--- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
+++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
@@ -114,7 +114,7 @@ public class SqlSearchProvider : IInternalSearchProvider
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
- dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
+ dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
@@ -196,8 +196,9 @@ public class SqlSearchProvider : IInternalSearchProvider
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
JellyfinDbContext dbContext,
IQueryable<BaseItemEntity> query,
- Guid? userId)
+ SearchProviderQuery searchQuery)
{
+ var userId = searchQuery.UserId;
if (!userId.HasValue || userId.Value.IsEmpty())
{
return query;
@@ -209,8 +210,7 @@ public class SqlSearchProvider : IInternalSearchProvider
return query;
}
- var accessFilter = new InternalItemsQuery(user);
- _libraryManager.ConfigureUserAccess(accessFilter, user);
+ var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager);
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
}