aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs')
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs259
1 files changed, 246 insertions, 13 deletions
diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
index b56779cf3f..4e482c174a 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 = [];
/// <summary>
@@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager
/// <param name="appPaths">The server application paths.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="fileSystem">The file system.</param>
+ /// <param name="serverConfigurationManager">The server configuration manager.</param>
public SimilarItemsManager(
ILogger<SimilarItemsManager> logger,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager,
- IFileSystem fileSystem)
+ IFileSystem fileSystem,
+ IServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_appPaths = appPaths;
_libraryManager = libraryManager;
_fileSystem = fileSystem;
+ _serverConfigurationManager = serverConfigurationManager;
}
/// <inheritdoc/>
@@ -117,6 +125,7 @@ public class SimilarItemsManager : ISimilarItemsManager
var allResults = new List<(BaseItem Item, float Score)>();
var excludeIds = new HashSet<Guid> { item.Id };
+ var excludeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() };
foreach (var (providerOrder, provider) in orderedProviders.Index())
{
if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
@@ -141,7 +150,9 @@ public class SimilarItemsManager : ISimilarItemsManager
foreach (var (position, resultItem) in items.Index())
{
- if (excludeIds.Add(resultItem.Id))
+ var isNewId = excludeIds.Add(resultItem.Id);
+ var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey());
+ if (isNewId && isNewKey)
{
var score = CalculateScore(null, providerOrder, position);
allResults.Add((resultItem, score));
@@ -155,7 +166,7 @@ public class SimilarItemsManager : ISimilarItemsManager
var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false);
if (cachedReferences is not null)
{
- var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds);
+ var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
continue;
}
@@ -172,6 +183,7 @@ public class SimilarItemsManager : ISimilarItemsManager
// Collect references in batches and resolve against local library.
// Stop fetching once we have enough resolved local items.
const int BatchSize = 20;
+ const int MaxRemoteReferenceFetchLimit = 500;
var remaining = requestedLimit - allResults.Count;
var collectedReferences = new List<SimilarItemReference>();
var pendingBatch = new List<SimilarItemReference>();
@@ -183,12 +195,12 @@ public class SimilarItemsManager : ISimilarItemsManager
if (pendingBatch.Count >= BatchSize)
{
- var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds);
+ var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
remaining -= resolvedItems.Count;
pendingBatch.Clear();
- if (remaining <= 0)
+ if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit)
{
break;
}
@@ -198,7 +210,7 @@ public class SimilarItemsManager : ISimilarItemsManager
// Resolve any remaining references in the last partial batch
if (pendingBatch.Count > 0)
{
- var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds);
+ var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
}
@@ -225,20 +237,230 @@ public class SimilarItemsManager : ISimilarItemsManager
.ToList();
}
+ /// <inheritdoc/>
+ public async Task<IReadOnlyList<SimilarItemsRecommendation>> 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> { 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,
+ cancellationToken).ConfigureAwait(false);
+
+ var similarToLiked = await GetSimilarItemsRecommendationsAsync(
+ likedBaseline,
+ RecommendationType.SimilarToLikedItem,
+ batchQuery,
+ cancellationToken).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<T> to box the List<T>.Enumerator struct once;
+ // using var would box separately per list insertion, creating independent copies.
+ IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
+ IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
+
+ var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>>
+ {
+ similarToRecentlyPlayedEnum,
+ similarToRecentlyPlayedEnum,
+ similarToLikedEnum,
+ similarToLikedEnum,
+ hasDirectorFromRecentlyPlayed.GetEnumerator(),
+ hasActorFromRecentlyPlayed.GetEnumerator()
+ };
+
+ var categories = new List<SimilarItemsRecommendation>();
+ 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<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync(
+ IReadOnlyList<BaseItem> baselineItems,
+ RecommendationType recommendationType,
+ SimilarItemsQuery query,
+ CancellationToken cancellationToken)
+ {
+ var batchProvider = _similarItemsProviders
+ .OfType<IBatchLocalSimilarItemsProvider>()
+ .FirstOrDefault();
+
+ if (batchProvider is null || baselineItems.Count == 0)
+ {
+ return [];
+ }
+
+ var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
+
+ var recommendations = new List<SimilarItemsRecommendation>(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<SimilarItemsRecommendation> GetPersonRecommendations(
+ User? user,
+ IReadOnlyList<string> names,
+ int itemLimit,
+ DtoOptions dtoOptions,
+ RecommendationType type,
+ IReadOnlyList<BaseItemKind> itemTypes)
+ {
+ var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
+ ? [PersonType.Director]
+ : Array.Empty<string>();
+
+ 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<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes)
+ {
+ var itemIds = items.Select(i => i.Id).ToArray();
+ return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes)
+ .Values
+ .SelectMany(names => names)
+ .Distinct()
+ .ToArray();
+ }
+
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
IReadOnlyList<SimilarItemReference> references,
int providerOrder,
User? user,
DtoOptions dtoOptions,
BaseItemKind itemKind,
- HashSet<Guid> excludeIds)
+ HashSet<Guid> excludeIds,
+ HashSet<string> excludeKeys)
{
if (references.Count == 0)
{
return [];
}
- var resolvedById = new Dictionary<Guid, (BaseItem Item, float Score)>();
+ var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase);
var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance);
foreach (var (position, match) in references.Index())
@@ -269,7 +491,13 @@ public class SimilarItemsManager : ISimilarItemsManager
foreach (var item in items)
{
- if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id))
+ if (excludeIds.Contains(item.Id))
+ {
+ continue;
+ }
+
+ var presentationKey = item.GetPresentationUniqueKey();
+ if (excludeKeys.Contains(presentationKey))
{
continue;
}
@@ -279,10 +507,9 @@ public class SimilarItemsManager : ISimilarItemsManager
if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo))
{
var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
- if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score)
+ if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score)
{
- excludeIds.Add(item.Id);
- resolvedById[item.Id] = (item, score);
+ resolvedByKey[presentationKey] = (item, score);
}
break;
@@ -290,7 +517,13 @@ public class SimilarItemsManager : ISimilarItemsManager
}
}
- return [.. resolvedById.Values];
+ foreach (var (key, entry) in resolvedByKey)
+ {
+ excludeIds.Add(entry.Item.Id);
+ excludeKeys.Add(key);
+ }
+
+ return [.. resolvedByKey.Values];
}
private static float CalculateScore(float? matchScore, int providerOrder, int position)