< 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: 9
Uncovered lines: 310
Coverable lines: 319
Total lines: 639
Line coverage: 2.8%
Branch coverage
0%
Covered branches: 0
Total branches: 132
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: 639 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: 639

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.Entities;
 11using Jellyfin.Database.Implementations.Enums;
 12using Jellyfin.Extensions.Json;
 13using MediaBrowser.Common.Extensions;
 14using MediaBrowser.Controller;
 15using MediaBrowser.Controller.Configuration;
 16using MediaBrowser.Controller.Dto;
 17using MediaBrowser.Controller.Entities;
 18using MediaBrowser.Controller.Library;
 19using MediaBrowser.Model.Configuration;
 20using MediaBrowser.Model.Dto;
 21using MediaBrowser.Model.Entities;
 22using MediaBrowser.Model.IO;
 23using MediaBrowser.Model.Querying;
 24using Microsoft.Extensions.Logging;
 25
 26namespace Emby.Server.Implementations.Library.SimilarItems;
 27
 28/// <summary>
 29/// Manages similar items providers and orchestrates similar items operations.
 30/// </summary>
 31public class SimilarItemsManager : ISimilarItemsManager
 32{
 33    private readonly ILogger<SimilarItemsManager> _logger;
 34    private readonly IServerApplicationPaths _appPaths;
 35    private readonly ILibraryManager _libraryManager;
 36    private readonly IFileSystem _fileSystem;
 37    private readonly IServerConfigurationManager _serverConfigurationManager;
 2238    private ISimilarItemsProvider[] _similarItemsProviders = [];
 39
 40    /// <summary>
 41    /// Initializes a new instance of the <see cref="SimilarItemsManager"/> class.
 42    /// </summary>
 43    /// <param name="logger">The logger.</param>
 44    /// <param name="appPaths">The server application paths.</param>
 45    /// <param name="libraryManager">The library manager.</param>
 46    /// <param name="fileSystem">The file system.</param>
 47    /// <param name="serverConfigurationManager">The server configuration manager.</param>
 48    public SimilarItemsManager(
 49        ILogger<SimilarItemsManager> logger,
 50        IServerApplicationPaths appPaths,
 51        ILibraryManager libraryManager,
 52        IFileSystem fileSystem,
 53        IServerConfigurationManager serverConfigurationManager)
 54    {
 2255        _logger = logger;
 2256        _appPaths = appPaths;
 2257        _libraryManager = libraryManager;
 2258        _fileSystem = fileSystem;
 2259        _serverConfigurationManager = serverConfigurationManager;
 2260    }
 61
 62    /// <inheritdoc/>
 63    public void AddParts(IEnumerable<ISimilarItemsProvider> providers)
 64    {
 2265        _similarItemsProviders = providers.ToArray();
 2266    }
 67
 68    /// <inheritdoc/>
 69    public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>()
 70        where T : BaseItem
 71    {
 072        var itemType = typeof(T);
 073        return _similarItemsProviders
 074            .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType))
 075                || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType)))
 076            .ToList();
 77    }
 78
 79    /// <inheritdoc/>
 80    public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(
 81        BaseItem item,
 82        IReadOnlyList<Guid> excludeArtistIds,
 83        User? user,
 84        DtoOptions dtoOptions,
 85        int? limit,
 86        LibraryOptions? libraryOptions,
 87        CancellationToken cancellationToken)
 88    {
 089        ArgumentNullException.ThrowIfNull(item);
 090        ArgumentNullException.ThrowIfNull(excludeArtistIds);
 91
 092        var itemType = item.GetType();
 093        var requestedLimit = limit ?? 50;
 094        var itemKind = item.GetBaseItemKind();
 95
 96        // Ensure ProviderIds is included in DtoOptions for matching remote provider responses
 097        if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds))
 98        {
 099            dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray();
 100        }
 101
 102        // Local providers are always enabled. Remote providers must be explicitly enabled.
 0103        var localProviders = _similarItemsProviders
 0104            .OfType<ILocalSimilarItemsProvider>()
 0105            .Where(p => p.Supports(itemType))
 0106            .ToList();
 0107        var remoteProviders = _similarItemsProviders
 0108            .OfType<IRemoteSimilarItemsProvider>()
 0109            .Where(p => p.Supports(itemType));
 0110        var matchingProviders = new List<ISimilarItemsProvider>(localProviders);
 111
 0112        var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name);
 0113        if (typeOptions?.SimilarItemProviders?.Length > 0)
 114        {
 0115            matchingProviders.AddRange(remoteProviders
 0116                .Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase)));
 117        }
 118
 0119        var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order
 0120            ? order
 0121            : typeOptions?.SimilarItemProviders;
 0122        var orderedProviders = matchingProviders
 0123            .OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name))
 0124            .ToList();
 125
 0126        var allResults = new List<(BaseItem Item, float Score)>();
 0127        var excludeIds = new HashSet<Guid> { item.Id };
 0128        var excludeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() };
 0129        foreach (var (providerOrder, provider) in orderedProviders.Index())
 130        {
 0131            if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
 132            {
 133                break;
 134            }
 135
 136            try
 137            {
 0138                if (provider is ILocalSimilarItemsProvider localProvider)
 139                {
 0140                    var query = new SimilarItemsQuery
 0141                    {
 0142                        User = user,
 0143                        Limit = requestedLimit - allResults.Count,
 0144                        DtoOptions = dtoOptions,
 0145                        ExcludeItemIds = [.. excludeIds],
 0146                        ExcludeArtistIds = excludeArtistIds
 0147                    };
 148
 0149                    var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(
 150
 0151                    foreach (var (position, resultItem) in items.Index())
 152                    {
 0153                        var isNewId = excludeIds.Add(resultItem.Id);
 0154                        var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey());
 0155                        if (isNewId && isNewKey)
 156                        {
 0157                            var score = CalculateScore(null, providerOrder, position);
 0158                            allResults.Add((resultItem, score));
 159                        }
 160                    }
 161                }
 0162                else if (provider is IRemoteSimilarItemsProvider remoteProvider)
 163                {
 0164                    var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id);
 165
 0166                    var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAw
 0167                    if (cachedReferences is not null)
 168                    {
 0169                        var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, i
 0170                        allResults.AddRange(resolvedItems);
 0171                        continue;
 172                    }
 173
 0174                    var query = new SimilarItemsQuery
 0175                    {
 0176                        User = user,
 0177                        Limit = requestedLimit - allResults.Count,
 0178                        DtoOptions = dtoOptions,
 0179                        ExcludeItemIds = [.. excludeIds],
 0180                        ExcludeArtistIds = excludeArtistIds
 0181                    };
 182
 183                    // Collect references in batches and resolve against local library.
 184                    // Stop fetching once we have enough resolved local items.
 185                    const int BatchSize = 20;
 186                    const int MaxRemoteReferenceFetchLimit = 500;
 0187                    var remaining = requestedLimit - allResults.Count;
 0188                    var collectedReferences = new List<SimilarItemReference>();
 0189                    var pendingBatch = new List<SimilarItemReference>();
 190
 0191                    await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).
 192                    {
 0193                        collectedReferences.Add(reference);
 0194                        pendingBatch.Add(reference);
 195
 0196                        if (pendingBatch.Count >= BatchSize)
 197                        {
 0198                            var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, i
 0199                            allResults.AddRange(resolvedItems);
 0200                            remaining -= resolvedItems.Count;
 0201                            pendingBatch.Clear();
 202
 0203                            if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit)
 204                            {
 205                                break;
 206                            }
 207                        }
 208                    }
 209
 210                    // Resolve any remaining references in the last partial batch
 0211                    if (pendingBatch.Count > 0)
 212                    {
 0213                        var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemK
 0214                        allResults.AddRange(resolvedItems);
 215                    }
 216
 0217                    if (collectedReferences.Count > 0 && provider.CacheDuration is not null)
 218                    {
 0219                        await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, c
 220                    }
 0221                }
 0222            }
 0223            catch (OperationCanceledException)
 224            {
 0225                break;
 226            }
 0227            catch (Exception ex)
 228            {
 0229                _logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, 
 0230            }
 0231        }
 232
 0233        return allResults
 0234            .OrderByDescending(x => x.Score)
 0235            .Select(x => x.Item)
 0236            .Take(requestedLimit)
 0237            .ToList();
 0238    }
 239
 240    /// <inheritdoc/>
 241    public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
 242        User? user,
 243        Guid parentId,
 244        int categoryLimit,
 245        int itemLimit,
 246        DtoOptions dtoOptions,
 247        CancellationToken cancellationToken)
 248    {
 0249        ArgumentNullException.ThrowIfNull(dtoOptions);
 250
 0251        var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
 0252        {
 0253            IncludeItemTypes = [BaseItemKind.Movie],
 0254            OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)],
 0255            Limit = 7,
 0256            ParentId = parentId,
 0257            Recursive = true,
 0258            IsPlayed = true,
 0259            EnableGroupByMetadataKey = true,
 0260            DtoOptions = dtoOptions
 0261        });
 262
 0263        var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
 0264        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
 265        {
 0266            itemTypes.Add(BaseItemKind.Trailer);
 0267            itemTypes.Add(BaseItemKind.LiveTvProgram);
 268        }
 269
 0270        var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
 0271        {
 0272            IncludeItemTypes = itemTypes.ToArray(),
 0273            IsMovie = true,
 0274            OrderBy = [(ItemSortBy.Random, SortOrder.Descending)],
 0275            Limit = 10,
 0276            IsFavoriteOrLiked = true,
 0277            ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
 0278            EnableGroupByMetadataKey = true,
 0279            ParentId = parentId,
 0280            Recursive = true,
 0281            DtoOptions = dtoOptions
 0282        });
 283
 0284        var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
 0285        var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]);
 0286        var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]);
 287
 288        // Cap baseline items to categoryLimit - the round-robin can't use more categories than that.
 0289        var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit
 0290            ? recentlyPlayedMovies.Take(categoryLimit).ToList()
 0291            : recentlyPlayedMovies;
 0292        var likedBaseline = likedMovies.Count > categoryLimit
 0293            ? likedMovies.Take(categoryLimit).ToList()
 0294            : likedMovies;
 295
 0296        var batchQuery = new SimilarItemsQuery
 0297        {
 0298            User = user,
 0299            Limit = itemLimit,
 0300            DtoOptions = dtoOptions
 0301        };
 302
 0303        var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync(
 0304            recentlyPlayedBaseline,
 0305            RecommendationType.SimilarToRecentlyPlayed,
 0306            batchQuery,
 0307            cancellationToken).ConfigureAwait(false);
 308
 0309        var similarToLiked = await GetSimilarItemsRecommendationsAsync(
 0310            likedBaseline,
 0311            RecommendationType.SimilarToLikedItem,
 0312            batchQuery,
 0313            cancellationToken).ConfigureAwait(false);
 314
 0315        var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, Recom
 0316        var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, Recommendat
 317
 318        // Use a single enumerator per list, listed twice so MoveNext advances it
 319        // twice per round-robin pass (giving these categories double weight).
 320        // IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once;
 321        // using var would box separately per list insertion, creating independent copies.
 0322        IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
 0323        IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
 324
 0325        var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>>
 0326        {
 0327            similarToRecentlyPlayedEnum,
 0328            similarToRecentlyPlayedEnum,
 0329            similarToLikedEnum,
 0330            similarToLikedEnum,
 0331            hasDirectorFromRecentlyPlayed.GetEnumerator(),
 0332            hasActorFromRecentlyPlayed.GetEnumerator()
 0333        };
 334
 0335        var categories = new List<SimilarItemsRecommendation>();
 0336        while (categories.Count < categoryLimit)
 337        {
 0338            var allEmpty = true;
 0339            foreach (var category in categoryTypes)
 340            {
 0341                if (category.MoveNext())
 342                {
 0343                    categories.Add(category.Current);
 0344                    allEmpty = false;
 345
 0346                    if (categories.Count >= categoryLimit)
 347                    {
 348                        break;
 349                    }
 350                }
 351            }
 352
 0353            if (allEmpty)
 354            {
 355                break;
 356            }
 357        }
 358
 0359        return [.. categories.OrderBy(i => i.RecommendationType)];
 0360    }
 361
 362    private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync(
 363        IReadOnlyList<BaseItem> baselineItems,
 364        RecommendationType recommendationType,
 365        SimilarItemsQuery query,
 366        CancellationToken cancellationToken)
 367    {
 0368        var batchProvider = _similarItemsProviders
 0369            .OfType<IBatchLocalSimilarItemsProvider>()
 0370            .FirstOrDefault();
 371
 0372        if (batchProvider is null || baselineItems.Count == 0)
 373        {
 0374            return [];
 375        }
 376
 0377        var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).Config
 378
 0379        var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
 0380        foreach (var baseline in baselineItems)
 381        {
 0382            if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
 383            {
 0384                recommendations.Add(new SimilarItemsRecommendation
 0385                {
 0386                    BaselineItemName = baseline.Name,
 0387                    CategoryId = baseline.Id,
 0388                    RecommendationType = recommendationType,
 0389                    Items = similar
 0390                });
 391            }
 392        }
 393
 0394        return recommendations;
 0395    }
 396
 397    private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations(
 398        User? user,
 399        IReadOnlyList<string> names,
 400        int itemLimit,
 401        DtoOptions dtoOptions,
 402        RecommendationType type,
 403        IReadOnlyList<BaseItemKind> itemTypes)
 404    {
 0405        var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
 0406            ? [PersonType.Director]
 0407            : Array.Empty<string>();
 408
 0409        foreach (var name in names)
 410        {
 0411            var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
 0412            {
 0413                Person = name,
 0414                Limit = itemLimit + 2,
 0415                PersonTypes = personTypes,
 0416                IncludeItemTypes = itemTypes.ToArray(),
 0417                IsMovie = true,
 0418                IsPlayed = false,
 0419                EnableGroupByMetadataKey = true,
 0420                DtoOptions = dtoOptions
 0421            })
 0422                .DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.Inva
 0423                .Take(itemLimit)
 0424                .ToList();
 425
 0426            if (items.Count > 0)
 427            {
 0428                yield return new SimilarItemsRecommendation
 0429                {
 0430                    BaselineItemName = name,
 0431                    CategoryId = name.GetMD5(),
 0432                    RecommendationType = type,
 0433                    Items = items
 0434                };
 435            }
 436        }
 0437    }
 438
 439    private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes)
 440    {
 0441        var itemIds = items.Select(i => i.Id).ToArray();
 0442        return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes)
 0443            .Values
 0444            .SelectMany(names => names)
 0445            .Distinct()
 0446            .ToArray();
 447    }
 448
 449    private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
 450        IReadOnlyList<SimilarItemReference> references,
 451        int providerOrder,
 452        User? user,
 453        DtoOptions dtoOptions,
 454        BaseItemKind itemKind,
 455        HashSet<Guid> excludeIds,
 456        HashSet<string> excludeKeys)
 457    {
 0458        if (references.Count == 0)
 459        {
 0460            return [];
 461        }
 462
 0463        var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase);
 0464        var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(Stri
 465
 0466        foreach (var (position, match) in references.Index())
 467        {
 0468            var lookupKey = (match.ProviderName, match.ProviderId);
 0469            if (!providerLookup.TryGetValue(lookupKey, out var existing))
 470            {
 0471                providerLookup[lookupKey] = (match.Score, position);
 472            }
 0473            else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position))
 474            {
 0475                providerLookup[lookupKey] = (match.Score, position);
 476            }
 477        }
 478
 0479        var allProviderIds = providerLookup
 0480            .GroupBy(kvp => kvp.Key.ProviderName)
 0481            .ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray());
 482
 0483        var query = new InternalItemsQuery(user)
 0484        {
 0485            HasAnyProviderIds = allProviderIds,
 0486            IncludeItemTypes = [itemKind],
 0487            DtoOptions = dtoOptions
 0488        };
 489
 0490        var items = _libraryManager.GetItemList(query);
 491
 0492        foreach (var item in items)
 493        {
 0494            if (excludeIds.Contains(item.Id))
 495            {
 496                continue;
 497            }
 498
 0499            var presentationKey = item.GetPresentationUniqueKey();
 0500            if (excludeKeys.Contains(presentationKey))
 501            {
 502                continue;
 503            }
 504
 0505            foreach (var providerName in allProviderIds.Keys)
 506            {
 0507                if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerN
 508                {
 0509                    var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
 0510                    if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score)
 511                    {
 0512                        resolvedByKey[presentationKey] = (item, score);
 513                    }
 514
 0515                    break;
 516                }
 517            }
 518        }
 519
 0520        foreach (var (key, entry) in resolvedByKey)
 521        {
 0522            excludeIds.Add(entry.Item.Id);
 0523            excludeKeys.Add(key);
 524        }
 525
 0526        return [.. resolvedByKey.Values];
 527    }
 528
 529    private static float CalculateScore(float? matchScore, int providerOrder, int position)
 530    {
 531        // Use provider-supplied score if available, otherwise derive from position
 0532        var baseScore = matchScore ?? (1.0f - (position * 0.02f));
 533
 534        // Apply small boost based on provider order (higher priority providers get small bonus)
 0535        var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f;
 536
 0537        return Math.Clamp(baseScore + priorityBoost, 0f, 1f);
 538    }
 539
 540    private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName)
 541    {
 0542        if (orderConfig is null || orderConfig.Length == 0)
 543        {
 0544            return int.MaxValue;
 545        }
 546
 0547        var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnor
 0548        return index >= 0 ? index : int.MaxValue;
 549    }
 550
 551    private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId)
 552    {
 0553        var dataPath = Path.Combine(
 0554            _appPaths.CachePath,
 0555            $"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}");
 0556        return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json");
 557    }
 558
 559    private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken ca
 560    {
 0561        var fileInfo = _fileSystem.GetFileSystemInfo(cachePath);
 0562        if (!fileInfo.Exists || fileInfo.Length == 0)
 563        {
 0564            return null;
 565        }
 566
 567        try
 568        {
 0569            var stream = File.OpenRead(cachePath);
 0570            await using (stream.ConfigureAwait(false))
 571            {
 0572                var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cance
 0573                if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt)
 574                {
 0575                    return cache.References;
 576                }
 577            }
 0578        }
 0579        catch (IOException ex)
 580        {
 0581            _logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath);
 0582        }
 0583        catch (JsonException ex)
 584        {
 0585            _logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath);
 0586        }
 587
 0588        return null;
 0589    }
 590
 591    private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cach
 592    {
 593        try
 594        {
 0595            var directory = Path.GetDirectoryName(cachePath);
 0596            if (!string.IsNullOrEmpty(directory))
 597            {
 0598                Directory.CreateDirectory(directory);
 599            }
 600
 0601            var cache = new SimilarItemsCache
 0602            {
 0603                References = references,
 0604                ExpiresAt = DateTime.UtcNow.Add(cacheDuration)
 0605            };
 606
 0607            var stream = File.Create(cachePath);
 0608            await using (stream.ConfigureAwait(false))
 609            {
 0610                await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwa
 611            }
 0612        }
 0613        catch (IOException ex)
 614        {
 0615            _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath);
 0616        }
 0617    }
 618
 619    private sealed class SimilarItemsCache
 620    {
 621        public List<SimilarItemReference>? References { get; set; }
 622
 623        public DateTime ExpiresAt { get; set; }
 624    }
 625
 626    private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)>
 627    {
 0628        public static readonly StringTupleComparer Instance = new();
 629
 630        public bool Equals((string Key, string Value) x, (string Key, string Value) y)
 0631            => string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) &&
 0632               string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase);
 633
 634        public int GetHashCode((string Key, string Value) obj)
 0635            => HashCode.Combine(
 0636                StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key),
 0637                StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value));
 638    }
 639}

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)
AddParts(System.Collections.Generic.IEnumerable`1<MediaBrowser.Controller.Library.ISimilarItemsProvider>)
GetSimilarItemsProviders()
GetSimilarItemsAsync()
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>)