mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-28 19:38:26 +01:00
Fix movie recommendations
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
// Get recently played actors
|
||||
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 similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
|
||||
var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
|
||||
var batchQuery = new SimilarItemsQuery
|
||||
{
|
||||
User = user,
|
||||
Limit = itemLimit,
|
||||
DtoOptions = dtoOptions
|
||||
};
|
||||
|
||||
var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
|
||||
var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
|
||||
var similarToRecentlyPlayed = BuildPendingFromBatch(
|
||||
_similarItemsManager.GetBatchSimilarItemsAsync(recentlyPlayedBaseline, batchQuery),
|
||||
recentlyPlayedBaseline,
|
||||
RecommendationType.SimilarToRecentlyPlayed);
|
||||
|
||||
var categoryTypes = new List<IEnumerator<RecommendationDto>>
|
||||
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<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<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 IEnumerable<RecommendationDto> GetWithDirector(
|
||||
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<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();
|
||||
public required Guid CategoryId { get; init; }
|
||||
|
||||
return people
|
||||
.Where(i => itemIds.Contains(i.ItemId))
|
||||
.Select(i => i.Name)
|
||||
.DistinctNames();
|
||||
}
|
||||
public required RecommendationType RecommendationType { get; init; }
|
||||
|
||||
private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items)
|
||||
{
|
||||
var people = _libraryManager.GetPeople(new InternalPeopleQuery(
|
||||
new[] { PersonType.Director },
|
||||
Array.Empty<string>()));
|
||||
|
||||
var itemIds = items.Select(i => i.Id).ToList();
|
||||
|
||||
return people
|
||||
.Where(i => itemIds.Contains(i.ItemId))
|
||||
.Select(i => i.Name)
|
||||
.DistinctNames();
|
||||
public required IReadOnlyList<BaseItem> Items { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -597,6 +597,15 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <returns>List<System.String>.</returns>
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user