From 07a802d8fa93460c9f2a7f42da7a1f14a893a322 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 3 May 2026 23:33:56 +0200 Subject: Implement search providers --- .../Library/Search/SearchManager.cs | 443 +++++++++++++++++++++ .../Library/Search/SqlSearchProvider.cs | 200 ++++++++++ .../Library/SearchEngine.cs | 200 ---------- 3 files changed, 643 insertions(+), 200 deletions(-) create mode 100644 Emby.Server.Implementations/Library/Search/SearchManager.cs create mode 100644 Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs delete mode 100644 Emby.Server.Implementations/Library/SearchEngine.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs new file mode 100644 index 0000000000..d4c3302239 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -0,0 +1,443 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Search; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.Search; + +/// +/// Manages search providers and orchestrates search operations. +/// +public class SearchManager : ISearchManager +{ + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDbContextFactory _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; + private readonly ILogger _logger; + private IExternalSearchProvider[] _externalProviders = []; + private IInternalSearchProvider[] _internalProviders = []; + + /// + /// Initializes a new instance of the class. + /// + /// The library manager. + /// The user manager. + /// The database context factory. + /// The shared item query helpers. + /// The logger. + public SearchManager( + ILibraryManager libraryManager, + IUserManager userManager, + IDbContextFactory dbProvider, + IItemQueryHelpers queryHelpers, + ILogger logger) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; + _logger = logger; + } + + /// + public void AddParts(IEnumerable providers) + { + var allProviders = providers.OrderBy(p => p.Priority).ToArray(); + + _externalProviders = allProviders.OfType().ToArray(); + _internalProviders = allProviders.OfType().ToArray(); + + _logger.LogInformation( + "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}", + _externalProviders.Length, + string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")), + string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})"))); + } + + /// + public IReadOnlyList GetProviders() + { + return [.. _externalProviders, .. _internalProviders]; + } + + /// + public async Task> GetSearchResultsAsync( + SearchProviderQuery query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var searchTerm = query.SearchTerm.Trim().RemoveDiacritics(); + + var results = await CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); + if (results.Count == 0 && _internalProviders.Length > 0) + { + _logger.LogDebug("No results from external providers, falling back to internal providers"); + results = await CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); + } + + // External providers don't know about user permissions, so they may return IDs from + // hidden libraries or items the user is otherwise blocked from. Filter the candidate + // set to only items this user can access (top-parent libraries, parental rating, + // blocked/allowed tags, owned-item rules) before returning. The Items controller's + // second roundtrip via folder.GetItems applies most of these again, but it does not + // restrict by TopParentIds when ItemIds is set, leaving a gap that this closes. + if (results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) + { + var user = _userManager.GetUserById(query.UserId.Value); + if (user is not null) + { + results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false); + } + } + + return results; + } + + private async Task> FilterByUserAccessAsync( + IReadOnlyList candidates, + User user, + 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); + + var candidateIds = new Guid[candidates.Count]; + for (var i = 0; i < candidates.Count; i++) + { + candidateIds[i] = candidates[i].ItemId; + } + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .Where(e => candidateIds.Contains(e.Id)); + + baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); + + var allowedIds = await baseQuery + .Select(e => e.Id) + .ToHashSetAsync(cancellationToken) + .ConfigureAwait(false); + + if (allowedIds.Count == candidates.Count) + { + return candidates; + } + + var filtered = new List(allowedIds.Count); + foreach (var c in candidates) + { + if (allowedIds.Contains(c.ItemId)) + { + filtered.Add(c); + } + } + + if (filtered.Count < candidates.Count) + { + _logger.LogDebug( + "Dropped {Dropped} of {Total} search candidates due to user access filtering", + candidates.Count - filtered.Count, + candidates.Count); + } + + return filtered; + } + } + + /// + public async Task> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var providerQuery = BuildProviderQuery(query); + var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false); + if (candidates.Count == 0) + { + return new QueryResult(); + } + + var candidateScores = BuildScoreLookup(candidates); + var user = !query.UserId.IsEmpty() ? _userManager.GetUserById(query.UserId) : null; + + var excludeItemTypes = BuildExcludeItemTypes(query); + var includeItemTypes = BuildIncludeItemTypes(query); + + var internalQuery = new InternalItemsQuery(user) + { + ItemIds = candidateScores.Keys.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [], + MediaTypes = query.MediaTypes.ToArray(), + IncludeItemsByName = !query.ParentId.HasValue, + ParentId = query.ParentId ?? Guid.Empty, + Recursive = true, + IsKids = query.IsKids, + IsMovie = query.IsMovie, + IsNews = query.IsNews, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + DtoOptions = new DtoOptions + { + Fields = + [ + ItemFields.AirTime, + ItemFields.DateCreated, + ItemFields.ChannelInfo, + ItemFields.ParentId + ] + } + }; + + // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name + // rather than being stored as regular library items. They require special handling: + // 1. Convert ParentId to AncestorIds (to filter by library folder) + // 2. Set IncludeItemsByName = true (to include these virtual items in results) + // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally) + // 4. Use GetAllArtists() instead of GetItemList() to query the artist index + IReadOnlyList items; + if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) + { + if (!internalQuery.ParentId.IsEmpty()) + { + internalQuery.AncestorIds = [internalQuery.ParentId]; + internalQuery.ParentId = Guid.Empty; + } + + internalQuery.IncludeItemsByName = true; + internalQuery.IncludeItemTypes = []; + items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList(); + } + else + { + items = _libraryManager.GetItemList(internalQuery); + } + + var orderedResults = items + .Select(item => new SearchHintInfo { Item = item }) + .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f)) + .ToList(); + + var totalCount = orderedResults.Count; + + if (query.StartIndex.HasValue) + { + orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList(); + } + + if (query.Limit.HasValue) + { + orderedResults = orderedResults.Take(query.Limit.Value).ToList(); + } + + return new QueryResult(query.StartIndex, totalCount, orderedResults); + } + + private async Task> CollectFromProvidersAsync( + IEnumerable providers, + SearchProviderQuery providerQuery, + string searchTerm, + CancellationToken cancellationToken) + { + var bestScores = new Dictionary(); + var requestedLimit = providerQuery.Limit ?? 100; + + foreach (var provider in providers.Where(p => p.CanSearch(providerQuery))) + { + if (bestScores.Count >= requestedLimit || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (provider is IExternalSearchProvider externalProvider) + { + var count = 0; + await foreach (var result in externalProvider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) + { + UpdateBestScore(bestScores, result); + count++; + if (bestScores.Count >= requestedLimit) + { + break; + } + } + + _logger.LogDebug( + "External provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", + provider.Name, + count, + searchTerm); + } + else + { + var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); + foreach (var result in candidates) + { + UpdateBestScore(bestScores, result); + } + + _logger.LogDebug( + "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", + provider.Name, + candidates.Count, + searchTerm); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); + } + } + + return bestScores + .Select(kvp => new SearchResult(kvp.Key, kvp.Value)) + .OrderByDescending(r => r.Score) + .Take(requestedLimit) + .ToList(); + } + + private static void UpdateBestScore(Dictionary bestScores, SearchResult result) + { + if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore) + { + bestScores[result.ItemId] = result.Score; + } + } + + private static Dictionary BuildScoreLookup(IReadOnlyList results) + { + var lookup = new Dictionary(results.Count); + foreach (var result in results) + { + lookup[result.ItemId] = result.Score; + } + + return lookup; + } + + private static SearchProviderQuery BuildProviderQuery(SearchQuery query) + { + var excludeItemTypes = BuildExcludeItemTypes(query); + var includeItemTypes = BuildIncludeItemTypes(query); + + // Remove any excluded types from includes + if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0) + { + includeItemTypes.RemoveAll(excludeItemTypes.Contains); + } + + return new SearchProviderQuery + { + SearchTerm = query.SearchTerm, + UserId = query.UserId.IsEmpty() ? null : query.UserId, + IncludeItemTypes = includeItemTypes.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + MediaTypes = query.MediaTypes.ToArray(), + Limit = query.Limit, + ParentId = query.ParentId + }; + } + + private static List BuildExcludeItemTypes(SearchQuery query) + { + var excludeItemTypes = query.ExcludeItemTypes.ToList(); + + excludeItemTypes.Add(BaseItemKind.Year); + excludeItemTypes.Add(BaseItemKind.Folder); + excludeItemTypes.Add(BaseItemKind.CollectionFolder); + + if (!query.IncludeGenres) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Genre); + AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); + } + + if (!query.IncludePeople) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Person); + } + + if (!query.IncludeStudios) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Studio); + } + + if (!query.IncludeArtists) + { + AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); + } + + return excludeItemTypes; + } + + private static List BuildIncludeItemTypes(SearchQuery query) + { + var includeItemTypes = query.IncludeItemTypes.ToList(); + if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) + { + if (!query.IncludeMedia) + { + AddIfMissing(includeItemTypes, BaseItemKind.Genre); + AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); + } + } + + if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) + { + if (!query.IncludeMedia) + { + AddIfMissing(includeItemTypes, BaseItemKind.Person); + } + } + + if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) + { + if (!query.IncludeMedia) + { + AddIfMissing(includeItemTypes, BaseItemKind.Studio); + } + } + + if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) + { + if (!query.IncludeMedia) + { + AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); + } + } + + return includeItemTypes; + } + + private static void AddIfMissing(List list, BaseItemKind value) + { + if (!list.Contains(value)) + { + list.Add(value); + } + } +} diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs new file mode 100644 index 0000000000..53c1cbbb79 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -0,0 +1,200 @@ +#pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace Emby.Server.Implementations.Library.Search; + +/// +/// Built-in SQL-based search provider that queries the library database directly. +/// +public class SqlSearchProvider : IInternalSearchProvider +{ + private const int DefaultSearchLimit = 100; + private const float ExactMatchScore = 100f; + private const float PrefixMatchScore = 80f; + private const float WordPrefixMatchScore = 75f; + private const float ContainsMatchScore = 50f; + + private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + private readonly IDbContextFactory _dbProvider; + private readonly IItemTypeLookup _itemTypeLookup; + + /// + /// Initializes a new instance of the class. + /// + /// The database context factory. + /// The item type lookup. + public SqlSearchProvider(IDbContextFactory dbProvider, IItemTypeLookup itemTypeLookup) + { + _dbProvider = dbProvider; + _itemTypeLookup = itemTypeLookup; + } + + /// + public string Name => "Database"; + + /// + public MetadataPluginType Type => MetadataPluginType.SearchProvider; + + /// + public int Priority => 100; // Low priority - runs as fallback + + /// + public bool CanSearch(SearchProviderQuery query) + { + // SQL search can always handle any query + return true; + } + + /// + public async Task> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics(); + if (string.IsNullOrEmpty(rawSearchTerm)) + { + return []; + } + + var cleanSearchTerm = rawSearchTerm.GetCleanValue(); + if (string.IsNullOrEmpty(cleanSearchTerm)) + { + return []; + } + + var cleanPrefix = cleanSearchTerm + " "; + // OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName, + // so match it via a case-insensitive LIKE rather than a per-row case conversion + // that may not translate to SQL on every provider. + var likeOriginal = $"%{rawSearchTerm}%"; + var limit = query.Limit ?? DefaultSearchLimit; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + // Lightweight projection: select only what's needed to score and identify items. + var dbQuery = dbContext.BaseItems + .AsNoTracking() + .Where(e => e.Id != _placeholderId) + .Where(e => !e.IsVirtualItem) + .Where(e => e.CleanName!.Contains(cleanSearchTerm) + || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))); + + dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); + dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); + dbQuery = ApplyParentFilter(dbQuery, query.ParentId); + + // 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 + // directly without any per-row case conversion. Items that match only via + // OriginalTitle fall through to the Contains tier. + // Tie-break by Id for deterministic ordering so the explicit OrderBy + Take + // satisfies EF Core's row-limiting-with-OrderBy requirement. + var scored = dbQuery.Select(e => new + { + e.Id, + Score = + (e.CleanName == cleanSearchTerm) ? ExactMatchScore + : e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore + : e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore + : ContainsMatchScore + }); + + var top = await scored + .OrderByDescending(x => x.Score) + .ThenBy(x => x.Id) + .Take(limit) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var results = new List(top.Count); + foreach (var row in top) + { + results.Add(new SearchResult(row.Id, row.Score)); + } + + return results; + } + } + + private IQueryable ApplyTypeFilter( + IQueryable query, + BaseItemKind[] includeItemTypes, + BaseItemKind[] excludeItemTypes) + { + if (includeItemTypes.Length > 0) + { + var includeTypeNames = MapKindsToTypeNames(includeItemTypes); + if (includeTypeNames.Count > 0) + { + query = query.Where(e => includeTypeNames.Contains(e.Type)); + } + } + else if (excludeItemTypes.Length > 0) + { + var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes); + if (excludeTypeNames.Count > 0) + { + query = query.Where(e => !excludeTypeNames.Contains(e.Type)); + } + } + + return query; + } + + private static IQueryable ApplyMediaTypeFilter( + IQueryable query, + MediaType[] mediaTypes) + { + if (mediaTypes.Length == 0) + { + return query; + } + + var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray(); + return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType)); + } + + private static IQueryable ApplyParentFilter( + IQueryable query, + Guid? parentId) + { + if (!parentId.HasValue || parentId.Value.IsEmpty()) + { + return query; + } + + var pid = parentId.Value; + return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid)); + } + + private List MapKindsToTypeNames(BaseItemKind[] kinds) + { + var list = new List(kinds.Length); + foreach (var kind in kinds) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null) + { + list.Add(name); + } + } + + return list; + } +} diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs deleted file mode 100644 index c682118597..0000000000 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ /dev/null @@ -1,200 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Enums; -using Jellyfin.Extensions; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Search; - -namespace Emby.Server.Implementations.Library -{ - public class SearchEngine : ISearchEngine - { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - - public SearchEngine(ILibraryManager libraryManager, IUserManager userManager) - { - _libraryManager = libraryManager; - _userManager = userManager; - } - - public QueryResult GetSearchHints(SearchQuery query) - { - User? user = null; - if (!query.UserId.IsEmpty()) - { - user = _userManager.GetUserById(query.UserId); - } - - var results = GetSearchHints(query, user); - var totalRecordCount = results.Count; - - if (query.StartIndex.HasValue) - { - results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value); - } - - if (query.Limit.HasValue && query.Limit.Value > 0) - { - results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count)); - } - - return new QueryResult( - query.StartIndex, - totalRecordCount, - results); - } - - private static void AddIfMissing(List list, BaseItemKind value) - { - if (!list.Contains(value)) - { - list.Add(value); - } - } - - /// - /// Gets the search hints. - /// - /// The query. - /// The user. - /// IEnumerable{SearchHintResult}. - /// query.SearchTerm is null or empty. - private List GetSearchHints(SearchQuery query, User? user) - { - var searchTerm = query.SearchTerm; - - ArgumentException.ThrowIfNullOrEmpty(searchTerm); - - searchTerm = searchTerm.Trim().RemoveDiacritics(); - - var excludeItemTypes = query.ExcludeItemTypes.ToList(); - var includeItemTypes = query.IncludeItemTypes.ToList(); - - excludeItemTypes.Add(BaseItemKind.Year); - excludeItemTypes.Add(BaseItemKind.Folder); - - if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Genre); - AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Genre); - AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); - } - - if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Person); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Person); - } - - if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Studio); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Studio); - } - - if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); - } - - AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder); - AddIfMissing(excludeItemTypes, BaseItemKind.Folder); - var mediaTypes = query.MediaTypes.ToList(); - - if (includeItemTypes.Count > 0) - { - excludeItemTypes.Clear(); - mediaTypes.Clear(); - } - - var searchQuery = new InternalItemsQuery(user) - { - SearchTerm = searchTerm, - ExcludeItemTypes = excludeItemTypes.ToArray(), - IncludeItemTypes = includeItemTypes.ToArray(), - Limit = query.Limit, - IncludeItemsByName = !query.ParentId.HasValue, - ParentId = query.ParentId ?? Guid.Empty, - OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, - Recursive = true, - - IsKids = query.IsKids, - IsMovie = query.IsMovie, - IsNews = query.IsNews, - IsSeries = query.IsSeries, - IsSports = query.IsSports, - MediaTypes = mediaTypes.ToArray(), - - DtoOptions = new DtoOptions - { - Fields = new ItemFields[] - { - ItemFields.AirTime, - ItemFields.DateCreated, - ItemFields.ChannelInfo, - ItemFields.ParentId - } - } - }; - - IReadOnlyList mediaItems; - - if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) - { - if (!searchQuery.ParentId.IsEmpty()) - { - searchQuery.AncestorIds = [searchQuery.ParentId]; - searchQuery.ParentId = Guid.Empty; - } - - searchQuery.IncludeItemsByName = true; - searchQuery.IncludeItemTypes = Array.Empty(); - mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList(); - } - else - { - mediaItems = _libraryManager.GetItemList(searchQuery); - } - - return mediaItems.Select(i => new SearchHintInfo - { - Item = i - }).ToList(); - } - } -} -- cgit v1.2.3 From ea7000a4d6bec1cd289eb947b1ad8b7a756d41b7 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 4 May 2026 02:20:48 +0200 Subject: Fix Sonar complaints --- .../Library/Search/SearchManager.cs | 151 +++++++++-------- .../Item/BaseItemRepository.ByName.cs | 187 +++++++++++---------- 2 files changed, 178 insertions(+), 160 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index d4c3302239..af916ec9a7 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -145,15 +145,7 @@ public class SearchManager : ISearchManager return candidates; } - var filtered = new List(allowedIds.Count); - foreach (var c in candidates) - { - if (allowedIds.Contains(c.ItemId)) - { - filtered.Add(c); - } - } - + var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList(); if (filtered.Count < candidates.Count) { _logger.LogDebug( @@ -271,46 +263,7 @@ public class SearchManager : ISearchManager break; } - try - { - if (provider is IExternalSearchProvider externalProvider) - { - var count = 0; - await foreach (var result in externalProvider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) - { - UpdateBestScore(bestScores, result); - count++; - if (bestScores.Count >= requestedLimit) - { - break; - } - } - - _logger.LogDebug( - "External provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", - provider.Name, - count, - searchTerm); - } - else - { - var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); - foreach (var result in candidates) - { - UpdateBestScore(bestScores, result); - } - - _logger.LogDebug( - "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", - provider.Name, - candidates.Count, - searchTerm); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); - } + await CollectFromProviderAsync(provider, providerQuery, searchTerm, bestScores, requestedLimit, cancellationToken).ConfigureAwait(false); } return bestScores @@ -320,6 +273,68 @@ public class SearchManager : ISearchManager .ToList(); } + private async Task CollectFromProviderAsync( + ISearchProvider provider, + SearchProviderQuery providerQuery, + string searchTerm, + Dictionary bestScores, + int requestedLimit, + CancellationToken cancellationToken) + { + try + { + var count = provider is IExternalSearchProvider externalProvider + ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, bestScores, requestedLimit, cancellationToken).ConfigureAwait(false) + : await CollectFromInternalProviderAsync(provider, providerQuery, bestScores, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug( + "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", + provider.Name, + count, + searchTerm); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); + } + } + + private static async Task CollectFromExternalProviderAsync( + IExternalSearchProvider provider, + SearchProviderQuery providerQuery, + Dictionary bestScores, + int requestedLimit, + CancellationToken cancellationToken) + { + var count = 0; + await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) + { + UpdateBestScore(bestScores, result); + count++; + if (bestScores.Count >= requestedLimit) + { + break; + } + } + + return count; + } + + private static async Task CollectFromInternalProviderAsync( + ISearchProvider provider, + SearchProviderQuery providerQuery, + Dictionary bestScores, + CancellationToken cancellationToken) + { + var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); + foreach (var result in candidates) + { + UpdateBestScore(bestScores, result); + } + + return candidates.Count; + } + private static void UpdateBestScore(Dictionary bestScores, SearchResult result) { if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore) @@ -397,42 +412,38 @@ public class SearchManager : ISearchManager private static List BuildIncludeItemTypes(SearchQuery query) { var includeItemTypes = query.IncludeItemTypes.ToList(); - if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) + if (query.IncludeMedia) { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Genre); - AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); - } + return includeItemTypes; } - if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) + if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre)) { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Person); - } + AddIfMissing(includeItemTypes, BaseItemKind.Genre); + AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); } - if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) + if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person)) { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Studio); - } + AddIfMissing(includeItemTypes, BaseItemKind.Person); } - if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) + if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio)) { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); - } + AddIfMissing(includeItemTypes, BaseItemKind.Studio); + } + + if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist)) + { + AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); } return includeItemTypes; } + private static bool IsEmptyOrContains(List list, BaseItemKind value) + => list.Count == 0 || list.Contains(value); + private static void AddIfMissing(List list, BaseItemKind value) { if (!list.Contains(value)) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index f557e3732a..7c64d9854d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -167,43 +167,7 @@ public sealed partial class BaseItemRepository // Build the master query and collapse rows that share a PresentationUniqueKey // (e.g. alternate versions) by picking the lowest Id per group. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); - - IQueryable orderedMasterQuery; - if (!string.IsNullOrEmpty(filter.SearchTerm)) - { - var cleanSearchTerm = filter.SearchTerm.GetCleanValue(); - var cleanSearchPrefix = cleanSearchTerm + " "; - - orderedMasterQuery = masterQuery - .Select(e => new - { - e.Id, - e.PresentationUniqueKey, - e.SortName, - Score = (e.CleanName == cleanSearchTerm) ? 0 - : e.CleanName!.StartsWith(cleanSearchTerm) ? 1 - : e.CleanName!.Contains(cleanSearchPrefix) ? 2 - : 3 - }) - .GroupBy(x => x.PresentationUniqueKey) - .Select(g => new - { - Id = g.Min(x => x.Id), - Score = g.Min(x => x.Score), - SortName = g.Min(x => x.SortName) - }) - .OrderBy(x => x.Score) - .ThenBy(x => x.SortName) - .Select(x => x.Id); - } - else - { - orderedMasterQuery = masterQuery - .GroupBy(e => e.PresentationUniqueKey) - .Select(g => new { Id = g.Min(e => e.Id), SortName = g.Min(e => e.SortName) }) - .OrderBy(x => x.SortName) - .Select(x => x.Id); - } + var orderedMasterQuery = BuildOrderedMasterQuery(masterQuery, filter.SearchTerm); var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) @@ -229,60 +193,10 @@ public sealed partial class BaseItemRepository query = ApplyOrder(query, filter, context); + result.StartIndex = filter.StartIndex ?? 0; if (filter.IncludeItemTypes.Length > 0) { - var typeSubQuery = new InternalItemsQuery(filter.User) - { - ExcludeItemTypes = filter.ExcludeItemTypes, - IncludeItemTypes = filter.IncludeItemTypes, - MediaTypes = filter.MediaTypes, - AncestorIds = filter.AncestorIds, - ExcludeItemIds = filter.ExcludeItemIds, - ItemIds = filter.ItemIds, - TopParentIds = filter.TopParentIds, - ParentId = filter.ParentId, - IsPlayed = filter.IsPlayed - }; - - var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) - .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); - - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; - var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; - var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; - var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; - var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - var itemIds = itemCountQuery.Select(e => e.Id); - - // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) - // Instead, start from ItemValueMaps and join with BaseItems - var countsByCleanName = context.ItemValuesMap - .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) - .Where(ivm => itemIds.Contains(ivm.ItemId)) - .Join( - context.BaseItems, - ivm => ivm.ItemId, - e => e.Id, - (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) - .GroupBy(x => new { x.CleanName, x.Type }) - .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) - .GroupBy(x => x.CleanName) - .ToDictionary( - g => g.Key, - g => new ItemCounts - { - SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count), - EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count), - MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count), - AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count), - ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count), - SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count), - TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count), - }); - - result.StartIndex = filter.StartIndex ?? 0; + var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes); result.Items = [ .. query @@ -300,7 +214,6 @@ public sealed partial class BaseItemRepository } else { - result.StartIndex = filter.StartIndex ?? 0; result.Items = [ .. query @@ -314,4 +227,98 @@ public sealed partial class BaseItemRepository return result; } + + private static IQueryable BuildOrderedMasterQuery(IQueryable masterQuery, string? searchTerm) + { + if (string.IsNullOrEmpty(searchTerm)) + { + return masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => new { Id = g.Min(e => e.Id), SortName = g.Min(e => e.SortName) }) + .OrderBy(x => x.SortName) + .Select(x => x.Id); + } + + var cleanSearchTerm = searchTerm.GetCleanValue(); + var cleanSearchPrefix = cleanSearchTerm + " "; + + return masterQuery + .Select(e => new + { + e.Id, + e.PresentationUniqueKey, + e.SortName, + Score = (e.CleanName == cleanSearchTerm) ? 0 + : e.CleanName!.StartsWith(cleanSearchTerm) ? 1 + : e.CleanName!.Contains(cleanSearchPrefix) ? 2 + : 3 + }) + .GroupBy(x => x.PresentationUniqueKey) + .Select(g => new + { + Id = g.Min(x => x.Id), + Score = g.Min(x => x.Score), + SortName = g.Min(x => x.SortName) + }) + .OrderBy(x => x.Score) + .ThenBy(x => x.SortName) + .Select(x => x.Id); + } + + private Dictionary BuildItemCountsByCleanName( + Database.Implementations.JellyfinDbContext context, + InternalItemsQuery filter, + IReadOnlyList itemValueTypes) + { + var typeSubQuery = new InternalItemsQuery(filter.User) + { + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ExcludeItemIds = filter.ExcludeItemIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsPlayed = filter.IsPlayed + }; + + var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) + .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; + var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; + var itemIds = itemCountQuery.Select(e => e.Id); + + // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) + // Instead, start from ItemValueMaps and join with BaseItems + return context.ItemValuesMap + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .Where(ivm => itemIds.Contains(ivm.ItemId)) + .Join( + context.BaseItems, + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) + .GroupBy(x => new { x.CleanName, x.Type }) + .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) + .GroupBy(x => x.CleanName) + .ToDictionary( + g => g.Key, + g => new ItemCounts + { + SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count), + EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count), + MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count), + AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count), + ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count), + SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count), + TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count), + }); + } } -- cgit v1.2.3 From 5e82b61bab8c9461624fd2095fc9ccd11e33ce8d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 4 May 2026 23:40:07 +0200 Subject: Apply review suggestions --- .../Library/Search/SearchManager.cs | 93 ++++++++++------------ .../Library/Search/SqlSearchProvider.cs | 52 +++++++++--- .../JellyfinQueryHelperExtensions.cs | 15 ++-- 3 files changed, 90 insertions(+), 70 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index af916ec9a7..39fff42d9b 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -85,19 +85,20 @@ public class SearchManager : ISearchManager var searchTerm = query.SearchTerm.Trim().RemoveDiacritics(); var results = await CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); + var fromExternal = results.Count > 0; if (results.Count == 0 && _internalProviders.Length > 0) { _logger.LogDebug("No results from external providers, falling back to internal providers"); results = await CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); } - // External providers don't know about user permissions, so they may return IDs from - // hidden libraries or items the user is otherwise blocked from. Filter the candidate - // set to only items this user can access (top-parent libraries, parental rating, - // blocked/allowed tags, owned-item rules) before returning. The Items controller's - // second roundtrip via folder.GetItems applies most of these again, but it does not - // restrict by TopParentIds when ItemIds is set, leaving a gap that this closes. - if (results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) + // Internal providers apply user-access filtering inline in their queries. External + // providers don't know about user permissions, so they may return IDs from hidden + // libraries or items the user is otherwise blocked from. Run the post-filter only + // when results came from externals to close that gap. The Items controller's second + // roundtrip via folder.GetItems applies most of these again, but it does not restrict + // by TopParentIds when ItemIds is set. + if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) { var user = _userManager.GetUserById(query.UserId.Value); if (user is not null) @@ -120,31 +121,28 @@ public class SearchManager : ISearchManager var accessFilter = new InternalItemsQuery(user); _libraryManager.ConfigureUserAccess(accessFilter, user); - var candidateIds = new Guid[candidates.Count]; - for (var i = 0; i < candidates.Count; i++) - { - candidateIds[i] = candidates[i].ItemId; - } + Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)]; var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { var baseQuery = dbContext.BaseItems .AsNoTracking() - .Where(e => candidateIds.Contains(e.Id)); + .WhereOneOrMany(candidateIds, e => e.Id); baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); + var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false); + if (allowedCount == candidates.Count) + { + return candidates; + } + var allowedIds = await baseQuery .Select(e => e.Id) .ToHashSetAsync(cancellationToken) .ConfigureAwait(false); - if (allowedIds.Count == candidates.Count) - { - return candidates; - } - var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList(); if (filtered.Count < candidates.Count) { @@ -253,17 +251,24 @@ public class SearchManager : ISearchManager string searchTerm, CancellationToken cancellationToken) { - var bestScores = new Dictionary(); var requestedLimit = providerQuery.Limit ?? 100; + var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray(); + if (applicable.Length == 0) + { + return []; + } + + var perProvider = await Task.WhenAll( + applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken))) + .ConfigureAwait(false); - foreach (var provider in providers.Where(p => p.CanSearch(providerQuery))) + var bestScores = new Dictionary(); + foreach (var providerResults in perProvider) { - if (bestScores.Count >= requestedLimit || cancellationToken.IsCancellationRequested) + foreach (var result in providerResults) { - break; + UpdateBestScore(bestScores, result); } - - await CollectFromProviderAsync(provider, providerQuery, searchTerm, bestScores, requestedLimit, cancellationToken).ConfigureAwait(false); } return bestScores @@ -273,66 +278,50 @@ public class SearchManager : ISearchManager .ToList(); } - private async Task CollectFromProviderAsync( + private async Task> CollectFromProviderAsync( ISearchProvider provider, SearchProviderQuery providerQuery, string searchTerm, - Dictionary bestScores, int requestedLimit, CancellationToken cancellationToken) { try { - var count = provider is IExternalSearchProvider externalProvider - ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, bestScores, requestedLimit, cancellationToken).ConfigureAwait(false) - : await CollectFromInternalProviderAsync(provider, providerQuery, bestScores, cancellationToken).ConfigureAwait(false); + var results = provider is IExternalSearchProvider externalProvider + ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false) + : await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); _logger.LogDebug( "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", provider.Name, - count, + results.Count, searchTerm); + return results; } catch (Exception ex) { _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); + return []; } } - private static async Task CollectFromExternalProviderAsync( + private static async Task> CollectFromExternalProviderAsync( IExternalSearchProvider provider, SearchProviderQuery providerQuery, - Dictionary bestScores, int requestedLimit, CancellationToken cancellationToken) { - var count = 0; + var results = new List(); await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) { - UpdateBestScore(bestScores, result); - count++; - if (bestScores.Count >= requestedLimit) + results.Add(result); + if (results.Count >= requestedLimit) { break; } } - return count; - } - - private static async Task CollectFromInternalProviderAsync( - ISearchProvider provider, - SearchProviderQuery providerQuery, - Dictionary bestScores, - CancellationToken cancellationToken) - { - var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); - foreach (var result in candidates) - { - UpdateBestScore(bestScores, result); - } - - return candidates.Count; + return results; } private static void UpdateBestScore(Dictionary bestScores, SearchResult result) diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs index 53c1cbbb79..bc766f1c8c 100644 --- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -10,6 +10,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Configuration; @@ -32,16 +33,30 @@ public class SqlSearchProvider : IInternalSearchProvider private readonly IDbContextFactory _dbProvider; private readonly IItemTypeLookup _itemTypeLookup; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IItemQueryHelpers _queryHelpers; /// /// Initializes a new instance of the class. /// /// The database context factory. /// The item type lookup. - public SqlSearchProvider(IDbContextFactory dbProvider, IItemTypeLookup itemTypeLookup) + /// The library manager. + /// The user manager. + /// The shared item query helpers. + public SqlSearchProvider( + IDbContextFactory dbProvider, + IItemTypeLookup itemTypeLookup, + ILibraryManager libraryManager, + IUserManager userManager, + IItemQueryHelpers queryHelpers) { _dbProvider = dbProvider; _itemTypeLookup = itemTypeLookup; + _libraryManager = libraryManager; + _userManager = userManager; + _queryHelpers = queryHelpers; } /// @@ -99,6 +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); // 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 @@ -116,20 +132,13 @@ public class SqlSearchProvider : IInternalSearchProvider : ContainsMatchScore }); - var top = await scored + return await scored .OrderByDescending(x => x.Score) .ThenBy(x => x.Id) .Take(limit) - .ToListAsync(cancellationToken) + .Select(x => new SearchResult(x.Id, x.Score)) + .ToArrayAsync(cancellationToken) .ConfigureAwait(false); - - var results = new List(top.Count); - foreach (var row in top) - { - results.Add(new SearchResult(row.Id, row.Score)); - } - - return results; } } @@ -184,6 +193,27 @@ public class SqlSearchProvider : IInternalSearchProvider return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid)); } + private IQueryable ApplyUserAccessFilter( + JellyfinDbContext dbContext, + IQueryable query, + Guid? userId) + { + if (!userId.HasValue || userId.Value.IsEmpty()) + { + return query; + } + + var user = _userManager.GetUserById(userId.Value); + if (user is null) + { + return query; + } + + var accessFilter = new InternalItemsQuery(user); + _libraryManager.ConfigureUserAccess(accessFilter, user); + return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter); + } + private List MapKindsToTypeNames(BaseItemKind[] kinds) { var list = new List(kinds.Length); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index e366bdb095..1af7460540 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -224,13 +224,14 @@ public static class JellyfinQueryHelperExtensions var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); - return Expression.Lambda>( - Expression.Call( - null, - containsMethodInfo, - Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), - property.Body), - parameter); + // Threshold picked from microbenchmarks on SQLite: inline IN(const,...) beats a + // parameterized array lookup by ~5-10% up to ~32 elements. + if (oneOf.Count <= 32) + { + return Expression.Lambda>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter); + } + + return Expression.Lambda>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter); } internal static class ParameterReplacer -- cgit v1.2.3 From 97c20e6ac5bf89aa0a29f950b9308036e589de12 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 15 May 2026 14:37:01 +0200 Subject: Fix movie recommendations --- .../Library/LibraryManager.cs | 6 + .../SimilarItems/MovieSimilarItemsProvider.cs | 274 +++++++++++++++++++-- .../Library/SimilarItems/SimilarItemsManager.cs | 17 ++ Jellyfin.Api/Controllers/MoviesController.cs | 248 +++++++++---------- .../Item/PeopleRepository.cs | 27 +- .../Library/IBatchLocalSimilarItemsProvider.cs | 23 ++ MediaBrowser.Controller/Library/ILibraryManager.cs | 9 + .../Library/ISimilarItemsManager.cs | 10 + .../Persistence/IPeopleRepository.cs | 9 + 9 files changed, 475 insertions(+), 148 deletions(-) create mode 100644 MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index f2480679d9..c20a4442eb 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3389,6 +3389,12 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// + public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + { + return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit); + } + public void UpdatePeople(BaseItem item, List people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 93aa0574c0..5660cf30a8 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,36 +1,65 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; namespace Emby.Server.Implementations.Library.SimilarItems; /// -/// Provides similar items for movies and trailers. +/// Provides similar items for movies and trailers using weighted scoring. /// -public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider, IBatchLocalSimilarItemsProvider { - private readonly ILibraryManager _libraryManager; + private const int GenreWeight = 10; + private const int TagWeight = 5; + private const int StudioWeight = 5; + private const int DirectorWeight = 50; + private const int ActorWeight = 15; + + private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions = + [ + (ItemValueType.Genre, GenreWeight), + (ItemValueType.Tags, TagWeight), + (ItemValueType.Studios, StudioWeight) + ]; + + private static readonly (string[] PersonTypes, int Weight)[] _peopleDimensions = + [ + ([nameof(PersonKind.Director)], DirectorWeight), + ([nameof(PersonKind.Actor), nameof(PersonKind.GuestStar)], ActorWeight) + ]; + + private readonly IDbContextFactory _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; private readonly IServerConfigurationManager _serverConfigurationManager; /// /// Initializes a new instance of the class. /// - /// The library manager. + /// The database context factory. + /// The shared query helpers. /// The server configuration manager. public MovieSimilarItemsProvider( - ILibraryManager libraryManager, + IDbContextFactory dbProvider, + IItemQueryHelpers queryHelpers, IServerConfigurationManager serverConfigurationManager) { - _libraryManager = libraryManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; _serverConfigurationManager = serverConfigurationManager; } @@ -41,15 +70,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider MetadataPluginType.LocalSimilarityProvider; /// - public Task> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) + public async Task> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) { - return Task.FromResult(GetSimilarMovieItems(item, query)); + var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; } /// - public Task> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) + public async Task> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) { - return Task.FromResult(GetSimilarMovieItems(item, query)); + var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; } bool ILocalSimilarItemsProvider.Supports(Type itemType) @@ -63,29 +94,230 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) }; - private IReadOnlyList GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) + /// + public Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query) { var includeItemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { includeItemTypes.Add(BaseItemKind.Trailer); includeItemTypes.Add(BaseItemKind.LiveTvProgram); } - var internalQuery = new InternalItemsQuery(query.User) + var limit = query.Limit ?? 50; + var dtoOptions = query.DtoOptions ?? new DtoOptions(); + + using var context = _dbProvider.CreateDbContext(); + + // Phase 1: Score all candidates per source item + var sourceIds = sourceItems.Select(i => i.Id).ToList(); + var perSourceScores = ComputeBatchScores(sourceIds, context); + + var allCandidateIds = new HashSet(); + foreach (var (_, scores) in perSourceScores) + { + allCandidateIds.UnionWith( + scores.OrderByDescending(kvp => kvp.Value) + .Take(limit * 3) + .Select(kvp => kvp.Key)); + } + + var result = new Dictionary>(); + if (allCandidateIds.Count == 0) + { + return Task.FromResult(result); + } + + // Phase 2: One access filter for all candidates + var filter = new InternalItemsQuery(query.User) { - Genres = item.Genres, - Tags = item.Tags, - Limit = query.Limit, - DtoOptions = query.DtoOptions ?? new DtoOptions(), - ExcludeItemIds = [.. query.ExcludeItemIds], IncludeItemTypes = [.. includeItemTypes], + ExcludeItemIds = [.. query.ExcludeItemIds], + DtoOptions = dtoOptions, EnableGroupByMetadataKey = true, EnableTotalRecordCount = false, - OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + IsMovie = true, + IsPlayed = false }; - return _libraryManager.GetItemList(internalQuery); + _queryHelpers.PrepareFilterQuery(filter); + var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); + baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + + var allCandidateIdsList = allCandidateIds.ToList(); + var accessibleItems = baseQuery + .Where(e => allCandidateIdsList.Contains(e.Id)) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToList(); + + // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey + var allOrderedIds = new HashSet(); + var perSourceOrderedIds = new Dictionary>(); + + foreach (var item in sourceItems) + { + if (!perSourceScores.TryGetValue(item.Id, out var scores)) + { + continue; + } + + var orderedIds = accessibleItems + .Where(x => scores.ContainsKey(x.Id)) + .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + .DistinctBy(x => x.PresentationUniqueKey) + .Take(limit) + .Select(x => x.Id) + .ToList(); + + if (orderedIds.Count > 0) + { + perSourceOrderedIds[item.Id] = orderedIds; + allOrderedIds.UnionWith(orderedIds); + } + } + + if (allOrderedIds.Count == 0) + { + return Task.FromResult(result); + } + + // Phase 4: One entity load for all results + var allOrderedIdsList = allOrderedIds.ToList(); + var entitiesById = _queryHelpers.ApplyNavigations( + context.BaseItems.AsNoTracking().Where(e => allOrderedIdsList.Contains(e.Id)), + filter) + .AsSplitQuery() + .AsEnumerable() + .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToDictionary(i => i!.Id); + + // Phase 5: Split by source, preserving score order + foreach (var (sourceId, orderedIds) in perSourceOrderedIds) + { + var items = orderedIds + .Where(entitiesById.ContainsKey) + .Select(id => entitiesById[id]!) + .ToList(); + + if (items.Count > 0) + { + result[sourceId] = items; + } + } + + return Task.FromResult(result); + } + + private Dictionary> ComputeBatchScores(List sourceIds, JellyfinDbContext context) + { + var result = new Dictionary>(); + foreach (var id in sourceIds) + { + result[id] = []; + } + + // Score item-value dimensions (genre, tags, studios) + foreach (var (valueType, weight) in _itemValueDimensions) + { + var sourceMap = context.ItemValuesMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType) + .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) + .ToList() + .GroupBy(m => m.ItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.CleanValue).ToHashSet()); + + var allValues = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allValues.Count == 0) + { + continue; + } + + var valueToCandidates = context.ItemValuesMap.AsNoTracking() + .Where(m => m.ItemValue.Type == valueType && allValues.Contains(m.ItemValue.CleanValue)) + .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) + .ToList() + .GroupBy(m => m.CleanValue) + .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + + foreach (var sourceId in sourceIds) + { + if (!sourceMap.TryGetValue(sourceId, out var sourceValues)) + { + continue; + } + + var scoreMap = result[sourceId]; + foreach (var value in sourceValues) + { + if (valueToCandidates.TryGetValue(value, out var candidates)) + { + foreach (var candidateId in candidates) + { + scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; + } + } + } + } + } + + // Score people dimensions (directors, actors) + foreach (var (personTypes, weight) in _peopleDimensions) + { + var sourceMap = context.PeopleBaseItemMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && personTypes.Contains(m.People.PersonType)) + .Select(m => new { m.ItemId, m.PeopleId }) + .ToList() + .GroupBy(m => m.ItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + + var allPeopleIds = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allPeopleIds.Count == 0) + { + continue; + } + + var personToCandidates = context.PeopleBaseItemMap.AsNoTracking() + .Where(m => allPeopleIds.Contains(m.PeopleId)) + .Select(m => new { m.ItemId, m.PeopleId }) + .ToList() + .GroupBy(m => m.PeopleId) + .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + + foreach (var sourceId in sourceIds) + { + if (!sourceMap.TryGetValue(sourceId, out var sourcePeopleIds)) + { + continue; + } + + var scoreMap = result[sourceId]; + foreach (var peopleId in sourcePeopleIds) + { + if (personToCandidates.TryGetValue(peopleId, out var candidates)) + { + foreach (var candidateId in candidates) + { + scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; + } + } + } + } + } + + // Remove self-references and empty entries + foreach (var sourceId in sourceIds) + { + var scoreMap = result[sourceId]; + scoreMap.Remove(sourceId); + if (scoreMap.Count == 0) + { + result.Remove(sourceId); + } + } + + return result; } } diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index b56779cf3f..d4ee643f95 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -225,6 +225,23 @@ public class SimilarItemsManager : ISimilarItemsManager .ToList(); } + /// + public Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query) + { + var batchProvider = _similarItemsProviders + .OfType() + .FirstOrDefault(); + + if (batchProvider is null) + { + return Task.FromResult(new Dictionary>()); + } + + return batchProvider.GetBatchSimilarItemsAsync(sourceItems, query); + } + private List<(BaseItem Item, float Score)> ResolveRemoteReferences( IReadOnlyList references, int providerOrder, diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 50d34d0656..9c0e216f12 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -2,6 +2,9 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -33,6 +36,7 @@ public class MoviesController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ISimilarItemsManager _similarItemsManager; /// /// Initializes a new instance of the class. @@ -41,16 +45,19 @@ public class MoviesController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public MoviesController( IUserManager userManager, ILibraryManager libraryManager, IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager) + IServerConfigurationManager serverConfigurationManager, + ISimilarItemsManager similarItemsManager) { _userManager = userManager; _libraryManager = libraryManager; _dtoService = dtoService; _serverConfigurationManager = serverConfigurationManager; + _similarItemsManager = similarItemsManager; } /// @@ -61,15 +68,17 @@ public class MoviesController : BaseJellyfinApiController /// Optional. The fields to return. /// The max number of categories to return. /// The max number of items to return per category. + /// The cancellation token. /// Movie recommendations returned. /// The list of movie recommendations. [HttpGet("Recommendations")] - public ActionResult> GetMovieRecommendations( + public Task>> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, - [FromQuery] int itemLimit = 8) + [FromQuery] int itemLimit = 8, + CancellationToken cancellationToken = default) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -86,15 +95,13 @@ public class MoviesController : BaseJellyfinApiController IncludeItemTypes = new[] { BaseItemKind.Movie, - // nameof(Trailer), - // nameof(LiveTvProgram) }, - // IsMovie = true OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, Limit = 7, ParentId = parentIdGuid, Recursive = true, IsPlayed = true, + EnableGroupByMetadataKey = true, DtoOptions = dtoOptions }; @@ -122,31 +129,52 @@ public class MoviesController : BaseJellyfinApiController }); var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); - // Get recently played directors - var recentDirectors = GetDirectors(mostRecentMovies) - .ToList(); + var recentDirectors = GetDirectors(mostRecentMovies).ToList(); + var recentActors = GetActors(mostRecentMovies).ToList(); + + // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. + var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit + ? recentlyPlayedMovies.Take(categoryLimit).ToList() + : recentlyPlayedMovies; + var likedBaseline = likedMovies.Count > categoryLimit + ? likedMovies.Take(categoryLimit).ToList() + : likedMovies; + + var batchQuery = new SimilarItemsQuery + { + User = user, + Limit = itemLimit, + DtoOptions = dtoOptions + }; + + var similarToRecentlyPlayed = BuildPendingFromBatch( + _similarItemsManager.GetBatchSimilarItemsAsync(recentlyPlayedBaseline, batchQuery), + recentlyPlayedBaseline, + RecommendationType.SimilarToRecentlyPlayed); - // Get recently played actors - var recentActors = GetActors(mostRecentMovies) - .ToList(); + var similarToLiked = BuildPendingFromBatch( + _similarItemsManager.GetBatchSimilarItemsAsync(likedBaseline, batchQuery), + likedBaseline, + RecommendationType.SimilarToLikedItem); - var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); - var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); + var hasDirectorFromRecentlyPlayed = GetWithPerson(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed); + var hasActorFromRecentlyPlayed = GetWithPerson(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed); - var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); - var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); + // Use a single enumerator per list, listed twice so MoveNext advances it + // twice per round-robin pass (giving these categories double weight). + // IMPORTANT: Declare as IEnumerator to box the List.Enumerator struct once; + // using var would box separately per list insertion, creating independent copies. + IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); + IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator(); - var categoryTypes = new List> + var categoryTypes = new List> { - // Give this extra weight - similarToRecentlyPlayed, - similarToRecentlyPlayed, - - // Give this extra weight - similarToLiked, - similarToLiked, - hasDirectorFromRecentlyPlayed, - hasActorFromRecentlyPlayed + similarToRecentlyPlayedEnum, + similarToRecentlyPlayedEnum, + similarToLikedEnum, + similarToLikedEnum, + hasDirectorFromRecentlyPlayed.GetEnumerator(), + hasActorFromRecentlyPlayed.GetEnumerator() }; while (categories.Count < categoryLimit) @@ -157,7 +185,17 @@ public class MoviesController : BaseJellyfinApiController { if (category.MoveNext()) { - categories.Add(category.Current); + var pending = category.Current; + var returnItems = _dtoService.GetBaseItemDtos(pending.Items, dtoOptions, user); + + categories.Add(new RecommendationDto + { + BaselineItemName = pending.BaselineItemName, + CategoryId = pending.CategoryId, + RecommendationType = pending.RecommendationType, + Items = returnItems + }); + allEmpty = false; if (categories.Count >= categoryLimit) @@ -173,10 +211,36 @@ public class MoviesController : BaseJellyfinApiController } } - return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); + return Task.FromResult>>( + Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable())); + } + + private static List BuildPendingFromBatch( + Task>> batchTask, + IReadOnlyList baselineItems, + RecommendationType type) + { + var batchResults = batchTask.GetAwaiter().GetResult(); + var results = new List(); + + foreach (var item in baselineItems) + { + if (batchResults.TryGetValue(item.Id, out var similar) && similar.Count > 0) + { + results.Add(new PendingRecommendation + { + BaselineItemName = item.Name, + CategoryId = item.Id, + RecommendationType = type, + Items = similar + }); + } + } + + return results; } - private IEnumerable GetWithDirector( + private IEnumerable GetWithPerson( User? user, IEnumerable names, int itemLimit, @@ -190,17 +254,21 @@ public class MoviesController : BaseJellyfinApiController itemTypes.Add(BaseItemKind.LiveTvProgram); } + var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed + ? [PersonType.Director] + : Array.Empty(); + foreach (var name in names) { var items = _libraryManager.GetItemList( new InternalItemsQuery(user) { Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet Limit = itemLimit + 2, - PersonTypes = new[] { PersonType.Director }, + PersonTypes = personTypes, IncludeItemTypes = itemTypes.ToArray(), IsMovie = true, + IsPlayed = false, EnableGroupByMetadataKey = true, DtoOptions = dtoOptions }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) @@ -209,119 +277,47 @@ public class MoviesController : BaseJellyfinApiController if (items.Count > 0) { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto + yield return new PendingRecommendation { BaselineItemName = name, CategoryId = name.GetMD5(), RecommendationType = type, - Items = returnItems + Items = items }; } } } - private IEnumerable GetWithActor(User? user, IEnumerable names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + private IReadOnlyList GetActors(IReadOnlyList items) { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet - Limit = itemLimit + 2, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems( + itemIds, + new[] { PersonType.Actor, PersonType.GuestStar }, + limit: 0); } - private IEnumerable GetSimilarTo(User? user, IEnumerable baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + private IReadOnlyList GetDirectors(IReadOnlyList items) { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var item in baselineItems) - { - var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Limit = itemLimit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }); - - if (similar.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = returnItems - }; - } - } + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems( + itemIds, + [PersonType.Director], + limit: 0); } - private IEnumerable GetActors(IEnumerable items) + /// + /// Holds a recommendation category's BaseItems before DTO conversion. + /// DTO conversion is deferred until the round-robin actually selects the category. + /// + private sealed class PendingRecommendation { - var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty(), new[] { PersonType.Director }) - { - MaxListOrder = 3 - }); + public required string BaselineItemName { get; init; } - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } - - private IEnumerable GetDirectors(IEnumerable items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery( - new[] { PersonType.Director }, - Array.Empty())); + public required Guid CategoryId { get; init; } - var itemIds = items.Select(i => i.Id).ToList(); + public required RecommendationType RecommendationType { get; init; } - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); + public required IReadOnlyList Items { get; init; } } } diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 8f8741d00f..88bf6fa1bb 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -165,6 +165,31 @@ public class PeopleRepository(IDbContextFactory dbProvider, I transaction.Commit(); } + /// + public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + { + using var context = _dbProvider.CreateDbContext(); + var query = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)); + + if (personTypes.Count > 0) + { + query = query.Where(m => personTypes.Contains(m.People.PersonType)); + } + + var names = query + .Select(m => m.People.Name) + .Distinct(); + + if (limit > 0) + { + names = names.Take(limit); + } + + return names.ToArray(); + } + private PersonInfo Map(People people) { var mapping = people.BaseItems?.FirstOrDefault(); @@ -239,7 +264,7 @@ public class PeopleRepository(IDbContextFactory dbProvider, I if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value); + query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value)); } if (!string.IsNullOrWhiteSpace(filter.NameContains)) diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..fe2ce7d394 --- /dev/null +++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// +/// A local similar items provider that supports batch queries across multiple source items. +/// Implementations share access filtering and entity loading across all sources for better performance. +/// +public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// + /// Gets similar items for multiple source items in a single batch. + /// + /// The source items to find similar items for. + /// The query options. + /// Per-source-item results keyed by source item ID. + Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query); +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f5e3d7034e..365f078652 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -597,6 +597,15 @@ namespace MediaBrowser.Controller.Library /// List<System.String>. IReadOnlyList GetPeopleNames(InternalPeopleQuery query); + /// + /// Gets distinct people names for multiple items. + /// + /// The item IDs. + /// The person types to include. + /// Maximum number of names. + /// The distinct people names. + IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); + /// /// Queries the items. /// diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs index 0ced6f71ee..1c826ea780 100644 --- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -47,4 +47,14 @@ public interface ISimilarItemsManager int? limit, LibraryOptions? libraryOptions, CancellationToken cancellationToken); + + /// + /// Gets similar items for multiple source items in a single batch. + /// + /// The source items to find similar items for. + /// The query options. + /// Per-source-item results keyed by source item ID. + Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query); } diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index a89f3ef9ee..7474130ec4 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -32,4 +32,13 @@ public interface IPeopleRepository /// The query. /// The list of people names matching the filter. IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); + + /// + /// Gets distinct people names for multiple items efficiently by querying from the mapping table. + /// + /// The item IDs to get people for. + /// The person types to include (e.g. "Actor", "Director"). + /// Maximum number of names to return. + /// The distinct people names. + IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); } -- cgit v1.2.3 From 1fdf58e40f7c8f58377be3716368720923d8d8c0 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 09:44:36 +0200 Subject: Address review comments --- .../SimilarItems/MovieSimilarItemsProvider.cs | 1 - .../Library/SimilarItems/SimilarItemsManager.cs | 205 +++++++++++++++- Jellyfin.Api/Controllers/MoviesController.cs | 257 +-------------------- .../Library/ISimilarItemsManager.cs | 24 +- .../Library/SimilarItemsRecommendation.cs | 32 +++ 5 files changed, 258 insertions(+), 261 deletions(-) create mode 100644 MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 5660cf30a8..54466a6ad9 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -188,7 +188,6 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider allOrderedIdsList.Contains(e.Id)), filter) - .AsSplitQuery() .AsEnumerable() .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) .Where(dto => dto is not null) diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index d4ee643f95..fc83817015 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -8,12 +8,16 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; @@ -30,6 +34,7 @@ public class SimilarItemsManager : ISimilarItemsManager private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; private ISimilarItemsProvider[] _similarItemsProviders = []; /// @@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager /// The server application paths. /// The library manager. /// The file system. + /// The server configuration manager. public SimilarItemsManager( ILogger logger, IServerApplicationPaths appPaths, ILibraryManager libraryManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IServerConfigurationManager serverConfigurationManager) { _logger = logger; _appPaths = appPaths; _libraryManager = libraryManager; _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; } /// @@ -226,20 +234,205 @@ public class SimilarItemsManager : ISimilarItemsManager } /// - public Task>> GetBatchSimilarItemsAsync( - IReadOnlyList sourceItems, + public async Task> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dtoOptions); + + var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = [BaseItemKind.Movie], + OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)], + Limit = 7, + ParentId = parentId, + Recursive = true, + IsPlayed = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }); + + var itemTypes = new List { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Descending)], + Limit = 10, + IsFavoriteOrLiked = true, + ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), + EnableGroupByMetadataKey = true, + ParentId = parentId, + Recursive = true, + DtoOptions = dtoOptions + }); + + var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); + var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]); + var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]); + + // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. + var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit + ? recentlyPlayedMovies.Take(categoryLimit).ToList() + : recentlyPlayedMovies; + var likedBaseline = likedMovies.Count > categoryLimit + ? likedMovies.Take(categoryLimit).ToList() + : likedMovies; + + var batchQuery = new SimilarItemsQuery + { + User = user, + Limit = itemLimit, + DtoOptions = dtoOptions + }; + + var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync( + recentlyPlayedBaseline, + RecommendationType.SimilarToRecentlyPlayed, + batchQuery).ConfigureAwait(false); + + var similarToLiked = await GetSimilarItemsRecommendationsAsync( + likedBaseline, + RecommendationType.SimilarToLikedItem, + batchQuery).ConfigureAwait(false); + + var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes); + var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes); + + // Use a single enumerator per list, listed twice so MoveNext advances it + // twice per round-robin pass (giving these categories double weight). + // IMPORTANT: Declare as IEnumerator to box the List.Enumerator struct once; + // using var would box separately per list insertion, creating independent copies. + IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); + IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator(); + + var categoryTypes = new List> + { + similarToRecentlyPlayedEnum, + similarToRecentlyPlayedEnum, + similarToLikedEnum, + similarToLikedEnum, + hasDirectorFromRecentlyPlayed.GetEnumerator(), + hasActorFromRecentlyPlayed.GetEnumerator() + }; + + var categories = new List(); + while (categories.Count < categoryLimit) + { + var allEmpty = true; + foreach (var category in categoryTypes) + { + if (category.MoveNext()) + { + categories.Add(category.Current); + allEmpty = false; + + if (categories.Count >= categoryLimit) + { + break; + } + } + } + + if (allEmpty) + { + break; + } + } + + return [.. categories.OrderBy(i => i.RecommendationType)]; + } + + private async Task> GetSimilarItemsRecommendationsAsync( + IReadOnlyList baselineItems, + RecommendationType recommendationType, SimilarItemsQuery query) { var batchProvider = _similarItemsProviders .OfType() .FirstOrDefault(); - if (batchProvider is null) + if (batchProvider is null || baselineItems.Count == 0) { - return Task.FromResult(new Dictionary>()); + return []; } - return batchProvider.GetBatchSimilarItemsAsync(sourceItems, query); + var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query).ConfigureAwait(false); + + var recommendations = new List(baselineItems.Count); + foreach (var baseline in baselineItems) + { + if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0) + { + recommendations.Add(new SimilarItemsRecommendation + { + BaselineItemName = baseline.Name, + CategoryId = baseline.Id, + RecommendationType = recommendationType, + Items = similar + }); + } + } + + return recommendations; + } + + private IEnumerable GetPersonRecommendations( + User? user, + IReadOnlyList names, + int itemLimit, + DtoOptions dtoOptions, + RecommendationType type, + IReadOnlyList itemTypes) + { + var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed + ? [PersonType.Director] + : Array.Empty(); + + foreach (var name in names) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Person = name, + Limit = itemLimit + 2, + PersonTypes = personTypes, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + IsPlayed = false, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }) + .DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + .Take(itemLimit) + .ToList(); + + if (items.Count > 0) + { + yield return new SimilarItemsRecommendation + { + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = items + }; + } + } + } + + private IReadOnlyList GetPeopleNames(IReadOnlyList items, IReadOnlyList personTypes) + { + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes, limit: 0); } private List<(BaseItem Item, float Score)> ResolveRemoteReferences( diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 9c0e216f12..a1f2fe7ce7 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -1,20 +1,13 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -33,30 +26,22 @@ namespace Jellyfin.Api.Controllers; public class MoviesController : BaseJellyfinApiController { private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; - private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ISimilarItemsManager _similarItemsManager; /// /// Initializes a new instance of the class. /// /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. public MoviesController( IUserManager userManager, - ILibraryManager libraryManager, IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager, ISimilarItemsManager similarItemsManager) { _userManager = userManager; - _libraryManager = libraryManager; _dtoService = dtoService; - _serverConfigurationManager = serverConfigurationManager; _similarItemsManager = similarItemsManager; } @@ -72,7 +57,7 @@ public class MoviesController : BaseJellyfinApiController /// Movie recommendations returned. /// The list of movie recommendations. [HttpGet("Recommendations")] - public Task>> GetMovieRecommendations( + public async Task>> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, @@ -86,238 +71,16 @@ public class MoviesController : BaseJellyfinApiController : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields }; - var categories = new List(); + var recommendations = await _similarItemsManager + .GetMovieRecommendationsAsync(user, parentId ?? Guid.Empty, categoryLimit, itemLimit, dtoOptions, cancellationToken) + .ConfigureAwait(false); - var parentIdGuid = parentId ?? Guid.Empty; - - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] - { - BaseItemKind.Movie, - }, - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 7, - ParentId = parentIdGuid, - Recursive = true, - IsPlayed = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }; - - var recentlyPlayedMovies = _libraryManager.GetItemList(query); - - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 10, - IsFavoriteOrLiked = true, - ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), - EnableGroupByMetadataKey = true, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions - }); - - var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); - var recentDirectors = GetDirectors(mostRecentMovies).ToList(); - var recentActors = GetActors(mostRecentMovies).ToList(); - - // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. - var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit - ? recentlyPlayedMovies.Take(categoryLimit).ToList() - : recentlyPlayedMovies; - var likedBaseline = likedMovies.Count > categoryLimit - ? likedMovies.Take(categoryLimit).ToList() - : likedMovies; - - var batchQuery = new SimilarItemsQuery - { - User = user, - Limit = itemLimit, - DtoOptions = dtoOptions - }; - - var similarToRecentlyPlayed = BuildPendingFromBatch( - _similarItemsManager.GetBatchSimilarItemsAsync(recentlyPlayedBaseline, batchQuery), - recentlyPlayedBaseline, - RecommendationType.SimilarToRecentlyPlayed); - - var similarToLiked = BuildPendingFromBatch( - _similarItemsManager.GetBatchSimilarItemsAsync(likedBaseline, batchQuery), - likedBaseline, - RecommendationType.SimilarToLikedItem); - - var hasDirectorFromRecentlyPlayed = GetWithPerson(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed); - var hasActorFromRecentlyPlayed = GetWithPerson(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed); - - // Use a single enumerator per list, listed twice so MoveNext advances it - // twice per round-robin pass (giving these categories double weight). - // IMPORTANT: Declare as IEnumerator to box the List.Enumerator struct once; - // using var would box separately per list insertion, creating independent copies. - IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); - IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator(); - - var categoryTypes = new List> - { - similarToRecentlyPlayedEnum, - similarToRecentlyPlayedEnum, - similarToLikedEnum, - similarToLikedEnum, - hasDirectorFromRecentlyPlayed.GetEnumerator(), - hasActorFromRecentlyPlayed.GetEnumerator() - }; - - while (categories.Count < categoryLimit) + return Ok(recommendations.Select(r => new RecommendationDto { - var allEmpty = true; - - foreach (var category in categoryTypes) - { - if (category.MoveNext()) - { - var pending = category.Current; - var returnItems = _dtoService.GetBaseItemDtos(pending.Items, dtoOptions, user); - - categories.Add(new RecommendationDto - { - BaselineItemName = pending.BaselineItemName, - CategoryId = pending.CategoryId, - RecommendationType = pending.RecommendationType, - Items = returnItems - }); - - allEmpty = false; - - if (categories.Count >= categoryLimit) - { - break; - } - } - } - - if (allEmpty) - { - break; - } - } - - return Task.FromResult>>( - Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable())); - } - - private static List BuildPendingFromBatch( - Task>> batchTask, - IReadOnlyList baselineItems, - RecommendationType type) - { - var batchResults = batchTask.GetAwaiter().GetResult(); - var results = new List(); - - foreach (var item in baselineItems) - { - if (batchResults.TryGetValue(item.Id, out var similar) && similar.Count > 0) - { - results.Add(new PendingRecommendation - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = similar - }); - } - } - - return results; - } - - private IEnumerable GetWithPerson( - User? user, - IEnumerable names, - int itemLimit, - DtoOptions dtoOptions, - RecommendationType type) - { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed - ? [PersonType.Director] - : Array.Empty(); - - foreach (var name in names) - { - var items = _libraryManager.GetItemList( - new InternalItemsQuery(user) - { - Person = name, - Limit = itemLimit + 2, - PersonTypes = personTypes, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - IsPlayed = false, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - yield return new PendingRecommendation - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = items - }; - } - } - } - - private IReadOnlyList GetActors(IReadOnlyList items) - { - var itemIds = items.Select(i => i.Id).ToArray(); - return _libraryManager.GetPeopleNamesByItems( - itemIds, - new[] { PersonType.Actor, PersonType.GuestStar }, - limit: 0); - } - - private IReadOnlyList GetDirectors(IReadOnlyList items) - { - var itemIds = items.Select(i => i.Id).ToArray(); - return _libraryManager.GetPeopleNamesByItems( - itemIds, - [PersonType.Director], - limit: 0); - } - - /// - /// Holds a recommendation category's BaseItems before DTO conversion. - /// DTO conversion is deferred until the round-robin actually selects the category. - /// - private sealed class PendingRecommendation - { - public required string BaselineItemName { get; init; } - - public required Guid CategoryId { get; init; } - - public required RecommendationType RecommendationType { get; init; } - - public required IReadOnlyList Items { get; init; } + BaselineItemName = r.BaselineItemName, + CategoryId = r.CategoryId, + RecommendationType = r.RecommendationType, + Items = _dtoService.GetBaseItemDtos(r.Items, dtoOptions, user) + })); } } diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs index 1c826ea780..36fa547eeb 100644 --- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -6,6 +6,7 @@ using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; namespace MediaBrowser.Controller.Library; @@ -49,12 +50,21 @@ public interface ISimilarItemsManager CancellationToken cancellationToken); /// - /// Gets similar items for multiple source items in a single batch. + /// Builds movie recommendations for a user: a mix of similar-items and person-based categories, + /// scheduled round-robin and capped to . /// - /// The source items to find similar items for. - /// The query options. - /// Per-source-item results keyed by source item ID. - Task>> GetBatchSimilarItemsAsync( - IReadOnlyList sourceItems, - SimilarItemsQuery query); + /// The user the recommendations are for. May be for anonymous access. + /// The library/folder to localize the search to. Pass to use the root. + /// Maximum number of recommendation categories to return. + /// Maximum number of items per category. + /// DTO options used when querying the library. + /// The cancellation token. + /// The list of recommendation categories, ordered by . + Task> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs new file mode 100644 index 0000000000..71346fcadf --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library; + +/// +/// A recommendation category derived from a baseline item, holding similar items prior to DTO conversion. +/// +public sealed class SimilarItemsRecommendation +{ + /// + /// Gets the display name of the baseline item the recommendation is based on. + /// + public required string BaselineItemName { get; init; } + + /// + /// Gets an identifier for the recommendation category. + /// + public required Guid CategoryId { get; init; } + + /// + /// Gets the recommendation type. + /// + public required RecommendationType RecommendationType { get; init; } + + /// + /// Gets the similar items for the baseline, ordered by relevance. + /// + public required IReadOnlyList Items { get; init; } +} -- cgit v1.2.3 From d71194aa8cb07d998c0ed15df964c7c1259e7f17 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 09:50:33 +0200 Subject: Parallelize internal and external calls --- .../Library/Search/SearchManager.cs | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index 39fff42d9b..ff14a1db3a 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -84,12 +84,27 @@ public class SearchManager : ISearchManager var searchTerm = query.SearchTerm.Trim().RemoveDiacritics(); - var results = await CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); - var fromExternal = results.Count > 0; - if (results.Count == 0 && _internalProviders.Length > 0) + var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken); + var internalTask = _internalProviders.Length > 0 + ? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken) + : Task.FromResult>([]); + + await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false); + + var externalResults = await externalTask.ConfigureAwait(false); + var fromExternal = externalResults.Count > 0; + IReadOnlyList results; + if (fromExternal) { - _logger.LogDebug("No results from external providers, falling back to internal providers"); - results = await CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); + results = externalResults; + } + else + { + results = await internalTask.ConfigureAwait(false); + if (_internalProviders.Length > 0) + { + _logger.LogDebug("No results from external providers, using internal provider results"); + } } // Internal providers apply user-access filtering inline in their queries. External -- cgit v1.2.3 From 3655b4b09449e572826fa2f91a88f3b6dd4e63c4 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 16:11:13 +0200 Subject: Apply review and sonar suggestions --- .../SimilarItems/MovieSimilarItemsProvider.cs | 312 +++++++++++---------- .../Library/SimilarItems/SimilarItemsManager.cs | 11 +- .../Library/IBatchLocalSimilarItemsProvider.cs | 5 +- 3 files changed, 169 insertions(+), 159 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 54466a6ad9..29cde6a570 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -30,6 +30,10 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider public async Task> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) { - var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); return results.TryGetValue(item.Id, out var items) ? items : []; } /// public async Task> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) { - var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); return results.TryGetValue(item.Id, out var items) ? items : []; } @@ -95,9 +99,10 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider - public Task>> GetBatchSimilarItemsAsync( + public async Task>> GetBatchSimilarItemsAsync( IReadOnlyList sourceItems, - SimilarItemsQuery query) + SimilarItemsQuery query, + CancellationToken cancellationToken) { var includeItemTypes = new List { BaseItemKind.Movie }; if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) @@ -109,108 +114,119 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider i.Id).ToList(); - var perSourceScores = ComputeBatchScores(sourceIds, context); - - var allCandidateIds = new HashSet(); - foreach (var (_, scores) in perSourceScores) + if (sourceItems.Count > MaxBatchSourceItems) { - allCandidateIds.UnionWith( - scores.OrderByDescending(kvp => kvp.Value) - .Take(limit * 3) - .Select(kvp => kvp.Key)); + sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList(); } - var result = new Dictionary>(); - if (allCandidateIds.Count == 0) + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) { - return Task.FromResult(result); - } + // Phase 1: Score all candidates per source item + var sourceIds = sourceItems.Select(i => i.Id).ToList(); + var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false); - // Phase 2: One access filter for all candidates - var filter = new InternalItemsQuery(query.User) - { - IncludeItemTypes = [.. includeItemTypes], - ExcludeItemIds = [.. query.ExcludeItemIds], - DtoOptions = dtoOptions, - EnableGroupByMetadataKey = true, - EnableTotalRecordCount = false, - IsMovie = true, - IsPlayed = false - }; + var allCandidateIds = new HashSet(); + foreach (var (_, scores) in perSourceScores) + { + allCandidateIds.UnionWith( + scores.OrderByDescending(kvp => kvp.Value) + .Take(limit * 3) + .Select(kvp => kvp.Key)); + } - _queryHelpers.PrepareFilterQuery(filter); - var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); - baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + var result = new Dictionary>(); + if (allCandidateIds.Count == 0) + { + return result; + } - var allCandidateIdsList = allCandidateIds.ToList(); - var accessibleItems = baseQuery - .Where(e => allCandidateIdsList.Contains(e.Id)) - .Select(e => new { e.Id, e.PresentationUniqueKey }) - .ToList(); + // Phase 2: One access filter for all candidates + var filter = new InternalItemsQuery(query.User) + { + IncludeItemTypes = [.. includeItemTypes], + ExcludeItemIds = [.. query.ExcludeItemIds], + DtoOptions = dtoOptions, + EnableGroupByMetadataKey = true, + EnableTotalRecordCount = false, + IsMovie = true, + IsPlayed = false + }; + + _queryHelpers.PrepareFilterQuery(filter); + var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); + baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + + var allCandidateIdsList = allCandidateIds.ToList(); + var accessibleItems = await baseQuery + .WhereOneOrMany(allCandidateIdsList, e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey + var allOrderedIds = new HashSet(); + var perSourceOrderedIds = new Dictionary>(); + + foreach (var item in sourceItems) + { + if (!perSourceScores.TryGetValue(item.Id, out var scores)) + { + continue; + } - // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey - var allOrderedIds = new HashSet(); - var perSourceOrderedIds = new Dictionary>(); + var orderedIds = accessibleItems + .Where(x => scores.ContainsKey(x.Id)) + .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + .DistinctBy(x => x.PresentationUniqueKey) + .Take(limit) + .Select(x => x.Id) + .ToList(); - foreach (var item in sourceItems) - { - if (!perSourceScores.TryGetValue(item.Id, out var scores)) - { - continue; + if (orderedIds.Count > 0) + { + perSourceOrderedIds[item.Id] = orderedIds; + allOrderedIds.UnionWith(orderedIds); + } } - var orderedIds = accessibleItems - .Where(x => scores.ContainsKey(x.Id)) - .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) - .DistinctBy(x => x.PresentationUniqueKey) - .Take(limit) - .Select(x => x.Id) - .ToList(); - - if (orderedIds.Count > 0) + if (allOrderedIds.Count == 0) { - perSourceOrderedIds[item.Id] = orderedIds; - allOrderedIds.UnionWith(orderedIds); + return result; } - } - if (allOrderedIds.Count == 0) - { - return Task.FromResult(result); - } - - // Phase 4: One entity load for all results - var allOrderedIdsList = allOrderedIds.ToList(); - var entitiesById = _queryHelpers.ApplyNavigations( - context.BaseItems.AsNoTracking().Where(e => allOrderedIdsList.Contains(e.Id)), - filter) - .AsEnumerable() - .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) - .Where(dto => dto is not null) - .ToDictionary(i => i!.Id); - - // Phase 5: Split by source, preserving score order - foreach (var (sourceId, orderedIds) in perSourceOrderedIds) - { - var items = orderedIds - .Where(entitiesById.ContainsKey) - .Select(id => entitiesById[id]!) - .ToList(); - - if (items.Count > 0) + // Phase 4: One entity load for all results. AsSplitQuery avoids a SQL Cartesian + // product across the multiple collection Includes added by ApplyNavigations. + var allOrderedIdsList = allOrderedIds.ToList(); + var entities = await _queryHelpers.ApplyNavigations( + context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id), + filter) + .AsSplitQuery() + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var entitiesById = entities + .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToDictionary(i => i!.Id); + + // Phase 5: Split by source, preserving score order + foreach (var (sourceId, orderedIds) in perSourceOrderedIds) { - result[sourceId] = items; + var items = orderedIds + .Where(entitiesById.ContainsKey) + .Select(id => entitiesById[id]!) + .ToList(); + + if (items.Count > 0) + { + result[sourceId] = items; + } } - } - return Task.FromResult(result); + return result; + } } - private Dictionary> ComputeBatchScores(List sourceIds, JellyfinDbContext context) + private static async Task>> ComputeBatchScoresAsync(List sourceIds, JellyfinDbContext context, CancellationToken cancellationToken) { var result = new Dictionary>(); foreach (var id in sourceIds) @@ -218,95 +234,52 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType) - .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) - .ToList() - .GroupBy(m => m.ItemId) - .ToDictionary(g => g.Key, g => g.Select(x => x.CleanValue).ToHashSet()); + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - var allValues = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); - if (allValues.Count == 0) + var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); + var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allKeys.Count == 0) { continue; } - var valueToCandidates = context.ItemValuesMap.AsNoTracking() - .Where(m => m.ItemValue.Type == valueType && allValues.Contains(m.ItemValue.CleanValue)) - .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) - .ToList() - .GroupBy(m => m.CleanValue) - .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + var candidateRows = await context.ItemValuesMap.AsNoTracking() + .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var sourceId in sourceIds) - { - if (!sourceMap.TryGetValue(sourceId, out var sourceValues)) - { - continue; - } - - var scoreMap = result[sourceId]; - foreach (var value in sourceValues) - { - if (valueToCandidates.TryGetValue(value, out var candidates)) - { - foreach (var candidateId in candidates) - { - scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; - } - } - } - } + var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); } - // Score people dimensions (directors, actors) foreach (var (personTypes, weight) in _peopleDimensions) { - var sourceMap = context.PeopleBaseItemMap.AsNoTracking() + var sourceRows = await context.PeopleBaseItemMap.AsNoTracking() .Where(m => sourceIds.Contains(m.ItemId) && personTypes.Contains(m.People.PersonType)) - .Select(m => new { m.ItemId, m.PeopleId }) - .ToList() - .GroupBy(m => m.ItemId) - .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + .Select(m => new { m.ItemId, Key = m.PeopleId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - var allPeopleIds = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); - if (allPeopleIds.Count == 0) + var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); + var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allKeys.Count == 0) { continue; } - var personToCandidates = context.PeopleBaseItemMap.AsNoTracking() - .Where(m => allPeopleIds.Contains(m.PeopleId)) - .Select(m => new { m.ItemId, m.PeopleId }) - .ToList() - .GroupBy(m => m.PeopleId) - .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + var candidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => allKeys.Contains(m.PeopleId)) + .Select(m => new { m.ItemId, Key = m.PeopleId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var sourceId in sourceIds) - { - if (!sourceMap.TryGetValue(sourceId, out var sourcePeopleIds)) - { - continue; - } - - var scoreMap = result[sourceId]; - foreach (var peopleId in sourcePeopleIds) - { - if (personToCandidates.TryGetValue(peopleId, out var candidates)) - { - foreach (var candidateId in candidates) - { - scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; - } - } - } - } + var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); } - // Remove self-references and empty entries foreach (var sourceId in sourceIds) { var scoreMap = result[sourceId]; @@ -319,4 +292,35 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider( + List sourceIds, + Dictionary> sourceMap, + Dictionary> keyToCandidates, + int weight, + Dictionary> result) + where TKey : notnull + { + foreach (var sourceId in sourceIds) + { + if (!sourceMap.TryGetValue(sourceId, out var sourceKeys)) + { + continue; + } + + var scoreMap = result[sourceId]; + foreach (var key in sourceKeys) + { + if (!keyToCandidates.TryGetValue(key, out var candidates)) + { + continue; + } + + foreach (var candidateId in candidates) + { + scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; + } + } + } + } } diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index fc83817015..358c170db2 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -299,12 +299,14 @@ public class SimilarItemsManager : ISimilarItemsManager var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync( recentlyPlayedBaseline, RecommendationType.SimilarToRecentlyPlayed, - batchQuery).ConfigureAwait(false); + batchQuery, + cancellationToken).ConfigureAwait(false); var similarToLiked = await GetSimilarItemsRecommendationsAsync( likedBaseline, RecommendationType.SimilarToLikedItem, - batchQuery).ConfigureAwait(false); + batchQuery, + cancellationToken).ConfigureAwait(false); var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes); var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes); @@ -356,7 +358,8 @@ public class SimilarItemsManager : ISimilarItemsManager private async Task> GetSimilarItemsRecommendationsAsync( IReadOnlyList baselineItems, RecommendationType recommendationType, - SimilarItemsQuery query) + SimilarItemsQuery query, + CancellationToken cancellationToken) { var batchProvider = _similarItemsProviders .OfType() @@ -367,7 +370,7 @@ public class SimilarItemsManager : ISimilarItemsManager return []; } - var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query).ConfigureAwait(false); + var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false); var recommendations = new List(baselineItems.Count); foreach (var baseline in baselineItems) diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs index fe2ce7d394..af49711606 100644 --- a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs +++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -16,8 +17,10 @@ public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider /// /// The source items to find similar items for. /// The query options. + /// The cancellation token. /// Per-source-item results keyed by source item ID. Task>> GetBatchSimilarItemsAsync( IReadOnlyList sourceItems, - SimilarItemsQuery query); + SimilarItemsQuery query, + CancellationToken cancellationToken); } -- cgit v1.2.3 From d6240bfa88b3e6acbd9eda5089d957e08ea17e88 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 18:19:40 +0200 Subject: Apply review suggestions --- .../SimilarItems/MovieSimilarItemsProvider.cs | 52 ++++++++++++---------- 1 file changed, 29 insertions(+), 23 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 29cde6a570..d73120e691 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -41,11 +41,14 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider _personTypeWeights = new(StringComparer.Ordinal) + { + [nameof(PersonKind.Director)] = DirectorWeight, + [nameof(PersonKind.Actor)] = ActorWeight, + [nameof(PersonKind.GuestStar)] = ActorWeight, + }; + + private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys]; private readonly IDbContextFactory _dbProvider; private readonly IItemQueryHelpers _queryHelpers; @@ -194,8 +197,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider e.Id), @@ -257,27 +259,31 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType)) + .Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + if (personSourceRows.Count > 0) { - var sourceRows = await context.PeopleBaseItemMap.AsNoTracking() - .Where(m => sourceIds.Contains(m.ItemId) && personTypes.Contains(m.People.PersonType)) - .Select(m => new { m.ItemId, Key = m.PeopleId }) + var allPersonIds = personSourceRows.Select(r => r.PeopleId).Distinct().ToList(); + + var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => allPersonIds.Contains(m.PeopleId)) + .Select(m => new { m.ItemId, m.PeopleId }) .ToListAsync(cancellationToken).ConfigureAwait(false); - var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); - var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); - if (allKeys.Count == 0) + var personToCandidates = personCandidateRows + .GroupBy(r => r.PeopleId) + .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + + foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!])) { - continue; + var sourceMap = weightGroup + .GroupBy(r => r.ItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result); } - - var candidateRows = await context.PeopleBaseItemMap.AsNoTracking() - .Where(m => allKeys.Contains(m.PeopleId)) - .Select(m => new { m.ItemId, Key = m.PeopleId }) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); - ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); } foreach (var sourceId in sourceIds) -- cgit v1.2.3 From 3d8bcf1ffd1c56efaffe9ff004c2ec44afbb9818 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Fri, 15 Mar 2024 14:30:55 +0100 Subject: Alternate solution to #7843 without extra prop --- Emby.Server.Implementations/Library/LibraryManager.cs | 10 ++++++++-- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..1745d711b4 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2432,8 +2432,14 @@ namespace Emby.Server.Implementations.Library var outdated = forceUpdate ? item.ImageInfos.Where(i => i.Path is not null).ToArray() : item.ImageInfos.Where(ImageNeedsRefresh).ToArray(); - // Skip image processing if current or live tv source - if (outdated.Length == 0 || item.SourceType != SourceType.Library) + + var parentItem = item.GetParent(); + var isLiveTvShow = item.SourceType != SourceType.Library && + parentItem is not null && + parentItem.SourceType != SourceType.Library; // not a channel + + // Skip image processing if current or live tv show + if (outdated.Length == 0 || isLiveTvShow) { RegisterItem(item); return; diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 556516674b..c3cc70381e 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -448,14 +448,19 @@ public class GuideManager : IGuideManager item.Name = channelInfo.Name; - if (!item.HasImage(ImageType.Primary)) + var currentPrimary = item.GetImageInfo(ImageType.Primary, 0); + var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl); + + // Update channel image if image URL has changed + if (currentPrimary is null + || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal))) { if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) { item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); forceUpdate = true; } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) + else if (!imageUrlIsNull) { item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); forceUpdate = true; -- cgit v1.2.3 From 5ed3ffdb42cde2ddb45d0ca55d84d885d877d2a9 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 22 May 2026 00:10:41 +0200 Subject: Use embedded query --- .../Library/SimilarItems/MovieSimilarItemsProvider.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index d73120e691..b4ed12a20c 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -266,10 +266,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider 0) { - var allPersonIds = personSourceRows.Select(r => r.PeopleId).Distinct().ToList(); - var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() - .Where(m => allPersonIds.Contains(m.PeopleId)) + .Where(m => context.PeopleBaseItemMap + .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType)) + .Select(s => s.PeopleId) + .Contains(m.PeopleId)) .Select(m => new { m.ItemId, m.PeopleId }) .ToListAsync(cancellationToken).ConfigureAwait(false); -- cgit v1.2.3 From cb9d6e9884d3b952321736392801743198b0ccd9 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 24 May 2026 18:26:21 +0200 Subject: Add batch method for people names --- .../Library/LibraryManager.cs | 6 +++ .../Item/PeopleRepository.cs | 58 ++++++++++++++++++++++ MediaBrowser.Controller/Library/ILibraryManager.cs | 8 +++ .../Persistence/IPeopleRepository.cs | 10 ++++ 4 files changed, 82 insertions(+) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..662e28ec1d 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3394,6 +3394,12 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// + public IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes) + { + return _peopleRepository.GetPeopleNamesByItem(itemIds, personTypes); + } + public void UpdatePeople(BaseItem item, List people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index b612112d49..d84a59850d 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -165,6 +165,64 @@ public class PeopleRepository(IDbContextFactory dbProvider, I transaction.Commit(); } + /// + public IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes) + { + if (itemIds.Count == 0) + { + return new Dictionary>(); + } + + using var context = _dbProvider.CreateDbContext(); + var query = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)); + + if (personTypes.Count > 0) + { + query = query.Where(m => personTypes.Contains(m.People.PersonType)); + } + + // One round-trip: pull (ItemId, ListOrder, Name) sorted by ItemId+ListOrder, group in memory. + var rows = query + .OrderBy(m => m.ItemId) + .ThenBy(m => m.ListOrder) + .Select(m => new { m.ItemId, m.People.Name }) + .ToArray(); + + var result = new Dictionary>(); + List? current = null; + var currentId = Guid.Empty; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + if (row.ItemId != currentId) + { + if (current is { Count: > 0 }) + { + result[currentId] = current; + } + + currentId = row.ItemId; + current = new List(); + seen.Clear(); + } + + if (!string.IsNullOrWhiteSpace(row.Name) && seen.Add(row.Name)) + { + current!.Add(row.Name); + } + } + + if (current is { Count: > 0 }) + { + result[currentId] = current; + } + + return result; + } + private PersonInfo Map(People people) { var mapping = people.BaseItems?.FirstOrDefault(); diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f4c2196400..d794205f00 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -597,6 +597,14 @@ namespace MediaBrowser.Controller.Library /// List<System.String>. IReadOnlyList GetPeopleNames(InternalPeopleQuery query); + /// + /// Gets the people names per item for a batch of item IDs in a single DB round-trip. + /// + /// The item IDs to look up. + /// Optional person types to include. Empty for all. + /// Dictionary keyed by item id; values are the per-item people names. Items with no people are absent. + IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes); + /// /// Queries the items. /// diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index a89f3ef9ee..3a3b2bfb1f 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -32,4 +32,14 @@ public interface IPeopleRepository /// The query. /// The list of people names matching the filter. IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); + + /// + /// Gets the people names per item for a batch of item IDs, preserving per-item list order. + /// One database round-trip for the whole batch; grouped by item id in memory. + /// Items with no people are omitted from the returned dictionary. + /// + /// The item IDs to get people for. + /// Optional person types to include (e.g. "Actor", "Director"). Empty for all. + /// Dictionary keyed by item id; values are the per-item people names. + IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes); } -- cgit v1.2.3 From a05bde53d46d1a074b5f17181c59086837dfb04c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 25 May 2026 10:49:01 +0200 Subject: Fix external data pruning on item deletion --- Emby.Server.Implementations/ApplicationHost.cs | 1 + .../Library/ExternalDataManager.cs | 40 +++-- .../Library/LibraryManager.cs | 19 ++- .../20260524220000_CleanupOrphanedExternalData.cs | 182 +++++++++++++++++++++ MediaBrowser.Controller/IO/IExternalDataManager.cs | 7 + 5 files changed, 231 insertions(+), 18 deletions(-) create mode 100644 Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index c81829688f..7cbff0c67e 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -539,6 +539,7 @@ namespace Emby.Server.Implementations serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); + serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs index 4ad0f999bf..2c18e56df7 100644 --- a/Emby.Server.Implementations/Library/ExternalDataManager.cs +++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Chapters; @@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager /// public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken) { - var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList(); - var itemId = item.Id; - if (validPaths.Count > 0) - { - foreach (var path in validPaths) - { - try - { - Directory.Delete(path, true); - } - catch (Exception ex) - { - _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex); - } - } - } + DeleteExternalItemFiles(item); + var itemId = item.Id; await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false); await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false); } + + /// + public void DeleteExternalItemFiles(BaseItem item) + { + foreach (var path in _pathManager.GetExtractedDataPaths(item)) + { + if (!Directory.Exists(path)) + { + continue; + } + + try + { + Directory.Delete(path, true); + } + catch (Exception ex) + { + _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex); + } + } + } } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..2d89f763d3 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -89,6 +89,7 @@ namespace Emby.Server.Implementations.Library private readonly FastConcurrentLru _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly IMediaStreamRepository _mediaStreamRepository; + private readonly Lazy _externalDataManagerFactory; /// /// The _root folder sync lock. @@ -132,6 +133,7 @@ namespace Emby.Server.Implementations.Library /// The path manager. /// The .ignore rule handler. /// The media stream repository. + /// The external data manager (lazy, to break the DI cycle through ChapterManager). public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -155,7 +157,8 @@ namespace Emby.Server.Implementations.Library IPeopleRepository peopleRepository, IPathManager pathManager, DotIgnoreIgnoreRule dotIgnoreIgnoreRule, - IMediaStreamRepository mediaStreamRepository) + IMediaStreamRepository mediaStreamRepository, + Lazy externalDataManagerFactory) { _appHost = appHost; _logger = loggerFactory.CreateLogger(); @@ -186,6 +189,7 @@ namespace Emby.Server.Implementations.Library _configurationManager.ConfigurationUpdated += ConfigurationUpdated; _mediaStreamRepository = mediaStreamRepository; + _externalDataManagerFactory = externalDataManagerFactory; RecordConfigurationValues(_configurationManager.Configuration); } @@ -396,6 +400,12 @@ namespace Emby.Server.Implementations.Library } } + var externalDataManager = _externalDataManagerFactory.Value; + foreach (var (item, _, _) in pathMaps) + { + externalDataManager.DeleteExternalItemFiles(item); + } + _persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); } @@ -576,6 +586,13 @@ namespace Emby.Server.Implementations.Library item.SetParent(null); + var externalDataManager = _externalDataManagerFactory.Value; + externalDataManager.DeleteExternalItemFiles(item); + foreach (var child in children) + { + externalDataManager.DeleteExternalItemFiles(child); + } + _persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]); _cache.TryRemove(item.Id, out _); foreach (var child in children) diff --git a/Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs b/Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs new file mode 100644 index 0000000000..d8dfe181ca --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that +/// no longer exist in the BaseItems table. The database side is cleaned up synchronously by +/// IItemPersistenceService.DeleteItem, so the leftover orphans live on the filesystem. +/// +[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class CleanupOrphanedExternalData : IAsyncMigrationRoutine +{ + private const int ProgressLogStep = 500; + + private readonly IStartupLogger _logger; + private readonly IDbContextFactory _dbContextFactory; + private readonly IApplicationPaths _appPaths; + private readonly IServerApplicationPaths _serverPaths; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The database context factory. + /// The application paths. + /// The server application paths. + public CleanupOrphanedExternalData( + IStartupLogger logger, + IDbContextFactory dbContextFactory, + IApplicationPaths appPaths, + IServerApplicationPaths serverPaths) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _appPaths = appPaths; + _serverPaths = serverPaths; + } + + /// + public async Task PerformAsync(CancellationToken cancellationToken) + { + var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false); + + CleanupGuidIndexedRoot( + "attachment", + Path.Combine(_appPaths.DataPath, "attachments"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "subtitle", + Path.Combine(_appPaths.DataPath, "subtitles"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "trickplay", + _appPaths.TrickplayPath, + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "chapter image", + Path.Combine(_serverPaths.InternalMetadataPath, "library"), + knownIds, + deleteSubPath: "chapters", + cancellationToken); + } + + private async Task> LoadKnownItemIdsAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var ids = await context.BaseItems + .AsNoTracking() + .Select(b => b.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + return [.. ids]; + } + } + + private void CleanupGuidIndexedRoot( + string label, + string root, + HashSet knownIds, + string? deleteSubPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root); + return; + } + + _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root); + + var scanned = 0; + var removed = 0; + foreach (var prefixDir in Directory.EnumerateDirectories(root)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var prefixName = Path.GetFileName(prefixDir); + if (prefixName.Length != 2) + { + continue; + } + + foreach (var guidDir in Directory.EnumerateDirectories(prefixDir)) + { + cancellationToken.ThrowIfCancellationRequested(); + + scanned++; + if (scanned % ProgressLogStep == 0) + { + _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed); + } + + var leafName = Path.GetFileName(guidDir); + if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id)) + { + continue; + } + + if (knownIds.Contains(id)) + { + continue; + } + + var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath); + if (deleteSubPath is not null && !Directory.Exists(target)) + { + continue; + } + + if (TryDelete(target)) + { + removed++; + } + } + } + + _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed); + } + + private bool TryDelete(string dir) + { + try + { + Directory.Delete(dir, recursive: true); + return true; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir); + } + + return false; + } +} diff --git a/MediaBrowser.Controller/IO/IExternalDataManager.cs b/MediaBrowser.Controller/IO/IExternalDataManager.cs index f69f4586c6..b2eb8fc3f1 100644 --- a/MediaBrowser.Controller/IO/IExternalDataManager.cs +++ b/MediaBrowser.Controller/IO/IExternalDataManager.cs @@ -16,4 +16,11 @@ public interface IExternalDataManager /// The cancellation token. /// Task. Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken); + + /// + /// Deletes only the filesystem-side external item data (attachments, subtitles, trickplay, chapter images). + /// Use this when DB-side cleanup is already handled by another code path (e.g. IItemPersistenceService.DeleteItem). + /// + /// The item. + void DeleteExternalItemFiles(BaseItem item); } -- cgit v1.2.3 From 4af66c4e1ad0f8c0105dc3a48c2bfaf29cd11750 Mon Sep 17 00:00:00 2001 From: Erik W <22211983+Lampan-git@users.noreply.github.com> Date: Tue, 26 May 2026 21:02:43 +0200 Subject: Improve OriginalLanguage normalization and inheritance (#16829) Improve OriginalLanguage normalization and inheritance --- .../Library/MediaSourceManager.cs | 16 +--------------- MediaBrowser.Controller/Entities/BaseItem.cs | 19 +++++++++++++++++-- MediaBrowser.Controller/Entities/TV/Episode.cs | 6 ++++++ MediaBrowser.Controller/Entities/TV/Season.cs | 6 ++++++ MediaBrowser.Controller/Entities/Video.cs | 11 +++++++++++ MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs | 2 +- 6 files changed, 42 insertions(+), 18 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index fdb4c7328b..66614c6725 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -440,10 +440,6 @@ namespace Emby.Server.Implementations.Library if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase)) { - originalLanguage = !string.IsNullOrWhiteSpace(originalLanguage) - ? originalLanguage.Split(',').FirstOrDefault() - : null; - if (user.PlayDefaultAudioTrack) { source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex( @@ -498,17 +494,7 @@ namespace Emby.Server.Implementations.Library var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections; - var originalLanguage = item?.OriginalLanguage ?? item switch - { - Episode episode => episode.Series.OriginalLanguage, - Video video => video.GetOwner() switch - { - Episode ownerEpisode => ownerEpisode.OriginalLanguage ?? ownerEpisode.Series.OriginalLanguage, - BaseItem owner => owner.OriginalLanguage, - null => null - }, - _ => null - }; + var originalLanguage = item?.GetInheritedOriginalLanguage(); SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage); SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection); diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 4cdcaabbb1..e24b60f69f 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -94,6 +94,8 @@ namespace MediaBrowser.Controller.Entities private string _name; + private string _originalLanguage; + public const char SlugChar = '-'; protected BaseItem() @@ -217,7 +219,11 @@ namespace MediaBrowser.Controller.Entities public string OriginalTitle { get; set; } [JsonIgnore] - public string OriginalLanguage { get; set; } + public string OriginalLanguage + { + get => _originalLanguage; + set => _originalLanguage = LocalizationManager?.FindLanguageInfo(value)?.TwoLetterISOLanguageName ?? value; + } /// /// Gets or sets the id. @@ -1564,7 +1570,7 @@ namespace MediaBrowser.Controller.Entities } /// - /// Gets the preferred metadata language. + /// Gets the preferred metadata country code. /// /// System.String. public string GetPreferredMetadataCountryCode() @@ -1598,6 +1604,15 @@ namespace MediaBrowser.Controller.Entities return lang; } + /// + /// Gets the original language of the item, inheriting from parent items if necessary. + /// + /// System.String. + public virtual string GetInheritedOriginalLanguage() + { + return OriginalLanguage; + } + public virtual bool IsSaveLocalMetadataEnabled() { if (SourceType == SourceType.Channel) diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index dbe6f94dfd..42e4f79942 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -153,6 +153,12 @@ namespace MediaBrowser.Controller.Entities.TV return 16.0 / 9; } + /// + public override string GetInheritedOriginalLanguage() + { + return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage(); + } + public override List GetUserDataKeys() { var list = base.GetUserDataKeys(); diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index f70f7dfb4c..e96ed05a5e 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -128,6 +128,12 @@ namespace MediaBrowser.Controller.Entities.TV return result; } + /// + public override string GetInheritedOriginalLanguage() + { + return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage(); + } + public override string CreatePresentationUniqueKey() { if (IndexNumber.HasValue) diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 80bcd62dcd..44cae5197a 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -278,6 +278,17 @@ namespace MediaBrowser.Controller.Entities return linkedVersionCount + localVersionCount + 1; } + /// + public override string GetInheritedOriginalLanguage() + { + if (ExtraType.GetValueOrDefault() == Model.Entities.ExtraType.Trailer) + { + return GetOwner()?.GetInheritedOriginalLanguage(); + } + + return OriginalLanguage ?? GetOwner()?.GetInheritedOriginalLanguage(); + } + public override List GetUserDataKeys() { var list = base.GetUserDataKeys(); diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 4882822766..f562d64ddd 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -413,7 +413,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } item.Overview = result.Plot; - item.OriginalLanguage = result.Language; + item.OriginalLanguage = result.Language?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); if (!Plugin.Instance.Configuration.CastAndCrew) { -- cgit v1.2.3 From 6f0ff89bdcc1961ec630f43f012528ab79022a9f Mon Sep 17 00:00:00 2001 From: Neptune Date: Sun, 31 May 2026 22:18:25 +0700 Subject: Add support for VobSub subtitle streams (#16552) * Add support for VobSub subtitle streams * update logic to determine separate extraction for VobSub subtitles * simplify VobSub extraction logic and fix ffmpeg command * Match `ExtractAllExtractableSubtitlesMKS` with `ExtractAllExtractableSubtitlesInternal` Matroska's VobSub option * Add a comments clarify why MKS was used, and remove the redundant VobSub extension branch * remove redundant VobSub format check * fix type errors --- .../Library/MediaSourceManager.cs | 5 +++ .../Subtitles/SubtitleEncoder.cs | 36 +++++++++++++++------- MediaBrowser.Model/Dlna/StreamBuilder.cs | 18 +++++++++-- MediaBrowser.Model/Entities/MediaStream.cs | 30 +++++++++++++++++- 4 files changed, 74 insertions(+), 15 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 66614c6725..0caf66555a 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -127,6 +127,11 @@ namespace Emby.Server.Implementations.Library return true; } + if (stream.IsVobSubSubtitleStream) + { + return true; + } + return false; } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index e0c5f3ad39..8d237473a3 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -220,12 +220,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles Path = outputPath, Protocol = MediaProtocol.File, Format = outputFormat, - IsExternal = false + IsExternal = MediaStream.IsVobSubFormat(outputFormat) }; } - var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path) - .TrimStart('.'); + var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path).TrimStart('.'); // Handle PGS subtitles as raw streams for the client to render if (MediaStream.IsPgsFormat(currentFormat)) @@ -475,6 +474,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles { return subtitleStream.Codec; } + else if (MediaStream.IsVobSubFormat(subtitleStream.Codec)) + { + return "mks"; + } else { return "srt"; @@ -488,6 +491,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles { return "sup"; } + else if (MediaStream.IsVobSubFormat(subtitleStream.Codec)) + { + // FFmpeg cannot mux VobSub subtitle streams back into the .idx/.sub pair, so we use .mks container instead. + return "mks"; + } else { return GetExtractableSubtitleFormat(subtitleStream); @@ -500,7 +508,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase); + || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase) + || MediaStream.IsVobSubFormat(codec); } /// @@ -516,7 +525,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles foreach (var subtitleStream in subtitleStreams) { - if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) + if (subtitleStream.IsExternal + && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -603,6 +613,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles } var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; + // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files. + var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty; var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); if (streamIndex == -1) @@ -616,9 +628,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles outputPaths.Add(outputPath); args += string.Format( CultureInfo.InvariantCulture, - " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"", + " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"", streamIndex, outputCodec, + outputFormatOption, outputPath); } @@ -653,6 +666,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles } var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; + // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files. + var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty; var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); if (streamIndex == -1) @@ -666,18 +681,17 @@ namespace MediaBrowser.MediaEncoding.Subtitles outputPaths.Add(outputPath); args += string.Format( CultureInfo.InvariantCulture, - " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"", + " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"", streamIndex, outputCodec, + outputFormatOption, outputPath); } - if (outputPaths.Count == 0) + if (outputPaths.Count > 0) { - return; + await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); } - - await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); } private async Task ExtractSubtitlesForFile( diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 2ccd2a6c28..d875bbe8ed 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -575,7 +575,12 @@ namespace MediaBrowser.Model.Dlna { foreach (var profile in subtitleProfiles) { - if (profile.Method == SubtitleDeliveryMethod.External && string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase)) + if (profile.Method == SubtitleDeliveryMethod.External + && (string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase) + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks. + || (string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) + && stream.IsVobSubSubtitleStream + && (!stream.IsExternal || stream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))))) { return stream.Index; } @@ -1577,10 +1582,17 @@ namespace MediaBrowser.Model.Dlna continue; } - if ((profile.Method == SubtitleDeliveryMethod.External && subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format)) || + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are matched against external .mks delivery profiles. + bool isVobSubMksProfile = string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) + && subtitleStream.IsVobSubSubtitleStream + && (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)); + + if ((profile.Method == SubtitleDeliveryMethod.External + && (isVobSubMksProfile || subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format))) || (profile.Method == SubtitleDeliveryMethod.Hls && subtitleStream.IsTextSubtitleStream)) { - bool requiresConversion = !string.Equals(subtitleStream.Codec, profile.Format, StringComparison.OrdinalIgnoreCase); + bool requiresConversion = !isVobSubMksProfile + && !string.Equals(subtitleStream.Codec, profile.Format, StringComparison.OrdinalIgnoreCase); if (!requiresConversion) { diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index dad4a6e149..f057714bea 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -644,13 +644,32 @@ namespace MediaBrowser.Model.Entities } } + [JsonIgnore] + public bool IsVobSubSubtitleStream + { + get + { + if (Type != MediaStreamType.Subtitle) + { + return false; + } + + if (string.IsNullOrEmpty(Codec) && !IsExternal) + { + return false; + } + + return IsVobSubFormat(Codec); + } + } + /// /// Gets a value indicating whether this is a subtitle steam that is extractable by ffmpeg. /// All text-based and pgs subtitles can be extracted. /// /// true if this is a extractable subtitle steam otherwise, false. [JsonIgnore] - public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream; + public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream || IsVobSubSubtitleStream; /// /// Gets or sets a value indicating whether [supports external stream]. @@ -728,6 +747,7 @@ namespace MediaBrowser.Model.Entities return codec.Contains("microdvd", StringComparison.OrdinalIgnoreCase) || (!codec.Contains("pgs", StringComparison.OrdinalIgnoreCase) && !codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase) + && !codec.Contains("vobsub", StringComparison.OrdinalIgnoreCase) && !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase) && !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase) && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase)); @@ -741,6 +761,14 @@ namespace MediaBrowser.Model.Entities || string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase); } + public static bool IsVobSubFormat(string format) + { + string codec = format ?? string.Empty; + + return codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase) + || codec.Contains("vobsub", StringComparison.OrdinalIgnoreCase); + } + public bool SupportsSubtitleConversionTo(string toCodec) { if (!IsTextSubtitleStream) -- cgit v1.2.3 From 63d1af5fe7ed67d0e2e56c79ef518a7a87da782f Mon Sep 17 00:00:00 2001 From: Tim Eisele Date: Sun, 31 May 2026 17:21:15 +0200 Subject: Fix similarity (#16942) Fix similarity --- .../Library/LibraryManager.cs | 4 +- .../Library/SimilarItems/SimilarItemsManager.cs | 43 ++++++++++++++++------ .../Item/PeopleRepository.cs | 25 +++++++++---- MediaBrowser.Controller/Library/ILibraryManager.cs | 7 ++-- .../Persistence/IPeopleRepository.cs | 7 ++-- 5 files changed, 57 insertions(+), 29 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index cc85f09d23..a826db090f 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3395,9 +3395,9 @@ namespace Emby.Server.Implementations.Library } /// - public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes) { - return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit); + return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes); } public void UpdatePeople(BaseItem item, List people) diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index 358c170db2..d923cff07e 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -125,6 +125,7 @@ public class SimilarItemsManager : ISimilarItemsManager var allResults = new List<(BaseItem Item, float Score)>(); var excludeIds = new HashSet { item.Id }; + var excludeKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() }; foreach (var (providerOrder, provider) in orderedProviders.Index()) { if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested) @@ -149,7 +150,9 @@ public class SimilarItemsManager : ISimilarItemsManager foreach (var (position, resultItem) in items.Index()) { - if (excludeIds.Add(resultItem.Id)) + var isNewId = excludeIds.Add(resultItem.Id); + var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey()); + if (isNewId && isNewKey) { var score = CalculateScore(null, providerOrder, position); allResults.Add((resultItem, score)); @@ -163,7 +166,7 @@ public class SimilarItemsManager : ISimilarItemsManager var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); if (cachedReferences is not null) { - var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds); + var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); allResults.AddRange(resolvedItems); continue; } @@ -191,7 +194,7 @@ public class SimilarItemsManager : ISimilarItemsManager if (pendingBatch.Count >= BatchSize) { - var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); allResults.AddRange(resolvedItems); remaining -= resolvedItems.Count; pendingBatch.Clear(); @@ -206,7 +209,7 @@ public class SimilarItemsManager : ISimilarItemsManager // Resolve any remaining references in the last partial batch if (pendingBatch.Count > 0) { - var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); allResults.AddRange(resolvedItems); } @@ -435,7 +438,11 @@ public class SimilarItemsManager : ISimilarItemsManager private IReadOnlyList GetPeopleNames(IReadOnlyList items, IReadOnlyList personTypes) { var itemIds = items.Select(i => i.Id).ToArray(); - return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes, limit: 0); + return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes) + .Values + .SelectMany(names => names) + .Distinct() + .ToArray(); } private List<(BaseItem Item, float Score)> ResolveRemoteReferences( @@ -444,14 +451,15 @@ public class SimilarItemsManager : ISimilarItemsManager User? user, DtoOptions dtoOptions, BaseItemKind itemKind, - HashSet excludeIds) + HashSet excludeIds, + HashSet excludeKeys) { if (references.Count == 0) { return []; } - var resolvedById = new Dictionary(); + var resolvedByKey = new Dictionary(StringComparer.OrdinalIgnoreCase); var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance); foreach (var (position, match) in references.Index()) @@ -482,7 +490,13 @@ public class SimilarItemsManager : ISimilarItemsManager foreach (var item in items) { - if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id)) + if (excludeIds.Contains(item.Id)) + { + continue; + } + + var presentationKey = item.GetPresentationUniqueKey(); + if (excludeKeys.Contains(presentationKey)) { continue; } @@ -492,10 +506,9 @@ public class SimilarItemsManager : ISimilarItemsManager if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo)) { var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position); - if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score) + if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score) { - excludeIds.Add(item.Id); - resolvedById[item.Id] = (item, score); + resolvedByKey[presentationKey] = (item, score); } break; @@ -503,7 +516,13 @@ public class SimilarItemsManager : ISimilarItemsManager } } - return [.. resolvedById.Values]; + foreach (var (key, entry) in resolvedByKey) + { + excludeIds.Add(entry.Item.Id); + excludeKeys.Add(key); + } + + return [.. resolvedByKey.Values]; } private static float CalculateScore(float? matchScore, int providerOrder, int position) diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 6062aaca2f..eb87b525fe 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -166,7 +166,7 @@ public class PeopleRepository(IDbContextFactory dbProvider, I } /// - public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes) { using var context = _dbProvider.CreateDbContext(); var query = context.PeopleBaseItemMap @@ -178,16 +178,27 @@ public class PeopleRepository(IDbContextFactory dbProvider, I query = query.Where(m => personTypes.Contains(m.People.PersonType)); } - var names = query - .Select(m => m.People.Name) - .Distinct(); + var rows = query + .OrderBy(m => m.ListOrder) + .Select(m => new { m.ItemId, m.People.Name }) + .ToList(); - if (limit > 0) + var result = new Dictionary>(); + foreach (var group in rows.GroupBy(r => r.ItemId)) { - names = names.Take(limit); + var names = group + .Select(r => r.Name) + .Where(name => !string.IsNullOrEmpty(name)) + .Distinct() + .ToArray(); + + if (names.Length > 0) + { + result[group.Key] = names; + } } - return names.ToArray(); + return result; } private PersonInfo Map(People people) diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index c23eba75ef..0b64da291c 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -598,13 +598,12 @@ namespace MediaBrowser.Controller.Library IReadOnlyList GetPeopleNames(InternalPeopleQuery query); /// - /// Gets distinct people names for multiple items. + /// Gets the distinct people names per item for multiple items. /// /// The item IDs. /// The person types to include. - /// Maximum number of names. - /// The distinct people names. - IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); + /// A dictionary mapping each item ID to its distinct people names. Items with no matching people are omitted. + IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes); /// /// Queries the items. diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index 7474130ec4..e2833dc722 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -34,11 +34,10 @@ public interface IPeopleRepository IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); /// - /// Gets distinct people names for multiple items efficiently by querying from the mapping table. + /// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table. /// /// The item IDs to get people for. /// The person types to include (e.g. "Actor", "Director"). - /// Maximum number of names to return. - /// The distinct people names. - IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); + /// A dictionary mapping each item ID to its distinct people names, ordered by cast list order. Items with no matching people are omitted. + IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes); } -- cgit v1.2.3 From c7111b7570895cd999b8ca6abde9f8d558b99200 Mon Sep 17 00:00:00 2001 From: Tim Eisele Date: Mon, 1 Jun 2026 19:43:25 +0200 Subject: Only resolve symlinks on playback (#16965) Only resolve symlinks on playback --- .../Library/MediaSourceManager.cs | 25 ++++++++++++++++++++++ Jellyfin.Api/Controllers/LibraryController.cs | 13 ++++++++++- MediaBrowser.Controller/Entities/BaseItem.cs | 9 -------- 3 files changed, 37 insertions(+), 10 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 0caf66555a..9ccfefa86e 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -24,6 +24,7 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; @@ -176,6 +177,7 @@ namespace Emby.Server.Implementations.Library public async Task> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken) { var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); + ResolveSymlinkPaths(mediaSources, enablePathSubstitution); // If file is strm or main media stream is missing, force a metadata refresh with remote probing if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder @@ -192,6 +194,7 @@ namespace Emby.Server.Implementations.Library cancellationToken).ConfigureAwait(false); mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); + ResolveSymlinkPaths(mediaSources, enablePathSubstitution); } var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false); @@ -324,6 +327,28 @@ namespace Emby.Server.Implementations.Library } } + /// + /// Resolves symlinked file paths on the supplied sources to the real on-disk target. + /// Skipped when is set because the path may + /// already have been rewritten to a UNC/URL meant for the client to consume directly. + /// + private static void ResolveSymlinkPaths(IReadOnlyList sources, bool enablePathSubstitution) + { + if (enablePathSubstitution) + { + return; + } + + foreach (var source in sources) + { + if (source.Protocol == MediaProtocol.File + && FileSystemHelper.ResolveLinkTarget(source.Path, returnFinalTarget: true) is { Exists: true } target) + { + source.Path = target.FullName; + } + } + } + private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource) { var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter; diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 6a30a80f1d..39a6fbace8 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -119,7 +119,18 @@ public class LibraryController : BaseJellyfinApiController return NotFound(); } - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); + var filePath = item.Path; + if (item.IsFileProtocol) + { + // PhysicalFile does not work well with symlinks at the moment. + var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true); + if (resolved is not null && resolved.Exists) + { + filePath = resolved.FullName; + } + } + + return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), true); } /// diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index e24b60f69f..d4e56772aa 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -23,7 +23,6 @@ using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; @@ -1134,15 +1133,7 @@ namespace MediaBrowser.Controller.Entities ArgumentNullException.ThrowIfNull(item); var protocol = item.PathProtocol; - - // Resolve the item path so everywhere we use the media source it will always point to - // the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link - // path will return null, so it's safe to check for all paths. var itemPath = item.Path; - if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo) - { - itemPath = linkInfo.FullName; - } var info = new MediaSourceInfo { -- cgit v1.2.3 From 1b80da0c3d4014b5e65815f4e71b20d6714dd029 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 3 Jun 2026 19:47:35 +0200 Subject: Do not set topParentId if OwnerIds are empty --- Emby.Server.Implementations/Library/LibraryManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index a826db090f..ffc449d974 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1987,7 +1987,8 @@ namespace Emby.Server.Implementations.Library query.TopParentIds.Length == 0 && string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) && string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) && - query.ItemIds.Length == 0) + query.ItemIds.Length == 0 && + query.OwnerIds.Length == 0) { var userViews = UserViewManager.GetUserViews(new UserViewQuery { -- cgit v1.2.3 From 8aaea6ea52950a2791cc9d519944e7a2136339ef Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 7 Jun 2026 14:56:05 +0200 Subject: Omit external trickplay directory if itme has no path --- Emby.Server.Implementations/Library/PathManager.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs index ef5edb9afa..fad948ad97 100644 --- a/Emby.Server.Implementations/Library/PathManager.cs +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -121,7 +121,11 @@ public class PathManager : IPathManager } paths.Add(GetTrickplayDirectory(item, false)); - paths.Add(GetTrickplayDirectory(item, true)); + if (!string.IsNullOrEmpty(item.Path)) + { + paths.Add(GetTrickplayDirectory(item, true)); + } + paths.Add(GetChapterImageFolderPath(item)); return paths; -- cgit v1.2.3 From d8d386e88a8bd27ef8e40497e302db184cc02b08 Mon Sep 17 00:00:00 2001 From: Tim Eisele Date: Sun, 7 Jun 2026 22:07:35 +0200 Subject: Apply suggestions from code review Co-authored-by: Bond-009 --- Emby.Server.Implementations/Library/Search/SearchManager.cs | 2 +- Jellyfin.Api/Controllers/ItemsController.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index ff14a1db3a..a5be3f07bd 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -185,7 +185,7 @@ public class SearchManager : ISearchManager } var candidateScores = BuildScoreLookup(candidates); - var user = !query.UserId.IsEmpty() ? _userManager.GetUserById(query.UserId) : null; + var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId); var excludeItemTypes = BuildExcludeItemTypes(query); var includeItemTypes = BuildIncludeItemTypes(query); diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 679f6fafc2..15c2e922f8 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -386,8 +386,8 @@ public class ItemsController : BaseJellyfinApiController Recursive = recursive ?? false, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), IsFavorite = isFavorite, - Limit = searchResultScores is not null ? null : limit, - StartIndex = searchResultScores is not null ? null : startIndex, + Limit = searchResultScores is null ? limit : null, + StartIndex = searchResultScores is null ? startIndex : null, IsMissing = isMissing, IsUnaired = isUnaired, CollapseBoxSetItems = collapseBoxSetItems, -- cgit v1.2.3 From 2392e32779f7e5bced3294de3ea8a24e206a7084 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 4 Jun 2026 02:58:14 +0200 Subject: Filter inaccessible alternative versions --- Emby.Server.Implementations/Library/MediaSourceManager.cs | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 9ccfefa86e..f0c7bd1d20 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -386,6 +386,12 @@ namespace Emby.Server.Implementations.Library if (user is not null) { + sources = sources + .Where(source => !Guid.TryParse(source.Id, out var sourceId) + || sourceId.Equals(item.Id) + || _libraryManager.GetItemById(sourceId, user) is not null) + .ToArray(); + foreach (var source in sources) { SetDefaultAudioAndSubtitleStreamIndices(item, source, user); -- cgit v1.2.3 From e71914e99331c35ff1f44ddf3d69cd81d0121ec5 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 7 Jun 2026 21:27:48 +0200 Subject: Keep the queried item's media source as the playback default in MediaSourceManager --- .../Library/MediaSourceManager.cs | 38 +++++++++++++--------- 1 file changed, 23 insertions(+), 15 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index f0c7bd1d20..c369fb0957 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -229,7 +229,7 @@ namespace Emby.Server.Implementations.Library list.Add(source); } - return SortMediaSources(list).ToArray(); + return SortMediaSources(list, item.Id).ToArray(); } /// > @@ -546,24 +546,32 @@ namespace Emby.Server.Implementations.Library } } - private static IEnumerable SortMediaSources(IEnumerable sources) + private static IEnumerable SortMediaSources(IEnumerable sources, Guid preferredItemId = default) { - return sources.OrderBy(i => - { - if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) + // The source belonging to the queried item sorts first so it stays the default that gets played. + var preferredId = preferredItemId.IsEmpty() + ? null + : preferredItemId.ToString("N", CultureInfo.InvariantCulture); + + return sources + .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase)) + .ThenBy(i => { - return 0; - } + if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) + { + return 0; + } - return 1; - }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) - .ThenByDescending(i => - { - var stream = i.VideoStream; + return 1; + }) + .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) + .ThenByDescending(i => + { + var stream = i.VideoStream; - return stream?.Width ?? 0; - }) - .Where(i => i.Type != MediaSourceType.Placeholder); + return stream?.Width ?? 0; + }) + .Where(i => i.Type != MediaSourceType.Placeholder); } public async Task> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) -- cgit v1.2.3