< 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: 638
Line coverage: 2.8%
Branch coverage
0%
Covered branches: 0
Total branches: 130
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: 638 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: 638

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;
 2138    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    {
 2155        _logger = logger;
 2156        _appPaths = appPaths;
 2157        _libraryManager = libraryManager;
 2158        _fileSystem = fileSystem;
 2159        _serverConfigurationManager = serverConfigurationManager;
 2160    }
 61
 62    /// <inheritdoc/>
 63    public void AddParts(IEnumerable<ISimilarItemsProvider> providers)
 64    {
 2165        _similarItemsProviders = providers.ToArray();
 2166    }
 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;
 0186                    var remaining = requestedLimit - allResults.Count;
 0187                    var collectedReferences = new List<SimilarItemReference>();
 0188                    var pendingBatch = new List<SimilarItemReference>();
 189
 0190                    await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).
 191                    {
 0192                        collectedReferences.Add(reference);
 0193                        pendingBatch.Add(reference);
 194
 0195                        if (pendingBatch.Count >= BatchSize)
 196                        {
 0197                            var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, i
 0198                            allResults.AddRange(resolvedItems);
 0199                            remaining -= resolvedItems.Count;
 0200                            pendingBatch.Clear();
 201
 0202                            if (remaining <= 0)
 203                            {
 204                                break;
 205                            }
 206                        }
 207                    }
 208
 209                    // Resolve any remaining references in the last partial batch
 0210                    if (pendingBatch.Count > 0)
 211                    {
 0212                        var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemK
 0213                        allResults.AddRange(resolvedItems);
 214                    }
 215
 0216                    if (collectedReferences.Count > 0 && provider.CacheDuration is not null)
 217                    {
 0218                        await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, c
 219                    }
 0220                }
 0221            }
 0222            catch (OperationCanceledException)
 223            {
 0224                break;
 225            }
 0226            catch (Exception ex)
 227            {
 0228                _logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, 
 0229            }
 0230        }
 231
 0232        return allResults
 0233            .OrderByDescending(x => x.Score)
 0234            .Select(x => x.Item)
 0235            .Take(requestedLimit)
 0236            .ToList();
 0237    }
 238
 239    /// <inheritdoc/>
 240    public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync(
 241        User? user,
 242        Guid parentId,
 243        int categoryLimit,
 244        int itemLimit,
 245        DtoOptions dtoOptions,
 246        CancellationToken cancellationToken)
 247    {
 0248        ArgumentNullException.ThrowIfNull(dtoOptions);
 249
 0250        var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
 0251        {
 0252            IncludeItemTypes = [BaseItemKind.Movie],
 0253            OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)],
 0254            Limit = 7,
 0255            ParentId = parentId,
 0256            Recursive = true,
 0257            IsPlayed = true,
 0258            EnableGroupByMetadataKey = true,
 0259            DtoOptions = dtoOptions
 0260        });
 261
 0262        var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
 0263        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
 264        {
 0265            itemTypes.Add(BaseItemKind.Trailer);
 0266            itemTypes.Add(BaseItemKind.LiveTvProgram);
 267        }
 268
 0269        var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
 0270        {
 0271            IncludeItemTypes = itemTypes.ToArray(),
 0272            IsMovie = true,
 0273            OrderBy = [(ItemSortBy.Random, SortOrder.Descending)],
 0274            Limit = 10,
 0275            IsFavoriteOrLiked = true,
 0276            ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
 0277            EnableGroupByMetadataKey = true,
 0278            ParentId = parentId,
 0279            Recursive = true,
 0280            DtoOptions = dtoOptions
 0281        });
 282
 0283        var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
 0284        var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]);
 0285        var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]);
 286
 287        // Cap baseline items to categoryLimit - the round-robin can't use more categories than that.
 0288        var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit
 0289            ? recentlyPlayedMovies.Take(categoryLimit).ToList()
 0290            : recentlyPlayedMovies;
 0291        var likedBaseline = likedMovies.Count > categoryLimit
 0292            ? likedMovies.Take(categoryLimit).ToList()
 0293            : likedMovies;
 294
 0295        var batchQuery = new SimilarItemsQuery
 0296        {
 0297            User = user,
 0298            Limit = itemLimit,
 0299            DtoOptions = dtoOptions
 0300        };
 301
 0302        var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync(
 0303            recentlyPlayedBaseline,
 0304            RecommendationType.SimilarToRecentlyPlayed,
 0305            batchQuery,
 0306            cancellationToken).ConfigureAwait(false);
 307
 0308        var similarToLiked = await GetSimilarItemsRecommendationsAsync(
 0309            likedBaseline,
 0310            RecommendationType.SimilarToLikedItem,
 0311            batchQuery,
 0312            cancellationToken).ConfigureAwait(false);
 313
 0314        var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, Recom
 0315        var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, Recommendat
 316
 317        // Use a single enumerator per list, listed twice so MoveNext advances it
 318        // twice per round-robin pass (giving these categories double weight).
 319        // IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once;
 320        // using var would box separately per list insertion, creating independent copies.
 0321        IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator();
 0322        IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator();
 323
 0324        var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>>
 0325        {
 0326            similarToRecentlyPlayedEnum,
 0327            similarToRecentlyPlayedEnum,
 0328            similarToLikedEnum,
 0329            similarToLikedEnum,
 0330            hasDirectorFromRecentlyPlayed.GetEnumerator(),
 0331            hasActorFromRecentlyPlayed.GetEnumerator()
 0332        };
 333
 0334        var categories = new List<SimilarItemsRecommendation>();
 0335        while (categories.Count < categoryLimit)
 336        {
 0337            var allEmpty = true;
 0338            foreach (var category in categoryTypes)
 339            {
 0340                if (category.MoveNext())
 341                {
 0342                    categories.Add(category.Current);
 0343                    allEmpty = false;
 344
 0345                    if (categories.Count >= categoryLimit)
 346                    {
 347                        break;
 348                    }
 349                }
 350            }
 351
 0352            if (allEmpty)
 353            {
 354                break;
 355            }
 356        }
 357
 0358        return [.. categories.OrderBy(i => i.RecommendationType)];
 0359    }
 360
 361    private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync(
 362        IReadOnlyList<BaseItem> baselineItems,
 363        RecommendationType recommendationType,
 364        SimilarItemsQuery query,
 365        CancellationToken cancellationToken)
 366    {
 0367        var batchProvider = _similarItemsProviders
 0368            .OfType<IBatchLocalSimilarItemsProvider>()
 0369            .FirstOrDefault();
 370
 0371        if (batchProvider is null || baselineItems.Count == 0)
 372        {
 0373            return [];
 374        }
 375
 0376        var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).Config
 377
 0378        var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
 0379        foreach (var baseline in baselineItems)
 380        {
 0381            if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
 382            {
 0383                recommendations.Add(new SimilarItemsRecommendation
 0384                {
 0385                    BaselineItemName = baseline.Name,
 0386                    CategoryId = baseline.Id,
 0387                    RecommendationType = recommendationType,
 0388                    Items = similar
 0389                });
 390            }
 391        }
 392
 0393        return recommendations;
 0394    }
 395
 396    private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations(
 397        User? user,
 398        IReadOnlyList<string> names,
 399        int itemLimit,
 400        DtoOptions dtoOptions,
 401        RecommendationType type,
 402        IReadOnlyList<BaseItemKind> itemTypes)
 403    {
 0404        var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed
 0405            ? [PersonType.Director]
 0406            : Array.Empty<string>();
 407
 0408        foreach (var name in names)
 409        {
 0410            var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
 0411            {
 0412                Person = name,
 0413                Limit = itemLimit + 2,
 0414                PersonTypes = personTypes,
 0415                IncludeItemTypes = itemTypes.ToArray(),
 0416                IsMovie = true,
 0417                IsPlayed = false,
 0418                EnableGroupByMetadataKey = true,
 0419                DtoOptions = dtoOptions
 0420            })
 0421                .DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.Inva
 0422                .Take(itemLimit)
 0423                .ToList();
 424
 0425            if (items.Count > 0)
 426            {
 0427                yield return new SimilarItemsRecommendation
 0428                {
 0429                    BaselineItemName = name,
 0430                    CategoryId = name.GetMD5(),
 0431                    RecommendationType = type,
 0432                    Items = items
 0433                };
 434            }
 435        }
 0436    }
 437
 438    private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes)
 439    {
 0440        var itemIds = items.Select(i => i.Id).ToArray();
 0441        return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes)
 0442            .Values
 0443            .SelectMany(names => names)
 0444            .Distinct()
 0445            .ToArray();
 446    }
 447
 448    private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
 449        IReadOnlyList<SimilarItemReference> references,
 450        int providerOrder,
 451        User? user,
 452        DtoOptions dtoOptions,
 453        BaseItemKind itemKind,
 454        HashSet<Guid> excludeIds,
 455        HashSet<string> excludeKeys)
 456    {
 0457        if (references.Count == 0)
 458        {
 0459            return [];
 460        }
 461
 0462        var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase);
 0463        var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(Stri
 464
 0465        foreach (var (position, match) in references.Index())
 466        {
 0467            var lookupKey = (match.ProviderName, match.ProviderId);
 0468            if (!providerLookup.TryGetValue(lookupKey, out var existing))
 469            {
 0470                providerLookup[lookupKey] = (match.Score, position);
 471            }
 0472            else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position))
 473            {
 0474                providerLookup[lookupKey] = (match.Score, position);
 475            }
 476        }
 477
 0478        var allProviderIds = providerLookup
 0479            .GroupBy(kvp => kvp.Key.ProviderName)
 0480            .ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray());
 481
 0482        var query = new InternalItemsQuery(user)
 0483        {
 0484            HasAnyProviderIds = allProviderIds,
 0485            IncludeItemTypes = [itemKind],
 0486            DtoOptions = dtoOptions
 0487        };
 488
 0489        var items = _libraryManager.GetItemList(query);
 490
 0491        foreach (var item in items)
 492        {
 0493            if (excludeIds.Contains(item.Id))
 494            {
 495                continue;
 496            }
 497
 0498            var presentationKey = item.GetPresentationUniqueKey();
 0499            if (excludeKeys.Contains(presentationKey))
 500            {
 501                continue;
 502            }
 503
 0504            foreach (var providerName in allProviderIds.Keys)
 505            {
 0506                if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerN
 507                {
 0508                    var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
 0509                    if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score)
 510                    {
 0511                        resolvedByKey[presentationKey] = (item, score);
 512                    }
 513
 0514                    break;
 515                }
 516            }
 517        }
 518
 0519        foreach (var (key, entry) in resolvedByKey)
 520        {
 0521            excludeIds.Add(entry.Item.Id);
 0522            excludeKeys.Add(key);
 523        }
 524
 0525        return [.. resolvedByKey.Values];
 526    }
 527
 528    private static float CalculateScore(float? matchScore, int providerOrder, int position)
 529    {
 530        // Use provider-supplied score if available, otherwise derive from position
 0531        var baseScore = matchScore ?? (1.0f - (position * 0.02f));
 532
 533        // Apply small boost based on provider order (higher priority providers get small bonus)
 0534        var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f;
 535
 0536        return Math.Clamp(baseScore + priorityBoost, 0f, 1f);
 537    }
 538
 539    private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName)
 540    {
 0541        if (orderConfig is null || orderConfig.Length == 0)
 542        {
 0543            return int.MaxValue;
 544        }
 545
 0546        var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnor
 0547        return index >= 0 ? index : int.MaxValue;
 548    }
 549
 550    private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId)
 551    {
 0552        var dataPath = Path.Combine(
 0553            _appPaths.CachePath,
 0554            $"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}");
 0555        return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json");
 556    }
 557
 558    private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken ca
 559    {
 0560        var fileInfo = _fileSystem.GetFileSystemInfo(cachePath);
 0561        if (!fileInfo.Exists || fileInfo.Length == 0)
 562        {
 0563            return null;
 564        }
 565
 566        try
 567        {
 0568            var stream = File.OpenRead(cachePath);
 0569            await using (stream.ConfigureAwait(false))
 570            {
 0571                var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cance
 0572                if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt)
 573                {
 0574                    return cache.References;
 575                }
 576            }
 0577        }
 0578        catch (IOException ex)
 579        {
 0580            _logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath);
 0581        }
 0582        catch (JsonException ex)
 583        {
 0584            _logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath);
 0585        }
 586
 0587        return null;
 0588    }
 589
 590    private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cach
 591    {
 592        try
 593        {
 0594            var directory = Path.GetDirectoryName(cachePath);
 0595            if (!string.IsNullOrEmpty(directory))
 596            {
 0597                Directory.CreateDirectory(directory);
 598            }
 599
 0600            var cache = new SimilarItemsCache
 0601            {
 0602                References = references,
 0603                ExpiresAt = DateTime.UtcNow.Add(cacheDuration)
 0604            };
 605
 0606            var stream = File.Create(cachePath);
 0607            await using (stream.ConfigureAwait(false))
 608            {
 0609                await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwa
 610            }
 0611        }
 0612        catch (IOException ex)
 613        {
 0614            _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath);
 0615        }
 0616    }
 617
 618    private sealed class SimilarItemsCache
 619    {
 620        public List<SimilarItemReference>? References { get; set; }
 621
 622        public DateTime ExpiresAt { get; set; }
 623    }
 624
 625    private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)>
 626    {
 0627        public static readonly StringTupleComparer Instance = new();
 628
 629        public bool Equals((string Key, string Value) x, (string Key, string Value) y)
 0630            => string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) &&
 0631               string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase);
 632
 633        public int GetHashCode((string Key, string Value) obj)
 0634            => HashCode.Combine(
 0635                StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key),
 0636                StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value));
 637    }
 638}

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>)