< 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
4%
Covered lines: 8
Uncovered lines: 174
Coverable lines: 182
Total lines: 406
Line coverage: 4.3%
Branch coverage
0%
Covered branches: 0
Total branches: 96
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: 406 5/16/2026 - 12:15:55 AM Line coverage: 4.3% (8/182) Branch coverage: 0% (0/96) Total lines: 406

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
AddParts(...)100%11100%
GetSimilarItemsProviders()100%210%
GetSimilarItemsAsync()0%2162460%
ResolveRemoteReferences(...)0%812280%
CalculateScore(...)0%620%
GetConfiguredSimilarProviderOrder(...)0%4260%
GetSimilarItemsCachePath(...)100%210%
TryReadSimilarItemsCacheAsync()0%110100%
SaveSimilarItemsCacheAsync()0%620%
.cctor()100%210%
Equals(...)0%620%
GetHashCode(...)100%210%

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.Extensions.Json;
 12using MediaBrowser.Controller;
 13using MediaBrowser.Controller.Dto;
 14using MediaBrowser.Controller.Entities;
 15using MediaBrowser.Controller.Library;
 16using MediaBrowser.Model.Configuration;
 17using MediaBrowser.Model.Entities;
 18using MediaBrowser.Model.IO;
 19using MediaBrowser.Model.Querying;
 20using Microsoft.Extensions.Logging;
 21
 22namespace Emby.Server.Implementations.Library.SimilarItems;
 23
 24/// <summary>
 25/// Manages similar items providers and orchestrates similar items operations.
 26/// </summary>
 27public class SimilarItemsManager : ISimilarItemsManager
 28{
 29    private readonly ILogger<SimilarItemsManager> _logger;
 30    private readonly IServerApplicationPaths _appPaths;
 31    private readonly ILibraryManager _libraryManager;
 32    private readonly IFileSystem _fileSystem;
 2133    private ISimilarItemsProvider[] _similarItemsProviders = [];
 34
 35    /// <summary>
 36    /// Initializes a new instance of the <see cref="SimilarItemsManager"/> class.
 37    /// </summary>
 38    /// <param name="logger">The logger.</param>
 39    /// <param name="appPaths">The server application paths.</param>
 40    /// <param name="libraryManager">The library manager.</param>
 41    /// <param name="fileSystem">The file system.</param>
 42    public SimilarItemsManager(
 43        ILogger<SimilarItemsManager> logger,
 44        IServerApplicationPaths appPaths,
 45        ILibraryManager libraryManager,
 46        IFileSystem fileSystem)
 47    {
 2148        _logger = logger;
 2149        _appPaths = appPaths;
 2150        _libraryManager = libraryManager;
 2151        _fileSystem = fileSystem;
 2152    }
 53
 54    /// <inheritdoc/>
 55    public void AddParts(IEnumerable<ISimilarItemsProvider> providers)
 56    {
 2157        _similarItemsProviders = providers.ToArray();
 2158    }
 59
 60    /// <inheritdoc/>
 61    public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>()
 62        where T : BaseItem
 63    {
 064        var itemType = typeof(T);
 065        return _similarItemsProviders
 066            .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType))
 067                || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType)))
 068            .ToList();
 69    }
 70
 71    /// <inheritdoc/>
 72    public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(
 73        BaseItem item,
 74        IReadOnlyList<Guid> excludeArtistIds,
 75        User? user,
 76        DtoOptions dtoOptions,
 77        int? limit,
 78        LibraryOptions? libraryOptions,
 79        CancellationToken cancellationToken)
 80    {
 081        ArgumentNullException.ThrowIfNull(item);
 082        ArgumentNullException.ThrowIfNull(excludeArtistIds);
 83
 084        var itemType = item.GetType();
 085        var requestedLimit = limit ?? 50;
 086        var itemKind = item.GetBaseItemKind();
 87
 88        // Ensure ProviderIds is included in DtoOptions for matching remote provider responses
 089        if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds))
 90        {
 091            dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray();
 92        }
 93
 94        // Local providers are always enabled. Remote providers must be explicitly enabled.
 095        var localProviders = _similarItemsProviders
 096            .OfType<ILocalSimilarItemsProvider>()
 097            .Where(p => p.Supports(itemType))
 098            .ToList();
 099        var remoteProviders = _similarItemsProviders
 0100            .OfType<IRemoteSimilarItemsProvider>()
 0101            .Where(p => p.Supports(itemType));
 0102        var matchingProviders = new List<ISimilarItemsProvider>(localProviders);
 103
 0104        var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name);
 0105        if (typeOptions?.SimilarItemProviders?.Length > 0)
 106        {
 0107            matchingProviders.AddRange(remoteProviders
 0108                .Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase)));
 109        }
 110
 0111        var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order
 0112            ? order
 0113            : typeOptions?.SimilarItemProviders;
 0114        var orderedProviders = matchingProviders
 0115            .OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name))
 0116            .ToList();
 117
 0118        var allResults = new List<(BaseItem Item, float Score)>();
 0119        var excludeIds = new HashSet<Guid> { item.Id };
 0120        foreach (var (providerOrder, provider) in orderedProviders.Index())
 121        {
 0122            if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
 123            {
 124                break;
 125            }
 126
 127            try
 128            {
 0129                if (provider is ILocalSimilarItemsProvider localProvider)
 130                {
 0131                    var query = new SimilarItemsQuery
 0132                    {
 0133                        User = user,
 0134                        Limit = requestedLimit - allResults.Count,
 0135                        DtoOptions = dtoOptions,
 0136                        ExcludeItemIds = [.. excludeIds],
 0137                        ExcludeArtistIds = excludeArtistIds
 0138                    };
 139
 0140                    var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(
 141
 0142                    foreach (var (position, resultItem) in items.Index())
 143                    {
 0144                        if (excludeIds.Add(resultItem.Id))
 145                        {
 0146                            var score = CalculateScore(null, providerOrder, position);
 0147                            allResults.Add((resultItem, score));
 148                        }
 149                    }
 150                }
 0151                else if (provider is IRemoteSimilarItemsProvider remoteProvider)
 152                {
 0153                    var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id);
 154
 0155                    var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAw
 0156                    if (cachedReferences is not null)
 157                    {
 0158                        var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, i
 0159                        allResults.AddRange(resolvedItems);
 0160                        continue;
 161                    }
 162
 0163                    var query = new SimilarItemsQuery
 0164                    {
 0165                        User = user,
 0166                        Limit = requestedLimit - allResults.Count,
 0167                        DtoOptions = dtoOptions,
 0168                        ExcludeItemIds = [.. excludeIds],
 0169                        ExcludeArtistIds = excludeArtistIds
 0170                    };
 171
 172                    // Collect references in batches and resolve against local library.
 173                    // Stop fetching once we have enough resolved local items.
 174                    const int BatchSize = 20;
 0175                    var remaining = requestedLimit - allResults.Count;
 0176                    var collectedReferences = new List<SimilarItemReference>();
 0177                    var pendingBatch = new List<SimilarItemReference>();
 178
 0179                    await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).
 180                    {
 0181                        collectedReferences.Add(reference);
 0182                        pendingBatch.Add(reference);
 183
 0184                        if (pendingBatch.Count >= BatchSize)
 185                        {
 0186                            var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, i
 0187                            allResults.AddRange(resolvedItems);
 0188                            remaining -= resolvedItems.Count;
 0189                            pendingBatch.Clear();
 190
 0191                            if (remaining <= 0)
 192                            {
 193                                break;
 194                            }
 195                        }
 196                    }
 197
 198                    // Resolve any remaining references in the last partial batch
 0199                    if (pendingBatch.Count > 0)
 200                    {
 0201                        var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemK
 0202                        allResults.AddRange(resolvedItems);
 203                    }
 204
 0205                    if (collectedReferences.Count > 0 && provider.CacheDuration is not null)
 206                    {
 0207                        await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, c
 208                    }
 0209                }
 0210            }
 0211            catch (OperationCanceledException)
 212            {
 0213                break;
 214            }
 0215            catch (Exception ex)
 216            {
 0217                _logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, 
 0218            }
 0219        }
 220
 0221        return allResults
 0222            .OrderByDescending(x => x.Score)
 0223            .Select(x => x.Item)
 0224            .Take(requestedLimit)
 0225            .ToList();
 0226    }
 227
 228    private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
 229        IReadOnlyList<SimilarItemReference> references,
 230        int providerOrder,
 231        User? user,
 232        DtoOptions dtoOptions,
 233        BaseItemKind itemKind,
 234        HashSet<Guid> excludeIds)
 235    {
 0236        if (references.Count == 0)
 237        {
 0238            return [];
 239        }
 240
 0241        var resolvedById = new Dictionary<Guid, (BaseItem Item, float Score)>();
 0242        var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(Stri
 243
 0244        foreach (var (position, match) in references.Index())
 245        {
 0246            var lookupKey = (match.ProviderName, match.ProviderId);
 0247            if (!providerLookup.TryGetValue(lookupKey, out var existing))
 248            {
 0249                providerLookup[lookupKey] = (match.Score, position);
 250            }
 0251            else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position))
 252            {
 0253                providerLookup[lookupKey] = (match.Score, position);
 254            }
 255        }
 256
 0257        var allProviderIds = providerLookup
 0258            .GroupBy(kvp => kvp.Key.ProviderName)
 0259            .ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray());
 260
 0261        var query = new InternalItemsQuery(user)
 0262        {
 0263            HasAnyProviderIds = allProviderIds,
 0264            IncludeItemTypes = [itemKind],
 0265            DtoOptions = dtoOptions
 0266        };
 267
 0268        var items = _libraryManager.GetItemList(query);
 269
 0270        foreach (var item in items)
 271        {
 0272            if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id))
 273            {
 274                continue;
 275            }
 276
 0277            foreach (var providerName in allProviderIds.Keys)
 278            {
 0279                if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerN
 280                {
 0281                    var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
 0282                    if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score)
 283                    {
 0284                        excludeIds.Add(item.Id);
 0285                        resolvedById[item.Id] = (item, score);
 286                    }
 287
 0288                    break;
 289                }
 290            }
 291        }
 292
 0293        return [.. resolvedById.Values];
 294    }
 295
 296    private static float CalculateScore(float? matchScore, int providerOrder, int position)
 297    {
 298        // Use provider-supplied score if available, otherwise derive from position
 0299        var baseScore = matchScore ?? (1.0f - (position * 0.02f));
 300
 301        // Apply small boost based on provider order (higher priority providers get small bonus)
 0302        var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f;
 303
 0304        return Math.Clamp(baseScore + priorityBoost, 0f, 1f);
 305    }
 306
 307    private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName)
 308    {
 0309        if (orderConfig is null || orderConfig.Length == 0)
 310        {
 0311            return int.MaxValue;
 312        }
 313
 0314        var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnor
 0315        return index >= 0 ? index : int.MaxValue;
 316    }
 317
 318    private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId)
 319    {
 0320        var dataPath = Path.Combine(
 0321            _appPaths.CachePath,
 0322            $"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}");
 0323        return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json");
 324    }
 325
 326    private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken ca
 327    {
 0328        var fileInfo = _fileSystem.GetFileSystemInfo(cachePath);
 0329        if (!fileInfo.Exists || fileInfo.Length == 0)
 330        {
 0331            return null;
 332        }
 333
 334        try
 335        {
 0336            var stream = File.OpenRead(cachePath);
 0337            await using (stream.ConfigureAwait(false))
 338            {
 0339                var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cance
 0340                if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt)
 341                {
 0342                    return cache.References;
 343                }
 344            }
 0345        }
 0346        catch (IOException ex)
 347        {
 0348            _logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath);
 0349        }
 0350        catch (JsonException ex)
 351        {
 0352            _logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath);
 0353        }
 354
 0355        return null;
 0356    }
 357
 358    private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cach
 359    {
 360        try
 361        {
 0362            var directory = Path.GetDirectoryName(cachePath);
 0363            if (!string.IsNullOrEmpty(directory))
 364            {
 0365                Directory.CreateDirectory(directory);
 366            }
 367
 0368            var cache = new SimilarItemsCache
 0369            {
 0370                References = references,
 0371                ExpiresAt = DateTime.UtcNow.Add(cacheDuration)
 0372            };
 373
 0374            var stream = File.Create(cachePath);
 0375            await using (stream.ConfigureAwait(false))
 376            {
 0377                await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwa
 378            }
 0379        }
 0380        catch (IOException ex)
 381        {
 0382            _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath);
 0383        }
 0384    }
 385
 386    private sealed class SimilarItemsCache
 387    {
 388        public List<SimilarItemReference>? References { get; set; }
 389
 390        public DateTime ExpiresAt { get; set; }
 391    }
 392
 393    private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)>
 394    {
 0395        public static readonly StringTupleComparer Instance = new();
 396
 397        public bool Equals((string Key, string Value) x, (string Key, string Value) y)
 0398            => string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) &&
 0399               string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase);
 400
 401        public int GetHashCode((string Key, string Value) obj)
 0402            => HashCode.Combine(
 0403                StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key),
 0404                StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value));
 405    }
 406}