aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-05-15 14:37:01 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-05-15 14:37:01 +0200
commit97c20e6ac5bf89aa0a29f950b9308036e589de12 (patch)
tree5ae14da7e58d49bd088310b54c4275fbeff25b44
parentd93e2d6667872ea16c523202f200c873fc8191ad (diff)
Fix movie recommendations
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs6
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs274
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs17
-rw-r--r--Jellyfin.Api/Controllers/MoviesController.cs248
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs27
-rw-r--r--MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs23
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs9
-rw-r--r--MediaBrowser.Controller/Library/ISimilarItemsManager.cs10
-rw-r--r--MediaBrowser.Controller/Persistence/IPeopleRepository.cs9
9 files changed, 475 insertions, 148 deletions
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);
}
+ /// <inheritdoc/>
+ public IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit)
+ {
+ return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit);
+ }
+
public void UpdatePeople(BaseItem item, List<PersonInfo> 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;
/// <summary>
-/// Provides similar items for movies and trailers.
+/// Provides similar items for movies and trailers using weighted scoring.
/// </summary>
-public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>
+public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, 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<JellyfinDbContext> _dbProvider;
+ private readonly IItemQueryHelpers _queryHelpers;
private readonly IServerConfigurationManager _serverConfigurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
/// </summary>
- /// <param name="libraryManager">The library manager.</param>
+ /// <param name="dbProvider">The database context factory.</param>
+ /// <param name="queryHelpers">The shared query helpers.</param>
/// <param name="serverConfigurationManager">The server configuration manager.</param>
public MovieSimilarItemsProvider(
- ILibraryManager libraryManager,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IItemQueryHelpers queryHelpers,
IServerConfigurationManager serverConfigurationManager)
{
- _libraryManager = libraryManager;
+ _dbProvider = dbProvider;
+ _queryHelpers = queryHelpers;
_serverConfigurationManager = serverConfigurationManager;
}
@@ -41,15 +70,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
/// <inheritdoc/>
- public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken)
+ public async Task<IReadOnlyList<BaseItemDto>> 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 : [];
}
/// <inheritdoc/>
- public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken)
+ public async Task<IReadOnlyList<BaseItemDto>> 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<Movie
_ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
};
- private IReadOnlyList<BaseItem> GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query)
+ /// <inheritdoc/>
+ public Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
+ IReadOnlyList<BaseItemDto> sourceItems,
+ SimilarItemsQuery query)
{
var includeItemTypes = new List<BaseItemKind> { 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<Guid>();
+ foreach (var (_, scores) in perSourceScores)
+ {
+ allCandidateIds.UnionWith(
+ scores.OrderByDescending(kvp => kvp.Value)
+ .Take(limit * 3)
+ .Select(kvp => kvp.Key));
+ }
+
+ var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
+ 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<Guid>();
+ var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>();
+
+ 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<Guid, Dictionary<Guid, int>> ComputeBatchScores(List<Guid> sourceIds, JellyfinDbContext context)
+ {
+ var result = new Dictionary<Guid, Dictionary<Guid, int>>();
+ 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();
}
+ /// <inheritdoc/>
+ public Task<Dictionary<Guid, IReadOnlyList<BaseItem>>> GetBatchSimilarItemsAsync(
+ IReadOnlyList<BaseItem> sourceItems,
+ SimilarItemsQuery query)
+ {
+ var batchProvider = _similarItemsProviders
+ .OfType<IBatchLocalSimilarItemsProvider>()
+ .FirstOrDefault();
+
+ if (batchProvider is null)
+ {
+ return Task.FromResult(new Dictionary<Guid, IReadOnlyList<BaseItem>>());
+ }
+
+ return batchProvider.GetBatchSimilarItemsAsync(sourceItems, query);
+ }
+
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
IReadOnlyList<SimilarItemReference> references,
int providerOrder,
diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs
index 50d34d0656..9c0e216f12 100644
--- a/Jellyfin.Api/Controllers/MoviesController.cs
+++ b/Jellyfin.Api/Controllers/MoviesController.cs
@@ -2,6 +2,9 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Enums;
@@ -33,6 +36,7 @@ public class MoviesController : BaseJellyfinApiController
private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly ISimilarItemsManager _similarItemsManager;
/// <summary>
/// Initializes a new instance of the <see cref="MoviesController"/> class.
@@ -41,16 +45,19 @@ public class MoviesController : BaseJellyfinApiController
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param>
public MoviesController(
IUserManager userManager,
ILibraryManager libraryManager,
IDtoService dtoService,
- IServerConfigurationManager serverConfigurationManager)
+ IServerConfigurationManager serverConfigurationManager,
+ ISimilarItemsManager similarItemsManager)
{
_userManager = userManager;
_libraryManager = libraryManager;
_dtoService = dtoService;
_serverConfigurationManager = serverConfigurationManager;
+ _similarItemsManager = similarItemsManager;
}
/// <summary>
@@ -61,15 +68,17 @@ public class MoviesController : BaseJellyfinApiController
/// <param name="fields">Optional. The fields to return.</param>
/// <param name="categoryLimit">The max number of categories to return.</param>
/// <param name="itemLimit">The max number of items to return per category.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
/// <response code="200">Movie recommendations returned.</response>
/// <returns>The list of movie recommendations.</returns>
[HttpGet("Recommendations")]
- public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
+ public Task<ActionResult<IEnumerable<RecommendationDto>>> GetMovieRecommendations(
[FromQuery] Guid? userId,
[FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
[FromQuery] int categoryLimit = 5,
- [FromQuery] int itemLimit = 8)
+ [FromQuery] int itemLimit = 8,
+ CancellationToken cancellationToken = default)
{
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
@@ -86,15 +95,13 @@ public class MoviesController : BaseJellyfinApiController
IncludeItemTypes = new[]
{
BaseItemKind.Movie,
- // nameof(Trailer),
- // nameof(LiveTvProgram)
},
- // IsMovie = true
OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) },
Limit = 7,
ParentId = parentIdGuid,
Recursive = true,
IsPlayed = true,
+ EnableGroupByMetadataKey = true,
DtoOptions = dtoOptions
};
@@ -122,31 +129,52 @@ public class MoviesController : BaseJellyfinApiController
});
var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
- // Get recently played directors
- var recentDirectors = GetDirectors(mostRecentMovies)
- .ToList();
+ var recentDirectors = GetDirectors(mostRecentMovies).ToList();
+ var recentActors = GetActors(mostRecentMovies).ToList();
+
+ // Cap baseline items to categoryLimit - the round-robin can't use more categories than that.
+ var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit
+ ? recentlyPlayedMovies.Take(categoryLimit).ToList()
+ : recentlyPlayedMovies;
+ var likedBaseline = likedMovies.Count > categoryLimit
+ ? likedMovies.Take(categoryLimit).ToList()
+ : likedMovies;
+
+ var batchQuery = new SimilarItemsQuery
+ {
+ User = user,
+ Limit = itemLimit,
+ DtoOptions = dtoOptions
+ };
+
+ var similarToRecentlyPlayed = BuildPendingFromBatch(
+ _similarItemsManager.GetBatchSimilarItemsAsync(recentlyPlayedBaseline, batchQuery),
+ recentlyPlayedBaseline,
+ RecommendationType.SimilarToRecentlyPlayed);
- // Get recently played actors
- var recentActors = GetActors(mostRecentMovies)
- .ToList();
+ var similarToLiked = BuildPendingFromBatch(
+ _similarItemsManager.GetBatchSimilarItemsAsync(likedBaseline, batchQuery),
+ likedBaseline,
+ RecommendationType.SimilarToLikedItem);
- var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
- var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
+ var hasDirectorFromRecentlyPlayed = GetWithPerson(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed);
+ var hasActorFromRecentlyPlayed = GetWithPerson(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed);
- var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
- var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
+ // Use a single enumerator per list, listed twice so MoveNext advances it
+ // twice per round-robin pass (giving these categories double weight).
+ // IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once;
+ // using var would box separately per list insertion, creating independent copies.
+ IEnumerator<PendingRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
+ IEnumerator<PendingRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
- var categoryTypes = new List<IEnumerator<RecommendationDto>>
+ var categoryTypes = new List<IEnumerator<PendingRecommendation>>
{
- // Give this extra weight
- similarToRecentlyPlayed,
- similarToRecentlyPlayed,
-
- // Give this extra weight
- similarToLiked,
- similarToLiked,
- hasDirectorFromRecentlyPlayed,
- hasActorFromRecentlyPlayed
+ similarToRecentlyPlayedEnum,
+ similarToRecentlyPlayedEnum,
+ similarToLikedEnum,
+ similarToLikedEnum,
+ hasDirectorFromRecentlyPlayed.GetEnumerator(),
+ hasActorFromRecentlyPlayed.GetEnumerator()
};
while (categories.Count < categoryLimit)
@@ -157,7 +185,17 @@ public class MoviesController : BaseJellyfinApiController
{
if (category.MoveNext())
{
- categories.Add(category.Current);
+ var pending = category.Current;
+ var returnItems = _dtoService.GetBaseItemDtos(pending.Items, dtoOptions, user);
+
+ categories.Add(new RecommendationDto
+ {
+ BaselineItemName = pending.BaselineItemName,
+ CategoryId = pending.CategoryId,
+ RecommendationType = pending.RecommendationType,
+ Items = returnItems
+ });
+
allEmpty = false;
if (categories.Count >= categoryLimit)
@@ -173,10 +211,36 @@ public class MoviesController : BaseJellyfinApiController
}
}
- return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable());
+ return Task.FromResult<ActionResult<IEnumerable<RecommendationDto>>>(
+ Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()));
+ }
+
+ private static List<PendingRecommendation> BuildPendingFromBatch(
+ Task<Dictionary<Guid, IReadOnlyList<BaseItem>>> batchTask,
+ IReadOnlyList<BaseItem> baselineItems,
+ RecommendationType type)
+ {
+ var batchResults = batchTask.GetAwaiter().GetResult();
+ var results = new List<PendingRecommendation>();
+
+ 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<RecommendationDto> GetWithDirector(
+ private IEnumerable<PendingRecommendation> GetWithPerson(
User? user,
IEnumerable<string> names,
int itemLimit,
@@ -190,17 +254,21 @@ public class MoviesController : BaseJellyfinApiController
itemTypes.Add(BaseItemKind.LiveTvProgram);
}
+ var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
+ ? [PersonType.Director]
+ : Array.Empty<string>();
+
foreach (var name in names)
{
var items = _libraryManager.GetItemList(
new InternalItemsQuery(user)
{
Person = name,
- // Account for duplicates by IMDb id, since the database doesn't support this yet
Limit = itemLimit + 2,
- PersonTypes = new[] { PersonType.Director },
+ PersonTypes = personTypes,
IncludeItemTypes = itemTypes.ToArray(),
IsMovie = true,
+ IsPlayed = false,
EnableGroupByMetadataKey = true,
DtoOptions = dtoOptions
}).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
@@ -209,119 +277,47 @@ public class MoviesController : BaseJellyfinApiController
if (items.Count > 0)
{
- var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
-
- yield return new RecommendationDto
+ yield return new PendingRecommendation
{
BaselineItemName = name,
CategoryId = name.GetMD5(),
RecommendationType = type,
- Items = returnItems
+ Items = items
};
}
}
}
- private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+ private IReadOnlyList<string> GetActors(IReadOnlyList<BaseItem> items)
{
- var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
- if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
- {
- itemTypes.Add(BaseItemKind.Trailer);
- itemTypes.Add(BaseItemKind.LiveTvProgram);
- }
-
- foreach (var name in names)
- {
- var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
- {
- Person = name,
- // Account for duplicates by IMDb id, since the database doesn't support this yet
- Limit = itemLimit + 2,
- IncludeItemTypes = itemTypes.ToArray(),
- IsMovie = true,
- EnableGroupByMetadataKey = true,
- DtoOptions = dtoOptions
- }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
- .Take(itemLimit)
- .ToList();
-
- if (items.Count > 0)
- {
- var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
-
- yield return new RecommendationDto
- {
- BaselineItemName = name,
- CategoryId = name.GetMD5(),
- RecommendationType = type,
- Items = returnItems
- };
- }
- }
+ var itemIds = items.Select(i => i.Id).ToArray();
+ return _libraryManager.GetPeopleNamesByItems(
+ itemIds,
+ new[] { PersonType.Actor, PersonType.GuestStar },
+ limit: 0);
}
- private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+ private IReadOnlyList<string> GetDirectors(IReadOnlyList<BaseItem> items)
{
- var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
- if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
- {
- itemTypes.Add(BaseItemKind.Trailer);
- itemTypes.Add(BaseItemKind.LiveTvProgram);
- }
-
- foreach (var item in baselineItems)
- {
- var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
- {
- Limit = itemLimit,
- IncludeItemTypes = itemTypes.ToArray(),
- IsMovie = true,
- EnableGroupByMetadataKey = true,
- DtoOptions = dtoOptions
- });
-
- if (similar.Count > 0)
- {
- var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
-
- yield return new RecommendationDto
- {
- BaselineItemName = item.Name,
- CategoryId = item.Id,
- RecommendationType = type,
- Items = returnItems
- };
- }
- }
+ var itemIds = items.Select(i => i.Id).ToArray();
+ return _libraryManager.GetPeopleNamesByItems(
+ itemIds,
+ [PersonType.Director],
+ limit: 0);
}
- private IEnumerable<string> GetActors(IEnumerable<BaseItem> items)
+ /// <summary>
+ /// Holds a recommendation category's BaseItems before DTO conversion.
+ /// DTO conversion is deferred until the round-robin actually selects the category.
+ /// </summary>
+ private sealed class PendingRecommendation
{
- var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director })
- {
- MaxListOrder = 3
- });
+ public required string BaselineItemName { get; init; }
- var itemIds = items.Select(i => i.Id).ToList();
-
- return people
- .Where(i => itemIds.Contains(i.ItemId))
- .Select(i => i.Name)
- .DistinctNames();
- }
-
- private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items)
- {
- var people = _libraryManager.GetPeople(new InternalPeopleQuery(
- new[] { PersonType.Director },
- Array.Empty<string>()));
+ public required Guid CategoryId { get; init; }
- var itemIds = items.Select(i => i.Id).ToList();
+ public required RecommendationType RecommendationType { get; init; }
- return people
- .Where(i => itemIds.Contains(i.ItemId))
- .Select(i => i.Name)
- .DistinctNames();
+ public required IReadOnlyList<BaseItem> Items { get; init; }
}
}
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 8f8741d00f..88bf6fa1bb 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -165,6 +165,31 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
transaction.Commit();
}
+ /// <inheritdoc/>
+ public IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit)
+ {
+ using var context = _dbProvider.CreateDbContext();
+ var query = context.PeopleBaseItemMap
+ .AsNoTracking()
+ .Where(m => itemIds.Contains(m.ItemId));
+
+ if (personTypes.Count > 0)
+ {
+ query = query.Where(m => personTypes.Contains(m.People.PersonType));
+ }
+
+ var names = query
+ .Select(m => m.People.Name)
+ .Distinct();
+
+ if (limit > 0)
+ {
+ names = names.Take(limit);
+ }
+
+ return names.ToArray();
+ }
+
private PersonInfo Map(People people)
{
var mapping = people.BaseItems?.FirstOrDefault();
@@ -239,7 +264,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value);
+ query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value));
}
if (!string.IsNullOrWhiteSpace(filter.NameContains))
diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs
new file mode 100644
index 0000000000..fe2ce7d394
--- /dev/null
+++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.Library;
+
+/// <summary>
+/// A local similar items provider that supports batch queries across multiple source items.
+/// Implementations share access filtering and entity loading across all sources for better performance.
+/// </summary>
+public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider
+{
+ /// <summary>
+ /// Gets similar items for multiple source items in a single batch.
+ /// </summary>
+ /// <param name="sourceItems">The source items to find similar items for.</param>
+ /// <param name="query">The query options.</param>
+ /// <returns>Per-source-item results keyed by source item ID.</returns>
+ Task<Dictionary<Guid, IReadOnlyList<BaseItem>>> GetBatchSimilarItemsAsync(
+ IReadOnlyList<BaseItem> sourceItems,
+ SimilarItemsQuery query);
+}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index f5e3d7034e..365f078652 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -598,6 +598,15 @@ namespace MediaBrowser.Controller.Library
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query);
/// <summary>
+ /// Gets distinct people names for multiple items.
+ /// </summary>
+ /// <param name="itemIds">The item IDs.</param>
+ /// <param name="personTypes">The person types to include.</param>
+ /// <param name="limit">Maximum number of names.</param>
+ /// <returns>The distinct people names.</returns>
+ IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit);
+
+ /// <summary>
/// Queries the items.
/// </summary>
/// <param name="query">The query.</param>
diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs
index 0ced6f71ee..1c826ea780 100644
--- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs
+++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs
@@ -47,4 +47,14 @@ public interface ISimilarItemsManager
int? limit,
LibraryOptions? libraryOptions,
CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Gets similar items for multiple source items in a single batch.
+ /// </summary>
+ /// <param name="sourceItems">The source items to find similar items for.</param>
+ /// <param name="query">The query options.</param>
+ /// <returns>Per-source-item results keyed by source item ID.</returns>
+ Task<Dictionary<Guid, IReadOnlyList<BaseItem>>> GetBatchSimilarItemsAsync(
+ IReadOnlyList<BaseItem> sourceItems,
+ SimilarItemsQuery query);
}
diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
index a89f3ef9ee..7474130ec4 100644
--- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
@@ -32,4 +32,13 @@ public interface IPeopleRepository
/// <param name="filter">The query.</param>
/// <returns>The list of people names matching the filter.</returns>
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter);
+
+ /// <summary>
+ /// Gets distinct people names for multiple items efficiently by querying from the mapping table.
+ /// </summary>
+ /// <param name="itemIds">The item IDs to get people for.</param>
+ /// <param name="personTypes">The person types to include (e.g. "Actor", "Director").</param>
+ /// <param name="limit">Maximum number of names to return.</param>
+ /// <returns>The distinct people names.</returns>
+ IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit);
}