< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.Library.SimilarItems.SimilarItemsManager
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
Line coverage
2%
Covered lines: 11
Uncovered lines: 361
Coverable lines: 372
Total lines: 749
Line coverage: 2.9%
Branch coverage
0%
Covered branches: 0
Total branches: 152
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 AM Line coverage: 4.3% (8/182) Branch coverage: 0% (0/96) Total lines: 4065/28/2026 - 12:15:50 AM Line coverage: 2.9% (9/308) Branch coverage: 0% (0/128) Total lines: 6196/1/2026 - 12:16:05 AM Line coverage: 2.8% (9/319) Branch coverage: 0% (0/130) Total lines: 6388/11/2026 - 12:17:00 AM Line coverage: 2.8% (9/319) Branch coverage: 0% (0/132) Total lines: 63910/29/2026 - 12:17:11 AM Line coverage: 2.9% (11/372) Branch coverage: 0% (0/152) Total lines: 749 5/16/2026 - 12:15:55 AM Line coverage: 4.3% (8/182) Branch coverage: 0% (0/96) Total lines: 4065/28/2026 - 12:15:50 AM Line coverage: 2.9% (9/308) Branch coverage: 0% (0/128) Total lines: 6196/1/2026 - 12:16:05 AM Line coverage: 2.8% (9/319) Branch coverage: 0% (0/130) Total lines: 6388/11/2026 - 12:17:00 AM Line coverage: 2.8% (9/319) Branch coverage: 0% (0/132) Total lines: 63910/29/2026 - 12:17:11 AM Line coverage: 2.9% (11/372) Branch coverage: 0% (0/152) Total lines: 749

Coverage delta

Coverage delta 2 -2

Metrics

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Text.Json;
 7using System.Threading;
 8using System.Threading.Tasks;
 9using Jellyfin.Data.Enums;
 10using Jellyfin.Database.Implementations;
 11using Jellyfin.Database.Implementations.Entities;
 12using Jellyfin.Database.Implementations.Enums;
 13using Jellyfin.Extensions;
 14using Jellyfin.Extensions.Json;
 15using MediaBrowser.Common.Extensions;
 16using MediaBrowser.Controller;
 17using MediaBrowser.Controller.Configuration;
 18using MediaBrowser.Controller.Dto;
 19using MediaBrowser.Controller.Entities;
 20using MediaBrowser.Controller.Library;
 21using MediaBrowser.Controller.Persistence;
 22using MediaBrowser.Model.Configuration;
 23using MediaBrowser.Model.Dto;
 24using MediaBrowser.Model.Entities;
 25using MediaBrowser.Model.IO;
 26using MediaBrowser.Model.Querying;
 27using Microsoft.EntityFrameworkCore;
 28using Microsoft.Extensions.Logging;
 29
 30namespace Emby.Server.Implementations.Library.SimilarItems;
 31
 32/// <summary>
 33/// Manages similar items providers and orchestrates similar items operations.
 34/// </summary>
 35public class SimilarItemsManager : ISimilarItemsManager
 36{
 37    private readonly ILogger<SimilarItemsManager> _logger;
 38    private readonly IServerApplicationPaths _appPaths;
 39    private readonly ILibraryManager _libraryManager;
 40    private readonly IFileSystem _fileSystem;
 41    private readonly IServerConfigurationManager _serverConfigurationManager;
 42    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 43    private readonly IItemQueryHelpers _queryHelpers;
 2744    private ISimilarItemsProvider[] _similarItemsProviders = [];
 45
 46    /// <summary>
 47    /// Initializes a new instance of the <see cref="SimilarItemsManager"/> class.
 48    /// </summary>
 49    /// <param name="logger">The logger.</param>
 50    /// <param name="appPaths">The server application paths.</param>
 51    /// <param name="libraryManager">The library manager.</param>
 52    /// <param name="fileSystem">The file system.</param>
 53    /// <param name="serverConfigurationManager">The server configuration manager.</param>
 54    /// <param name="dbProvider">The database context factory.</param>
 55    /// <param name="queryHelpers">The shared item query helpers.</param>
 56    public SimilarItemsManager(
 57        ILogger<SimilarItemsManager> logger,
 58        IServerApplicationPaths appPaths,
 59        ILibraryManager libraryManager,
 60        IFileSystem fileSystem,
 61        IServerConfigurationManager serverConfigurationManager,
 62        IDbContextFactory<JellyfinDbContext> dbProvider,
 63        IItemQueryHelpers queryHelpers)
 64    {
 2765        _logger = logger;
 2766        _appPaths = appPaths;
 2767        _libraryManager = libraryManager;
 2768        _fileSystem = fileSystem;
 2769        _serverConfigurationManager = serverConfigurationManager;
 2770        _dbProvider = dbProvider;
 2771        _queryHelpers = queryHelpers;
 2772    }
 73
 74    /// <inheritdoc/>
 75    public void AddParts(IEnumerable<ISimilarItemsProvider> providers)
 76    {
 2777        _similarItemsProviders = providers.ToArray();
 2778    }
 79
 80    /// <inheritdoc/>
 81    public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>()
 82        where T : BaseItem
 83    {
 084        var itemType = typeof(T);
 085        return _similarItemsProviders
 086            .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType))
 087                || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType)))
 088            .ToList();
 89    }
 90
 91    /// <inheritdoc/>
 92    public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(
 93        BaseItem item,
 94        IReadOnlyList<Guid> excludeArtistIds,
 95        User? user,
 96        DtoOptions dtoOptions,
 97        int? limit,
 98        LibraryOptions? libraryOptions,
 99        CancellationToken cancellationToken)
 100    {
 0101        ArgumentNullException.ThrowIfNull(item);
 0102        ArgumentNullException.ThrowIfNull(excludeArtistIds);
 103
 0104        var itemType = item.GetType();
 0105        var requestedLimit = limit ?? 50;
 0106        var itemKind = item.GetBaseItemKind();
 107
 108        // Ensure ProviderIds is included in DtoOptions for matching remote provider responses
 0109        if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds))
 110        {
 0111            dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray();
 112        }
 113
 114        // Local providers are always enabled. Remote providers must be explicitly enabled.
 0115        var localProviders = _similarItemsProviders
 0116            .OfType<ILocalSimilarItemsProvider>()
 0117            .Where(p => p.Supports(itemType))
 0118            .ToList();
 0119        var remoteProviders = _similarItemsProviders
 0120            .OfType<IRemoteSimilarItemsProvider>()
 0121            .Where(p => p.Supports(itemType));
 0122        var matchingProviders = new List<ISimilarItemsProvider>(localProviders);
 123
 0124        var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name);
 0125        if (typeOptions?.SimilarItemProviders?.Length > 0)
 126        {
 0127            matchingProviders.AddRange(remoteProviders
 0128                .Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase)));
 129        }
 130
 0131        var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order
 0132            ? order
 0133            : typeOptions?.SimilarItemProviders;
 0134        var orderedProviders = matchingProviders
 0135            .OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name))
 0136            .ToList();
 137
 0138        var allResults = new List<(BaseItem Item, float Score)>();
 0139        var excludeIds = new HashSet<Guid> { item.Id };
 0140        var excludeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() };
 0141        foreach (var (providerOrder, provider) in orderedProviders.Index())
 142        {
 0143            if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
 144            {
 145                break;
 146            }
 147
 148            try
 149            {
 0150                if (provider is ILocalSimilarItemsProvider localProvider)
 151                {
 0152                    var query = new SimilarItemsQuery
 0153                    {
 0154                        User = user,
 0155                        Limit = requestedLimit - allResults.Count,
 0156                        DtoOptions = dtoOptions,
 0157                        ExcludeItemIds = [.. excludeIds],
 0158                        ExcludeArtistIds = excludeArtistIds
 0159                    };
 160
 0161                    var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(
 162
 0163                    foreach (var (position, resultItem) in items.Index())
 164                    {
 0165                        var isNewId = excludeIds.Add(resultItem.Id);
 0166                        var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey());
 0167                        if (isNewId && isNewKey)
 168                        {
 0169                            var score = CalculateScore(null, providerOrder, position);
 0170                            allResults.Add((resultItem, score));
 171                        }
 172                    }
 173                }
 0174                else if (provider is IRemoteSimilarItemsProvider remoteProvider)
 175                {
 0176                    var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id);
 177
 0178                    var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAw
 0179                    if (cachedReferences is not null)
 180                    {
 0181                        var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, i
 0182                        allResults.AddRange(resolvedItems);
 0183                        continue;
 184                    }
 185
 0186                    var query = new SimilarItemsQuery
 0187                    {
 0188                        User = user,
 0189                        Limit = requestedLimit - allResults.Count,
 0190                        DtoOptions = dtoOptions,
 0191                        ExcludeItemIds = [.. excludeIds],
 0192                        ExcludeArtistIds = excludeArtistIds
 0193                    };
 194
 195                    // Collect references in batches and resolve against local library.
 196                    // Stop fetching once we have enough resolved local items.
 197                    const int BatchSize = 20;
 198                    const int MaxRemoteReferenceFetchLimit = 500;
 0199                    var remaining = requestedLimit - allResults.Count;
 0200                    var collectedReferences = new List<SimilarItemReference>();
 0201                    var pendingBatch = new List<SimilarItemReference>();
 202
 0203                    await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).
 204                    {
 0205                        collectedReferences.Add(reference);
 0206                        pendingBatch.Add(reference);
 207
 0208                        if (pendingBatch.Count >= BatchSize)
 209                        {
 0210                            var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, i
 0211                            allResults.AddRange(resolvedItems);
 0212                            remaining -= resolvedItems.Count;
 0213                            pendingBatch.Clear();
 214
 0215                            if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit)
 216                            {
 217                                break;
 218                            }
 219                        }
 220                    }
 221
 222                    // Resolve any remaining references in the last partial batch
 0223                    if (pendingBatch.Count > 0)
 224                    {
 0225                        var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemK
 0226                        allResults.AddRange(resolvedItems);
 227                    }
 228
 0229                    if (collectedReferences.Count > 0 && provider.CacheDuration is not null)
 230                    {
 0231                        await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, c
 232                    }
 0233                }
 0234            }
 0235            catch (OperationCanceledException)
 236            {
 0237                break;
 238            }
 0239            catch (Exception ex)
 240            {
 0241                _logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, 
 0242            }
 0243        }
 244
 0245        var ordered = allResults
 0246            .OrderByDescending(x => x.Score)
 0247            .Select(x => x.Item)
 0248            .Take(requestedLimit)
 0249            .ToList();
 250
 0251        return await FilterByLibraryAccessAsync(ordered, user, cancellationToken).ConfigureAwait(false);
 0252    }
 253
 254    private async Task<IReadOnlyList<BaseItem>> FilterByLibraryAccessAsync(
 255        IReadOnlyList<BaseItem> candidates,
 256        User? user,
 257        CancellationToken cancellationToken)
 258    {
 0259        if (candidates.Count == 0 || user is null)
 260        {
 0261            return candidates;
 262        }
 263
 0264        var accessFilter = SimilarItemsAccessFilter.Build(user, _libraryManager);
 265
 266        // No accessible libraries means nothing to compare against, and an empty TopParentIds set
 267        // would disable the filter rather than reject everything.
 0268        if (accessFilter.TopParentIds.Length == 0)
 269        {
 0270            return candidates;
 271        }
 272
 0273        Guid[] candidateIds = [.. candidates.Select(c => c.Id)];
 274
 0275        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0276        await using (dbContext.ConfigureAwait(false))
 277        {
 0278            var baseQuery = dbContext.BaseItems
 0279                .AsNoTracking()
 0280                .WhereOneOrMany(candidateIds, e => e.Id);
 281
 0282            baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
 283
 0284            var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
 0285            if (allowedCount == candidates.Count)
 286            {
 0287                return candidates;
 288            }
 289
 0290            var allowedIds = await baseQuery
 0291                .Select(e => e.Id)
 0292                .ToHashSetAsync(cancellationToken)
 0293                .ConfigureAwait(false);
 294
 0295            var filtered = candidates.Where(c => allowedIds.Contains(c.Id)).ToList();
 0296            _logger.LogDebug(
 0297                "Dropped {Dropped} of {Total} similar-item candidates due to user access filtering",
 0298                candidates.Count - filtered.Count,
 0299                candidates.Count);
 300
 0301            return filtered;
 302        }
 0303    }
 304
 305    /// <inheritdoc/>
 306    public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
 307        User? user,
 308        Guid parentId,
 309        int categoryLimit,
 310        int itemLimit,
 311        DtoOptions dtoOptions,
 312        CancellationToken cancellationToken)
 313    {
 0314        ArgumentNullException.ThrowIfNull(dtoOptions);
 315
 0316        var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
 0317        {
 0318            IncludeItemTypes = [BaseItemKind.Movie],
 0319            OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)],
 0320            Limit = 7,
 0321            ParentId = parentId,
 0322            Recursive = true,
 0323            IsPlayed = true,
 0324            EnableGroupByMetadataKey = true,
 0325            DtoOptions = dtoOptions
 0326        });
 327
 0328        var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
 0329        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
 330        {
 0331            itemTypes.Add(BaseItemKind.Trailer);
 0332            itemTypes.Add(BaseItemKind.LiveTvProgram);
 333        }
 334
 0335        var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
 0336        {
 0337            IncludeItemTypes = itemTypes.ToArray(),
 0338            IsMovie = true,
 0339            OrderBy = [(ItemSortBy.Random, SortOrder.Descending)],
 0340            Limit = 10,
 0341            IsFavoriteOrLiked = true,
 0342            ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
 0343            EnableGroupByMetadataKey = true,
 0344            ParentId = parentId,
 0345            Recursive = true,
 0346            DtoOptions = dtoOptions
 0347        });
 348
 0349        var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
 0350        var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]);
 0351        var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]);
 352
 353        // Cap baseline items to categoryLimit - the round-robin can't use more categories than that.
 0354        var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit
 0355            ? recentlyPlayedMovies.Take(categoryLimit).ToList()
 0356            : recentlyPlayedMovies;
 0357        var likedBaseline = likedMovies.Count > categoryLimit
 0358            ? likedMovies.Take(categoryLimit).ToList()
 0359            : likedMovies;
 360
 0361        var batchQuery = new SimilarItemsQuery
 0362        {
 0363            User = user,
 0364            Limit = itemLimit,
 0365            DtoOptions = dtoOptions
 0366        };
 367
 0368        var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync(
 0369            recentlyPlayedBaseline,
 0370            RecommendationType.SimilarToRecentlyPlayed,
 0371            batchQuery,
 0372            cancellationToken).ConfigureAwait(false);
 373
 0374        var similarToLiked = await GetSimilarItemsRecommendationsAsync(
 0375            likedBaseline,
 0376            RecommendationType.SimilarToLikedItem,
 0377            batchQuery,
 0378            cancellationToken).ConfigureAwait(false);
 379
 0380        var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, Recom
 0381        var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, Recommendat
 382
 383        // Use a single enumerator per list, listed twice so MoveNext advances it
 384        // twice per round-robin pass (giving these categories double weight).
 385        // IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once;
 386        // using var would box separately per list insertion, creating independent copies.
 0387        IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
 0388        IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
 389
 0390        var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>>
 0391        {
 0392            similarToRecentlyPlayedEnum,
 0393            similarToRecentlyPlayedEnum,
 0394            similarToLikedEnum,
 0395            similarToLikedEnum,
 0396            hasDirectorFromRecentlyPlayed.GetEnumerator(),
 0397            hasActorFromRecentlyPlayed.GetEnumerator()
 0398        };
 399
 0400        var categories = new List<SimilarItemsRecommendation>();
 0401        while (categories.Count < categoryLimit)
 402        {
 0403            var allEmpty = true;
 0404            foreach (var category in categoryTypes)
 405            {
 0406                if (category.MoveNext())
 407                {
 0408                    categories.Add(category.Current);
 0409                    allEmpty = false;
 410
 0411                    if (categories.Count >= categoryLimit)
 412                    {
 413                        break;
 414                    }
 415                }
 416            }
 417
 0418            if (allEmpty)
 419            {
 420                break;
 421            }
 422        }
 423
 0424        return [.. categories.OrderBy(i => i.RecommendationType)];
 0425    }
 426
 427    private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync(
 428        IReadOnlyList<BaseItem> baselineItems,
 429        RecommendationType recommendationType,
 430        SimilarItemsQuery query,
 431        CancellationToken cancellationToken)
 432    {
 0433        var batchProvider = _similarItemsProviders
 0434            .OfType<IBatchLocalSimilarItemsProvider>()
 0435            .FirstOrDefault();
 436
 0437        if (batchProvider is null || baselineItems.Count == 0)
 438        {
 0439            return [];
 440        }
 441
 0442        var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).Config
 443
 444        // Filter once across every category rather than per baseline, so a batch provider costs one
 445        // access query no matter how many categories it produced.
 0446        var allItems = batchResults.Values.SelectMany(items => items).DistinctBy(item => item.Id).ToList();
 0447        var allowed = await FilterByLibraryAccessAsync(allItems, query.User, cancellationToken).ConfigureAwait(false);
 448
 0449        HashSet<Guid>? allowedIds = allowed.Count == allItems.Count
 0450            ? null
 0451            : [.. allowed.Select(item => item.Id)];
 452
 0453        var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
 0454        foreach (var baseline in baselineItems)
 455        {
 0456            if (!batchResults.TryGetValue(baseline.Id, out var similar) || similar.Count == 0)
 457            {
 458                continue;
 459            }
 460
 0461            if (allowedIds is not null)
 462            {
 0463                similar = similar.Where(item => allowedIds.Contains(item.Id)).ToList();
 0464                if (similar.Count == 0)
 465                {
 466                    continue;
 467                }
 468            }
 469
 0470            recommendations.Add(new SimilarItemsRecommendation
 0471            {
 0472                BaselineItemName = baseline.Name,
 0473                CategoryId = baseline.Id,
 0474                RecommendationType = recommendationType,
 0475                Items = similar
 0476            });
 477        }
 478
 0479        return recommendations;
 0480    }
 481
 482    private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations(
 483        User? user,
 484        IReadOnlyList<string> names,
 485        int itemLimit,
 486        DtoOptions dtoOptions,
 487        RecommendationType type,
 488        IReadOnlyList<BaseItemKind> itemTypes)
 489    {
 0490        var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
 0491            ? [PersonType.Director]
 0492            : Array.Empty<string>();
 493
 0494        foreach (var name in names)
 495        {
 0496            var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
 0497            {
 0498                Person = name,
 0499                Limit = itemLimit + 2,
 0500                PersonTypes = personTypes,
 0501                IncludeItemTypes = itemTypes.ToArray(),
 0502                IsMovie = true,
 0503                IsPlayed = false,
 0504                EnableGroupByMetadataKey = true,
 0505                DtoOptions = dtoOptions
 0506            })
 0507                .DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.Inva
 0508                .Take(itemLimit)
 0509                .ToList();
 510
 0511            if (items.Count > 0)
 512            {
 0513                yield return new SimilarItemsRecommendation
 0514                {
 0515                    BaselineItemName = name,
 0516                    CategoryId = name.GetMD5(),
 0517                    RecommendationType = type,
 0518                    Items = items
 0519                };
 520            }
 521        }
 0522    }
 523
 524    private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes)
 525    {
 0526        var itemIds = items.Select(i => i.Id).ToArray();
 0527        return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes)
 0528            .Values
 0529            .SelectMany(names => names)
 0530            .Distinct()
 0531            .ToArray();
 532    }
 533
 534    private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
 535        IReadOnlyList<SimilarItemReference> references,
 536        int providerOrder,
 537        User? user,
 538        DtoOptions dtoOptions,
 539        BaseItemKind itemKind,
 540        HashSet<Guid> excludeIds,
 541        HashSet<string> excludeKeys)
 542    {
 0543        if (references.Count == 0)
 544        {
 0545            return [];
 546        }
 547
 0548        var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase);
 0549        var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(Stri
 550
 0551        foreach (var (position, match) in references.Index())
 552        {
 0553            var lookupKey = (match.ProviderName, match.ProviderId);
 0554            if (!providerLookup.TryGetValue(lookupKey, out var existing))
 555            {
 0556                providerLookup[lookupKey] = (match.Score, position);
 557            }
 0558            else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position))
 559            {
 0560                providerLookup[lookupKey] = (match.Score, position);
 561            }
 562        }
 563
 0564        var allProviderIds = providerLookup
 0565            .GroupBy(kvp => kvp.Key.ProviderName)
 0566            .ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray());
 567
 0568        var query = new InternalItemsQuery(user)
 0569        {
 0570            HasAnyProviderIds = allProviderIds,
 0571            IncludeItemTypes = [itemKind],
 0572            DtoOptions = dtoOptions
 0573        };
 574
 0575        var items = _libraryManager.GetItemList(query);
 576
 0577        foreach (var item in items)
 578        {
 0579            if (excludeIds.Contains(item.Id))
 580            {
 581                continue;
 582            }
 583
 0584            var presentationKey = item.GetPresentationUniqueKey();
 0585            if (excludeKeys.Contains(presentationKey))
 586            {
 587                continue;
 588            }
 589
 0590            foreach (var providerName in allProviderIds.Keys)
 591            {
 0592                if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerN
 593                {
 0594                    var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
 0595                    if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score)
 596                    {
 0597                        resolvedByKey[presentationKey] = (item, score);
 598                    }
 599
 0600                    break;
 601                }
 602            }
 603        }
 604
 0605        foreach (var (key, entry) in resolvedByKey)
 606        {
 0607            excludeIds.Add(entry.Item.Id);
 0608            excludeKeys.Add(key);
 609        }
 610
 0611        return [.. resolvedByKey.Values];
 612    }
 613
 614    private static float CalculateScore(float? matchScore, int providerOrder, int position)
 615    {
 616        // Use provider-supplied score if available, otherwise derive from position
 0617        var baseScore = matchScore ?? (1.0f - (position * 0.02f));
 618
 619        // Apply small boost based on provider order (higher priority providers get small bonus)
 0620        var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f;
 621
 0622        return Math.Clamp(baseScore + priorityBoost, 0f, 1f);
 623    }
 624
 625    private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName)
 626    {
 0627        if (orderConfig is null || orderConfig.Length == 0)
 628        {
 0629            return int.MaxValue;
 630        }
 631
 0632        var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnor
 0633        return index >= 0 ? index : int.MaxValue;
 634    }
 635
 636    private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId)
 637    {
 0638        var dataPath = Path.Combine(
 0639            _appPaths.CachePath,
 0640            $"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}");
 0641        return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json");
 642    }
 643
 644    private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken ca
 645    {
 0646        var fileInfo = _fileSystem.GetFileSystemInfo(cachePath);
 0647        if (!fileInfo.Exists || fileInfo.Length == 0)
 648        {
 0649            return null;
 650        }
 651
 652        try
 653        {
 0654            var stream = new FileStream(
 0655                cachePath,
 0656                FileMode.Open,
 0657                FileAccess.Read,
 0658                FileShare.ReadWrite | FileShare.Delete,
 0659                IODefaults.FileStreamBufferSize,
 0660                FileOptions.Asynchronous | FileOptions.SequentialScan);
 0661            await using (stream.ConfigureAwait(false))
 662            {
 0663                var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cance
 0664                if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt)
 665                {
 0666                    return cache.References;
 667                }
 668            }
 0669        }
 0670        catch (IOException ex)
 671        {
 0672            _logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath);
 0673        }
 0674        catch (JsonException ex)
 675        {
 0676            _logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath);
 0677        }
 678
 0679        return null;
 0680    }
 681
 682    private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cach
 683    {
 0684        string? tempPath = null;
 685        try
 686        {
 0687            var directory = Path.GetDirectoryName(cachePath);
 0688            if (!string.IsNullOrEmpty(directory))
 689            {
 0690                Directory.CreateDirectory(directory);
 691            }
 692
 0693            var cache = new SimilarItemsCache
 0694            {
 0695                References = references,
 0696                ExpiresAt = DateTime.UtcNow.Add(cacheDuration)
 0697            };
 698
 0699            tempPath = cachePath + "." + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + ".tmp";
 0700            var stream = File.Create(tempPath);
 0701            await using (stream.ConfigureAwait(false))
 702            {
 0703                await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwa
 704            }
 705
 0706            File.Move(tempPath, cachePath, true);
 0707            tempPath = null;
 0708        }
 0709        catch (IOException ex)
 710        {
 0711            _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath);
 0712        }
 713        finally
 714        {
 0715            if (tempPath is not null)
 716            {
 717                try
 718                {
 0719                    File.Delete(tempPath);
 0720                }
 0721                catch (IOException ex)
 722                {
 0723                    _logger.LogDebug(ex, "Failed to delete temporary similar items cache file {TempPath}", tempPath);
 0724                }
 725            }
 726        }
 0727    }
 728
 729    private sealed class SimilarItemsCache
 730    {
 731        public List<SimilarItemReference>? References { get; set; }
 732
 733        public DateTime ExpiresAt { get; set; }
 734    }
 735
 736    private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)>
 737    {
 0738        public static readonly StringTupleComparer Instance = new();
 739
 740        public bool Equals((string Key, string Value) x, (string Key, string Value) y)
 0741            => string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) &&
 0742               string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase);
 743
 744        public int GetHashCode((string Key, string Value) obj)
 0745            => HashCode.Combine(
 0746                StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key),
 0747                StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value));
 748    }
 749}

Methods/Properties

.ctor(Microsoft.Extensions.Logging.ILogger`1<Emby.Server.Implementations.Library.SimilarItems.SimilarItemsManager>,MediaBrowser.Controller.IServerApplicationPaths,MediaBrowser.Controller.Library.ILibraryManager,MediaBrowser.Model.IO.IFileSystem,MediaBrowser.Controller.Configuration.IServerConfigurationManager,Microsoft.EntityFrameworkCore.IDbContextFactory`1<Jellyfin.Database.Implementations.JellyfinDbContext>,MediaBrowser.Controller.Persistence.IItemQueryHelpers)
AddParts(System.Collections.Generic.IEnumerable`1<MediaBrowser.Controller.Library.ISimilarItemsProvider>)
GetSimilarItemsProviders()
GetSimilarItemsAsync()
FilterByLibraryAccessAsync()
GetMovieRecommendationsAsync()
GetSimilarItemsRecommendationsAsync()
GetPersonRecommendations()
GetPeopleNames(System.Collections.Generic.IReadOnlyList`1<MediaBrowser.Controller.Entities.BaseItem>,System.Collections.Generic.IReadOnlyList`1<System.String>)
ResolveRemoteReferences(System.Collections.Generic.IReadOnlyList`1<MediaBrowser.Controller.Library.SimilarItemReference>,System.Int32,Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Dto.DtoOptions,Jellyfin.Data.Enums.BaseItemKind,System.Collections.Generic.HashSet`1<System.Guid>,System.Collections.Generic.HashSet`1<System.String>)
CalculateScore(System.Nullable`1<System.Single>,System.Int32,System.Int32)
GetConfiguredSimilarProviderOrder(System.String[],System.String)
GetSimilarItemsCachePath(System.String,System.String,System.Guid)
TryReadSimilarItemsCacheAsync()
SaveSimilarItemsCacheAsync()
.cctor()
Equals(System.ValueTuple`2<System.String,System.String>,System.ValueTuple`2<System.String,System.String>)
GetHashCode(System.ValueTuple`2<System.String,System.String>)