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 ++ 3 files changed, 276 insertions(+), 21 deletions(-) (limited to 'Emby.Server.Implementations') 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, -- 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') 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 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') 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') 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 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') 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 317f57803d1e8851028edb087ca1cadacfb086c4 Mon Sep 17 00:00:00 2001 From: Bas <44002186+854562@users.noreply.github.com> Date: Sat, 23 May 2026 12:58:44 -0400 Subject: Translated using Weblate (Dutch) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nl/ --- Emby.Server.Implementations/Localization/Core/nl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 898f5892c9..9aea3adc22 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -8,7 +8,7 @@ "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "Favorites": "Favorieten", "Folders": "Mappen", - "HeaderContinueWatching": "Verder kijken", + "HeaderContinueWatching": "Verderkijken", "HeaderFavoriteEpisodes": "Favoriete afleveringen", "HeaderFavoriteShows": "Favoriete series", "HeaderLiveTV": "Live-tv", -- cgit v1.2.3 From 93b82ecb419a3fffd2b8d55fc6e75a9937d8db7b Mon Sep 17 00:00:00 2001 From: Ekaterine Papava Date: Sun, 24 May 2026 05:55:23 -0400 Subject: Translated using Weblate (Georgian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ka/ --- .../Localization/Core/ka.json | 36 +++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json index 5245d89948..5587301fe4 100644 --- a/Emby.Server.Implementations/Localization/Core/ka.json +++ b/Emby.Server.Implementations/Localization/Core/ka.json @@ -20,7 +20,7 @@ "External": "გარე", "HeaderFavoriteEpisodes": "რჩეული ეპიზოდები", "HearingImpaired": "სმენადაქვეითებული", - "LabelRunningTimeValue": "ხანგრძლივობა: {0}", + "LabelRunningTimeValue": "გაშვების დრო: {0}", "MixedContent": "შერეული შემცველობა", "MusicVideos": "მუსიკის ვიდეოები", "NotificationOptionInstallationFailed": "დაყენების შეცდომა", @@ -31,7 +31,7 @@ "PluginUninstalledWithName": "{0} წაიშალა", "VersionNumber": "ვერსია {0}", "TasksChannelsCategory": "ინტერნეტ-არხები", - "TaskRefreshChannelsDescription": "ინტერნეტ-არხის ინფორმაციის განახლება.", + "TaskRefreshChannelsDescription": "განაახლებს ინტერნეტ-არხის ინფორმაციას.", "Collections": "კოლექციები", "Default": "ნაგულისხმევი", "Favorites": "რჩეულები", @@ -53,30 +53,30 @@ "TaskOptimizeDatabase": "მონაცემთა ბაზის ოპტიმიზაცია", "TaskKeyframeExtractor": "საკვანძო კადრის გამომღები", "LabelIpAddressValue": "IP მისამართი: {0}", - "NameInstallFailed": "{0}-ის დაყენების შეცდომა", + "NameInstallFailed": "{0}-ის დაყენების ჩავარდა", "NotificationOptionApplicationUpdateAvailable": "ხელმისაწვდომია აპლიკაციის განახლება", "NotificationOptionAudioPlaybackStopped": "აუდიოს დაკვრა გაჩერებულია", "NotificationOptionNewLibraryContent": "ახალი შემცველობა დამატებულია", - "NotificationOptionPluginUpdateInstalled": "მოდულიs განახლება დაყენებულია", + "NotificationOptionPluginUpdateInstalled": "დამატების განახლება დაყენებულია", "NotificationOptionServerRestartRequired": "საჭიროა სერვერის გადატვირთვა", - "NotificationOptionTaskFailed": "გეგმიური დავალების შეცდომა", + "NotificationOptionTaskFailed": "დაგეგმილი ამოცანა ჩავარდა", "NotificationOptionUserLockedOut": "მომხმარებელი დაიბლოკა", "NotificationOptionVideoPlayback": "ვიდეოს დაკვრა დაწყებულია", "PluginInstalledWithName": "{0} დაყენებულია", "PluginUpdatedWithName": "{0} განახლდა", "TaskCleanActivityLog": "აქტივობების ჟურნალის გასუფთავება", - "TaskCleanCache": "ქეშის საქაღალდის გასუფთავება", - "TaskRefreshChapterImages": "თავის სურათების გაშლა", + "TaskCleanCache": "კეშის საქაღალდის გასუფთავება", + "TaskRefreshChapterImages": "თავის სურათების ამოღება", "TaskRefreshLibrary": "მედიის ბიბლიოთეკის სკანირება", "TaskCleanLogs": "ჟურნალის საქაღალდის გასუფთავება", "TaskCleanTranscode": "ტრანსკოდირების საქაღალდის გასუფთავება", - "TaskDownloadMissingSubtitles": "მიუწვდომელი სუბტიტრების გადმოწერა", - "UserDownloadingItemWithValues": "{0} -ი {1}-ს იწერს", + "TaskDownloadMissingSubtitles": "ნაკლული სუბტიტრების გადმოწერა", + "UserDownloadingItemWithValues": "{0} იწერს {1}-ს", "FailedLoginAttemptWithUserName": "შესვლის წარუმატებელი მცდელობა {0}-დან", "UserCreatedWithName": "მომხმარებელი {0} შეიქმნა", - "UserDeletedWithName": "მომხმარებელი {0} წაშლილია", - "UserOnlineFromDevice": "{0}-ი დაკავშირდა {1}-დან", - "UserOfflineFromDevice": "{0}-ი {1}-დან გაეთიშა", + "UserDeletedWithName": "მომხმარებელი {0} წაიშალა", + "UserOnlineFromDevice": "{0} ხაზზეა {1}-დან", + "UserOfflineFromDevice": "{0} გაითიშა {1}-დან", "UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია", "UserStartedPlayingItemWithValues": "{0} უყურებს {1}-ს {2}-ზე", "UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა", @@ -96,16 +96,16 @@ "TaskDownloadMissingSubtitlesDescription": "ეძებს ბიბლიოთეკაში მიუწვდომელ სუბტიტრებს ინტერნეტში მეტამონაცემებზე დაყრდნობით.", "TaskOptimizeDatabaseDescription": "კუმშავს მონაცემთა ბაზას ადგილის გათავისუფლებლად. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს.", "TaskRefreshTrickplayImagesDescription": "ქმნის trickplay წინასწარ ხედებს ვიდეოებისთვის დაშვებულ ბიბლიოთეკებში.", - "TaskRefreshTrickplayImages": "Trickplay სურათების გენერირება", - "TaskAudioNormalization": "აუდიოს ნორმალიზება", + "TaskRefreshTrickplayImages": "Trickplay სურათების გენერაცია", + "TaskAudioNormalization": "აუდიოს ნორმალიზაცია", "TaskAudioNormalizationDescription": "აანალიზებს ფაილებს აუდიოს ნორმალიზაციისთვის.", "TaskDownloadMissingLyrics": "მიუწვდომელი ლირიკების ჩამოტვირთვა", - "TaskDownloadMissingLyricsDescription": "ჩამოტვირთავს ამჟამად ბიბლიოთეკაში არარსებულ ლირიკებს სიმღერებისთვის", - "TaskExtractMediaSegments": "მედია სეგმენტების სკანირება", + "TaskDownloadMissingLyricsDescription": "გადმოწერს ლირიკას სიმღერებისთვის", + "TaskExtractMediaSegments": "მედიის სეგმენტების სკანირება", "TaskExtractMediaSegmentsDescription": "მედია სეგმენტების სკანირება მხარდაჭერილი მოდულებისთვის.", - "TaskMoveTrickplayImages": "Trickplay სურათების მიგრაცია", + "TaskMoveTrickplayImages": "Trickplay-ის გამოსახულებების მდებარეობის მიგრაცია", "TaskMoveTrickplayImagesDescription": "გადააქვს trickplay ფაილები ბიბლიოთეკის პარამეტრებზე დაყრდნობით.", - "CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავება", + "CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავების ამოცანა", "CleanupUserDataTaskDescription": "ასუფთავებს მომხმარებლების მონაცემებს (ყურების სტატუსი, ფავორიტები ანდ ა.შ) მედია ელემენტებისთვის რომლების 90 დღეზე მეტია აღარ არსებობენ.", "LyricDownloadFailureFromForItem": "{1}-ისთვის {0}-დან ლირიკის გადმოწერა ჩავარდა", "Original": "ორიგინალი" -- cgit v1.2.3 From 0b25c1483cca6fc90eee4b7c9d5e47b923bf8a7c Mon Sep 17 00:00:00 2001 From: Nitzan Selwyn Date: Sun, 24 May 2026 06:26:17 -0400 Subject: Translated using Weblate (Hebrew (Israel)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/he_IL/ --- .../Localization/Core/he_IL.json | 47 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Localization/Core/he_IL.json b/Emby.Server.Implementations/Localization/Core/he_IL.json index dedbc56a74..69e765f85f 100644 --- a/Emby.Server.Implementations/Localization/Core/he_IL.json +++ b/Emby.Server.Implementations/Localization/Core/he_IL.json @@ -16,5 +16,50 @@ "HeaderLiveTV": "טלוויזיה בשידור חי", "HeaderNextUp": "הבא", "HearingImpaired": "ללקויי שמיעה", - "HomeVideos": "סרטונים ביתיים" + "HomeVideos": "סרטונים ביתיים", + "AppDeviceValues": "אפליקציה: {0}, מכשיר: {1}", + "AuthenticationSucceededWithUserName": "{0} אומת בהצלחה", + "Default": "בררת מחדל", + "FailedLoginAttemptWithUserName": "התחברות נכשלה מ {0}", + "Forced": "בכוח", + "Inherit": "ירש", + "LabelIpAddressValue": "כתובת IP: {0}", + "LabelRunningTimeValue": "זמן ריצה: {0}", + "Latest": "הכי חדש", + "LyricDownloadFailureFromForItem": "מילות שיר נכשלו לרדת מ{0} בשביל {1}", + "MixedContent": "תוכן מעורב", + "MusicVideos": "סרטוני מוזיקה", + "NameInstallFailed": "{0} התכנות כושלות", + "NameSeasonUnknown": "עונה לא ידוע", + "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.", + "NotificationOptionApplicationUpdateAvailable": "גרסת אפליקציה חדשה זמינה להורדה", + "NotificationOptionApplicationUpdateInstalled": "עדכון אפליקציה הותקן", + "NotificationOptionAudioPlayback": "החלה השמעת אודיו", + "NotificationOptionAudioPlaybackStopped": "ניגון השמע הופסק", + "NotificationOptionCameraImageUploaded": "תמונת מצלמה עודכן", + "NotificationOptionInstallationFailed": "התקנה נכשלה", + "NotificationOptionNewLibraryContent": "תוכן חדש נוסף", + "NotificationOptionPluginError": "תוסף נכשל", + "NotificationOptionPluginInstalled": "תוסף הותקן", + "NotificationOptionPluginUninstalled": "תוסף נמחק", + "NotificationOptionPluginUpdateInstalled": "עידכון לתוסף הותקן", + "NotificationOptionServerRestartRequired": "נדרש התחול מחדש לשרת", + "NotificationOptionTaskFailed": "כשל במשימה מתוכננת", + "NotificationOptionUserLockedOut": "המשתמש ננעל", + "NotificationOptionVideoPlayback": "החלה הפעלת וידאו", + "NotificationOptionVideoPlaybackStopped": "הפעלת הסרטון הופסקה", + "Original": "מקורי", + "Photos": "תמונות", + "PluginInstalledWithName": "{0} הותקן", + "PluginUninstalledWithName": "{0} נמחק", + "PluginUpdatedWithName": "{0} עודכן", + "ScheduledTaskFailedWithName": "{0} נכשל", + "Shows": "סדרות", + "StartupEmbyServerIsLoading": "שרת Jellyfin נטען. אנא נסה שוב בקרוב.", + "SubtitleDownloadFailureFromForItem": "הורדת הכתוביות מ-{0} עבור {1} נכשלה", + "TvShows": "תוכניות טלויזיה", + "Undefined": "לא מוגדר", + "UserCreatedWithName": "המשתמש {0} נוצר", + "UserDeletedWithName": "המשתמש {0} נמחק", + "UserDownloadingItemWithValues": "{0} מוריד את {1}" } -- cgit v1.2.3 From a784733a53bec21a39a58ea1eb36216ff0563c2b Mon Sep 17 00:00:00 2001 From: Ekaterine Papava Date: Sun, 24 May 2026 08:09:41 -0400 Subject: Translated using Weblate (Georgian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ka/ --- Emby.Server.Implementations/Localization/Core/ka.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json index 5587301fe4..f7ca19d7f0 100644 --- a/Emby.Server.Implementations/Localization/Core/ka.json +++ b/Emby.Server.Implementations/Localization/Core/ka.json @@ -78,7 +78,7 @@ "UserOnlineFromDevice": "{0} ხაზზეა {1}-დან", "UserOfflineFromDevice": "{0} გაითიშა {1}-დან", "UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია", - "UserStartedPlayingItemWithValues": "{0} უყურებს {1}-ს {2}-ზე", + "UserStartedPlayingItemWithValues": "{0} უკრავს {1}-ს {2}-ზე", "UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა", "UserStoppedPlayingItemWithValues": "{0}-მა დაასრულა {1}-ის ყურება {2}-ზე", "TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა.", -- cgit v1.2.3 From 4322ee4391ea864b892b9dbca76af80cb5a46663 Mon Sep 17 00:00:00 2001 From: Nitzan Selwyn Date: Sun, 24 May 2026 08:46:26 -0400 Subject: Translated using Weblate (Hebrew (Israel)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/he_IL/ --- .../Localization/Core/he_IL.json | 49 +++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Localization/Core/he_IL.json b/Emby.Server.Implementations/Localization/Core/he_IL.json index 69e765f85f..b551608fd0 100644 --- a/Emby.Server.Implementations/Localization/Core/he_IL.json +++ b/Emby.Server.Implementations/Localization/Core/he_IL.json @@ -61,5 +61,52 @@ "Undefined": "לא מוגדר", "UserCreatedWithName": "המשתמש {0} נוצר", "UserDeletedWithName": "המשתמש {0} נמחק", - "UserDownloadingItemWithValues": "{0} מוריד את {1}" + "UserDownloadingItemWithValues": "{0} מוריד את {1}", + "UserLockedOutWithName": "המשתמש {0} ננעל בחוץ", + "UserOfflineFromDevice": "{0} התנתק מ-{1}", + "UserOnlineFromDevice": "{0} מחובר מ-{1}", + "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}", + "UserStartedPlayingItemWithValues": "{0} מנגן ב-{1} ב-{2}", + "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} ב-{2}", + "VersionNumber": "גרסה {0}", + "TasksMaintenanceCategory": "תחזוקה", + "TasksLibraryCategory": "ספריה", + "TasksApplicationCategory": "אפליקציה", + "TasksChannelsCategory": "ערוצי אינטרנט", + "TaskCleanActivityLog": "נקה יומן פעילות", + "TaskCleanActivityLogDescription": "מוחק רשומות יומן פעילות ישנות יותר מהגיל שהוגדר.", + "TaskCleanCache": "נקה ספריית מטמון", + "TaskCleanCacheDescription": "מוחק קבצי מטמון שאינם נחוצים עוד על ידי המערכת.", + "TaskRefreshChapterImages": "חלץ תמונות פרק", + "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות עבור סרטונים שיש להם פרקים.", + "TaskAudioNormalization": "נורמליזציה של שמע", + "TaskAudioNormalizationDescription": "סורק קבצים לאיתור נתוני נרמול שמע.", + "TaskRefreshLibrary": "סרוק ספריית מדיה", + "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך לאיתור קבצים חדשים ומרענן מטא-דאטה.", + "TaskCleanLogs": "נקה ספריית יומן", + "TaskCleanLogsDescription": "מוחק קבצי יומן שגילם עולה על {0} ימים.", + "TaskRefreshPeople": "רענן אנשים", + "TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.", + "TaskRefreshTrickplayImages": "צור תמונות Trickplay", + "TaskRefreshTrickplayImagesDescription": "יוצר תצוגות מקדימות של trickplay עבור סרטונים בספריות מופעלות.", + "TaskUpdatePlugins": "עדכן פלאגינים", + "TaskUpdatePluginsDescription": "מוריד ומתקין עדכונים עבור תוספים שתצורתם נקבעה לעדכון אוטומטי.", + "TaskCleanTranscode": "נקה ספריית קידוד", + "TaskCleanTranscodeDescription": "תמחוק את קבצי הקידוד בני יותר מיום.", + "TaskRefreshChannels": "רענן ערוצים", + "TaskRefreshChannelsDescription": "מרענן את פרטי ערוץ האינטרנט.", + "TaskDownloadMissingLyrics": "הורד מילות שיר חסרות", + "TaskDownloadMissingLyricsDescription": "הורדות מילים לשירים", + "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות", + "TaskDownloadMissingSubtitlesDescription": "מחפש באינטרנט אחר כתוביות חסרות בהתבסס על תצורת מטא-דאטה.", + "TaskOptimizeDatabase": "בצע אופטימיזציה של מסד הנתונים", + "TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים וקיצוץ שטח פנוי. הפעלת משימה זו לאחר סריקת הספרייה או ביצוע שינויים אחרים שמשמעותם שינויים בבסיס הנתונים עשויה לשפר את הביצועים.", + "TaskKeyframeExtractor": "מחלץ פריים מרכזי", + "TaskKeyframeExtractorDescription": "מחלץ פריימים מרכזיים מקבצי וידאו כדי ליצור רשימות השמעה HLS מדויקות יותר. משימה זו עשויה להימשך זמן רב.", + "TaskExtractMediaSegments": "סריקת מקטעי מדיה", + "TaskExtractMediaSegmentsDescription": "מחלץ או משיג קטעי מדיה מתוספים התומכים ב-MediaSegment.", + "TaskMoveTrickplayImages": "העברת מיקום תמונת Trickplay", + "TaskMoveTrickplayImagesDescription": "מעביר קבצי trickplay קיימים בהתאם להגדרות הספרייה.", + "CleanupUserDataTask": "משימת ניקוי נתוני משתמש", + "CleanupUserDataTaskDescription": "מנקה את כל נתוני המשתמש (מצב צפייה, סטטוס מועדף וכו') ממדיה שכבר לא הייתה קיימת במשך 90 יום לפחות." } -- cgit v1.2.3 From 498d265208673c323cc8401b45e0f17382fde7ae Mon Sep 17 00:00:00 2001 From: joanluc Date: Sun, 24 May 2026 07:22:28 -0400 Subject: Translated using Weblate (Occitan) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/oc/ --- Emby.Server.Implementations/Localization/Core/oc.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Localization/Core/oc.json b/Emby.Server.Implementations/Localization/Core/oc.json index 0967ef424b..cad5640763 100644 --- a/Emby.Server.Implementations/Localization/Core/oc.json +++ b/Emby.Server.Implementations/Localization/Core/oc.json @@ -1 +1,3 @@ -{} +{ + "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}" +} -- cgit v1.2.3 From 9fafc87bf6de9c742154191b650bad22dec35c47 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Mon, 25 May 2026 16:55:58 +0000 Subject: Deleted translation using Weblate (English (Middle)) --- Emby.Server.Implementations/Localization/Core/enm.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 Emby.Server.Implementations/Localization/Core/enm.json (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Localization/Core/enm.json b/Emby.Server.Implementations/Localization/Core/enm.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/Emby.Server.Implementations/Localization/Core/enm.json +++ /dev/null @@ -1 +0,0 @@ -{} -- cgit v1.2.3 From d0f1df13b2660ec8a20083929a731226bfbd7c9d Mon Sep 17 00:00:00 2001 From: Antonio Toledo Date: Mon, 25 May 2026 02:34:48 -0400 Subject: Translated using Weblate (Spanish) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es/ --- Emby.Server.Implementations/Localization/Core/es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 35efcf74d3..563dce8fe6 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -108,5 +108,5 @@ "CleanupUserDataTask": "Tarea de limpieza de datos del usuario", "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.", "Original": "Original", - "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}." + "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}" } -- 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') 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 b555de4ceab449dca3112a56e8eb5d81489e1faa Mon Sep 17 00:00:00 2001 From: Michael Jones Date: Tue, 26 May 2026 22:24:16 -0500 Subject: Fix CA2007 warnings in InstallationManager Wrap the downloaded stream in an explicit await using block with ConfigureAwait(false), matching the pattern already used in LiveStreamHelper and similar callers. Also add ConfigureAwait(false) to the ZipFile.ExtractToDirectoryAsync call. Part of #2149 --- .../Updates/InstallationManager.cs | 56 +++++++++++----------- 1 file changed, 29 insertions(+), 27 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 67b77a112d..ef53e3b326 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -527,42 +527,44 @@ namespace Emby.Server.Implementations.Updates using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - // CA5351: Do Not Use Broken Cryptographic Algorithms + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + // CA5351: Do Not Use Broken Cryptographic Algorithms #pragma warning disable CA5351 - cancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); - if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogError( - "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", - package.Name, - package.Checksum, - hash); - throw new InvalidDataException("The checksum of the received data doesn't match."); - } + var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); + if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", + package.Name, + package.Checksum, + hash); + throw new InvalidDataException("The checksum of the received data doesn't match."); + } - // Version folder as they cannot be overwritten in Windows. - targetDir += "_" + package.Version; + // Version folder as they cannot be overwritten in Windows. + targetDir += "_" + package.Version; - if (Directory.Exists(targetDir)) - { - try + if (Directory.Exists(targetDir)) { - Directory.Delete(targetDir, true); - } + try + { + Directory.Delete(targetDir, true); + } #pragma warning disable CA1031 // Do not catch general exception types - catch + catch #pragma warning restore CA1031 // Do not catch general exception types - { - // Ignore any exceptions. + { + // Ignore any exceptions. + } } - } - stream.Position = 0; - await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken); + stream.Position = 0; + await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken).ConfigureAwait(false); + } // Ensure we create one or populate existing ones with missing data. await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); -- cgit v1.2.3 From c9f71d8531d946f04d5395bd885f08128253559c Mon Sep 17 00:00:00 2001 From: Sam Xie Date: Fri, 29 May 2026 11:00:34 -0700 Subject: Add a collection API for `Included In` feature (#15516) Add a collection API for `Included In` feature --- .../Collections/CollectionManager.cs | 25 +++++++- Jellyfin.Api/Controllers/LibraryController.cs | 71 ++++++++++++++++++++++ .../Item/LinkedChildrenService.cs | 23 +++++-- .../Collections/ICollectionManager.cs | 8 +++ .../Persistence/ILinkedChildrenService.cs | 4 +- .../Controllers/LibraryControllerTests.cs | 1 + 6 files changed, 124 insertions(+), 8 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 0ede5665f9..295efd456c 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -4,12 +4,15 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections private readonly ILibraryMonitor _iLibraryMonitor; private readonly ILogger _logger; private readonly IProviderManager _providerManager; + private readonly ILinkedChildrenService _linkedChildrenService; private readonly ILocalizationManager _localizationManager; private readonly IApplicationPaths _appPaths; @@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections /// The library monitor. /// The logger factory. /// The provider manager. + /// The linked children service. public CollectionManager( ILibraryManager libraryManager, IApplicationPaths appPaths, @@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections IFileSystem fileSystem, ILibraryMonitor iLibraryMonitor, ILoggerFactory loggerFactory, - IProviderManager providerManager) + IProviderManager providerManager, + ILinkedChildrenService linkedChildrenService) { _libraryManager = libraryManager; _fileSystem = fileSystem; _iLibraryMonitor = iLibraryMonitor; _logger = loggerFactory.CreateLogger(); _providerManager = providerManager; + _linkedChildrenService = linkedChildrenService; _localizationManager = localizationManager; _appPaths = appPaths; } @@ -120,6 +127,22 @@ namespace Emby.Server.Implementations.Collections return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } + /// + public IEnumerable GetCollectionsContainingItem(User user, Guid itemId) + { + ArgumentNullException.ThrowIfNull(user); + + if (itemId.IsEmpty()) + { + return Enumerable.Empty(); + } + + return _linkedChildrenService + .GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet) + .Select(parentId => _libraryManager.GetItemById(parentId, user)) + .OfType(); + } + private IEnumerable GetCollections(User user) { var folder = GetCollectionsFolder(false).GetAwaiter().GetResult(); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index abf27b7702..6a30a80f1d 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -17,6 +17,7 @@ using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -50,6 +51,7 @@ public class LibraryController : BaseJellyfinApiController private readonly ISimilarItemsManager _similarItemsManager; private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; + private readonly ICollectionManager _collectionManager; private readonly IDtoService _dtoService; private readonly IActivityManager _activityManager; private readonly ILocalizationManager _localization; @@ -64,6 +66,7 @@ public class LibraryController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -75,6 +78,7 @@ public class LibraryController : BaseJellyfinApiController ISimilarItemsManager similarItemsManager, ILibraryManager libraryManager, IUserManager userManager, + ICollectionManager collectionManager, IDtoService dtoService, IActivityManager activityManager, ILocalizationManager localization, @@ -86,6 +90,7 @@ public class LibraryController : BaseJellyfinApiController _similarItemsManager = similarItemsManager; _libraryManager = libraryManager; _userManager = userManager; + _collectionManager = collectionManager; _dtoService = dtoService; _activityManager = activityManager; _localization = localization; @@ -704,6 +709,72 @@ public class LibraryController : BaseJellyfinApiController return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true); } + /// + /// Gets the collections that include the specified item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. The index of the first record in the output. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Collections returned. + /// User context missing. + /// Item not found. + /// The collections that contain the requested item. + [HttpGet("Items/{itemId}/Collections")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> GetItemCollections( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + + if (user is null) + { + return Unauthorized(); + } + + var item = _libraryManager.GetItemById(itemId, user); + if (item is null) + { + return NotFound(); + } + + var dtoOptions = new DtoOptions { Fields = fields }; + + var visibleCollections = _collectionManager + .GetCollectionsContainingItem(user, item.Id) + .OrderBy(i => i.SortName, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + IEnumerable pagedCollections = visibleCollections; + if (startIndex.HasValue) + { + pagedCollections = pagedCollections.Skip(startIndex.Value); + } + + if (limit.HasValue) + { + pagedCollections = pagedCollections.Take(limit.Value); + } + + var dtos = _dtoService.GetBaseItemDtos(pagedCollections.ToList(), dtoOptions, user); + + return new QueryResult( + startIndex, + visibleCollections.Count, + dtos); + } + /// /// Gets similar items. /// diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 9e11b6be62..5e5ce320a5 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -91,14 +91,25 @@ public class LinkedChildrenService : ILinkedChildrenService } /// - public IReadOnlyList GetManualLinkedParentIds(Guid childId) + public IReadOnlyList GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null) { using var context = _dbProvider.CreateDbContext(); - return context.LinkedChildren - .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual) - .Select(lc => lc.ParentId) - .Distinct() - .ToList(); + + var query = context.LinkedChildren + .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual); + + if (parentType.HasValue) + { + var parentTypeName = _itemTypeLookup.BaseItemKindNames[parentType.Value]; + query = query.Join( + context.BaseItems + .Where(item => item.Type == parentTypeName), + lc => lc.ParentId, + item => item.Id, + (lc, _) => lc); + } + + return query.Select(lc => lc.ParentId).Distinct().ToList(); } /// diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs index 206b5ac426..8d5d54ffd9 100644 --- a/MediaBrowser.Controller/Collections/ICollectionManager.cs +++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs @@ -57,6 +57,14 @@ namespace MediaBrowser.Controller.Collections /// IEnumerable{BaseItem}. IEnumerable CollapseItemsWithinBoxSets(IEnumerable items, User user); + /// + /// Gets the collections accessible to the supplied user that contain the provided item. + /// + /// The user. + /// The item identifier. + /// The collections containing the item. + IEnumerable GetCollectionsContainingItem(User user, Guid itemId); + /// /// Gets the folder where collections are stored. /// diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs index d0cddf54a6..a4614fc125 100644 --- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs +++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.Audio; using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; @@ -29,8 +30,9 @@ public interface ILinkedChildrenService /// Gets parent IDs that reference the specified child with LinkedChildType.Manual. /// /// The child item ID. + /// Optional parent item type filter. /// List of parent IDs that reference the child. - IReadOnlyList GetManualLinkedParentIds(Guid childId); + IReadOnlyList GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null); /// /// Updates LinkedChildren references from one child to another. diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs index edbb46b34c..b9b2862c65 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs @@ -23,6 +23,7 @@ public sealed class LibraryControllerTests : IClassFixture