< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.Library.SimilarItems.MovieSimilarItemsProvider
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
Line coverage
81%
Covered lines: 138
Uncovered lines: 32
Coverable lines: 170
Total lines: 353
Line coverage: 81.1%
Branch coverage
71%
Covered branches: 43
Total branches: 60
Branch coverage: 71.6%
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: 9.6% (3/31) Branch coverage: 0% (0/10) Total lines: 915/28/2026 - 12:15:50 AM Line coverage: 2.5% (4/159) Branch coverage: 0% (0/58) Total lines: 3337/18/2026 - 12:15:19 AM Line coverage: 3% (5/162) Branch coverage: 0% (0/60) Total lines: 34210/29/2026 - 12:17:11 AM Line coverage: 81.1% (138/170) Branch coverage: 71.6% (43/60) Total lines: 353 5/16/2026 - 12:15:55 AM Line coverage: 9.6% (3/31) Branch coverage: 0% (0/10) Total lines: 915/28/2026 - 12:15:50 AM Line coverage: 2.5% (4/159) Branch coverage: 0% (0/58) Total lines: 3337/18/2026 - 12:15:19 AM Line coverage: 3% (5/162) Branch coverage: 0% (0/60) Total lines: 34210/29/2026 - 12:17:11 AM Line coverage: 81.1% (138/170) Branch coverage: 71.6% (43/60) Total lines: 353

Coverage delta

Coverage delta 79 -79

Metrics

File(s)

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

#LineLine coverage
 1#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees.
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Linq;
 6using System.Threading;
 7using System.Threading.Tasks;
 8using Jellyfin.Data.Enums;
 9using Jellyfin.Database.Implementations;
 10using Jellyfin.Database.Implementations.Entities;
 11using Jellyfin.Extensions;
 12using MediaBrowser.Controller.Configuration;
 13using MediaBrowser.Controller.Dto;
 14using MediaBrowser.Controller.Entities;
 15using MediaBrowser.Controller.Entities.Movies;
 16using MediaBrowser.Controller.Library;
 17using MediaBrowser.Controller.Persistence;
 18using MediaBrowser.Model.Configuration;
 19using Microsoft.EntityFrameworkCore;
 20using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
 21
 22namespace Emby.Server.Implementations.Library.SimilarItems;
 23
 24/// <summary>
 25/// Provides similar items for movies and trailers using weighted scoring.
 26/// </summary>
 27public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, 
 28{
 29    private const int GenreWeight = 10;
 30    private const int TagWeight = 5;
 31    private const int StudioWeight = 5;
 32    private const int DirectorWeight = 50;
 33    private const int ActorWeight = 15;
 34
 35    // Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id
 36    // load, navigation includes) stay bounded regardless of caller input.
 37    private const int MaxBatchSourceItems = 64;
 38
 139    private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions =
 140    [
 141        (ItemValueType.Genre, GenreWeight),
 142        (ItemValueType.Tags, TagWeight),
 143        (ItemValueType.Studios, StudioWeight)
 144    ];
 45
 146    private static readonly Dictionary<string, int> _personTypeWeights = new(StringComparer.Ordinal)
 147    {
 148        [nameof(PersonKind.Director)] = DirectorWeight,
 149        [nameof(PersonKind.Actor)] = ActorWeight,
 150        [nameof(PersonKind.GuestStar)] = ActorWeight,
 151    };
 52
 153    private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys];
 54
 55    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 56    private readonly IItemQueryHelpers _queryHelpers;
 57    private readonly IServerConfigurationManager _serverConfigurationManager;
 58    private readonly ILibraryManager _libraryManager;
 59
 60    /// <summary>
 61    /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
 62    /// </summary>
 63    /// <param name="dbProvider">The database context factory.</param>
 64    /// <param name="queryHelpers">The shared query helpers.</param>
 65    /// <param name="serverConfigurationManager">The server configuration manager.</param>
 66    /// <param name="libraryManager">The library manager.</param>
 67    public MovieSimilarItemsProvider(
 68        IDbContextFactory<JellyfinDbContext> dbProvider,
 69        IItemQueryHelpers queryHelpers,
 70        IServerConfigurationManager serverConfigurationManager,
 71        ILibraryManager libraryManager)
 72    {
 3373        _dbProvider = dbProvider;
 3374        _queryHelpers = queryHelpers;
 3375        _serverConfigurationManager = serverConfigurationManager;
 3376        _libraryManager = libraryManager;
 3377    }
 78
 79    /// <inheritdoc/>
 080    public string Name => "Local Genre/Tag";
 81
 82    /// <inheritdoc/>
 083    public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
 84
 85    /// <inheritdoc/>
 86    public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, Cancellation
 87    {
 688        var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
 689        return results.TryGetValue(item.Id, out var items) ? items : [];
 690    }
 91
 92    /// <inheritdoc/>
 93    public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, Cancellati
 94    {
 095        var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
 096        return results.TryGetValue(item.Id, out var items) ? items : [];
 097    }
 98
 99    bool ILocalSimilarItemsProvider.Supports(Type itemType)
 0100        => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType);
 101
 102    Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query
 0103        => item switch
 0104        {
 0105            Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken),
 0106            Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken),
 0107            _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
 0108        };
 109
 110    /// <inheritdoc/>
 111    public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
 112        IReadOnlyList<BaseItemDto> sourceItems,
 113        SimilarItemsQuery query,
 114        CancellationToken cancellationToken)
 115    {
 6116        var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
 6117        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
 118        {
 6119            includeItemTypes.Add(BaseItemKind.Trailer);
 6120            includeItemTypes.Add(BaseItemKind.LiveTvProgram);
 121        }
 122
 6123        var limit = query.Limit ?? 50;
 6124        var dtoOptions = query.DtoOptions ?? new DtoOptions();
 125
 6126        if (sourceItems.Count > MaxBatchSourceItems)
 127        {
 0128            sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList();
 129        }
 130
 6131        var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 6132        await using (context.ConfigureAwait(false))
 133        {
 134            // Phase 1: Score all candidates per source item
 6135            var sourceIds = sourceItems.Select(i => i.Id).ToList();
 6136            var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(fa
 137
 6138            var allCandidateIds = new HashSet<Guid>();
 24139            foreach (var (_, scores) in perSourceScores)
 140            {
 6141                allCandidateIds.UnionWith(
 6142                    scores.OrderByDescending(kvp => kvp.Value)
 6143                        .Take(limit * 3)
 6144                        .Select(kvp => kvp.Key));
 145            }
 146
 6147            var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
 6148            if (allCandidateIds.Count == 0)
 149            {
 0150                return result;
 151            }
 152
 153            // Phase 2: One access filter for all candidates
 6154            var filter = new InternalItemsQuery(query.User)
 6155            {
 6156                IncludeItemTypes = [.. includeItemTypes],
 6157                ExcludeItemIds = [.. query.ExcludeItemIds],
 6158                DtoOptions = dtoOptions,
 6159                EnableGroupByMetadataKey = true,
 6160                EnableTotalRecordCount = false,
 6161                IsMovie = true,
 6162                IsPlayed = false
 6163            };
 164
 6165            if (query.User is not null)
 166            {
 6167                _libraryManager.ConfigureUserAccess(filter, query.User);
 168            }
 169
 6170            _queryHelpers.PrepareFilterQuery(filter);
 6171            var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
 6172            baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
 173
 6174            var allCandidateIdsList = allCandidateIds.ToList();
 6175            var accessibleItems = await baseQuery
 6176                .WhereOneOrMany(allCandidateIdsList, e => e.Id)
 6177                .Select(e => new { e.Id, e.PresentationUniqueKey, e.PrimaryVersionId })
 6178                .ToListAsync(cancellationToken).ConfigureAwait(false);
 179
 180            // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
 6181            var allOrderedIds = new HashSet<Guid>();
 6182            var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>();
 183
 24184            foreach (var item in sourceItems)
 185            {
 6186                if (!perSourceScores.TryGetValue(item.Id, out var scores))
 187                {
 188                    continue;
 189                }
 190
 6191                var orderedIds = accessibleItems
 6192                    .Where(x => scores.ContainsKey(x.Id))
 6193                    .OrderByDescending(x => scores.GetValueOrDefault(x.Id))
 6194                    // Two versions of one movie score the same, so name the primary as the
 6195                    // representative of the group rather than whichever came back first.
 6196                    .ThenBy(x => x.PrimaryVersionId.HasValue)
 6197                    .DistinctBy(x => x.PresentationUniqueKey)
 6198                    .Take(limit)
 6199                    .Select(x => x.Id)
 6200                    .ToList();
 201
 6202                if (orderedIds.Count > 0)
 203                {
 6204                    perSourceOrderedIds[item.Id] = orderedIds;
 6205                    allOrderedIds.UnionWith(orderedIds);
 206                }
 207            }
 208
 6209            if (allOrderedIds.Count == 0)
 210            {
 0211                return result;
 212            }
 213
 214            // Phase 4: One entity load for all results
 6215            var allOrderedIdsList = allOrderedIds.ToList();
 6216            var entities = await _queryHelpers.ApplyNavigations(
 6217                    context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id),
 6218                    filter)
 6219                .AsSplitQuery()
 6220                .ToListAsync(cancellationToken).ConfigureAwait(false);
 221
 6222            var entitiesById = entities
 6223                .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization))
 6224                .Where(dto => dto is not null)
 6225                .ToDictionary(i => i!.Id);
 226
 227            // Phase 5: Split by source, preserving score order
 24228            foreach (var (sourceId, orderedIds) in perSourceOrderedIds)
 229            {
 6230                var items = orderedIds
 6231                    .Where(entitiesById.ContainsKey)
 6232                    .Select(id => entitiesById[id]!)
 6233                    .ToList();
 234
 6235                if (items.Count > 0)
 236                {
 6237                    result[sourceId] = items;
 238                }
 239            }
 240
 6241            return result;
 242        }
 6243    }
 244
 245    private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, Jel
 246    {
 6247        var result = new Dictionary<Guid, Dictionary<Guid, int>>();
 24248        foreach (var id in sourceIds)
 249        {
 6250            result[id] = [];
 251        }
 252
 6253        var hiddenVersionIds = context.BaseItems.AsNoTracking()
 6254            .Where(e => e.PrimaryVersionId != null
 6255                && context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId))
 6256            .Select(e => e.Id);
 257
 48258        foreach (var (valueType, weight) in _itemValueDimensions)
 259        {
 18260            var sourceRows = await context.ItemValuesMap.AsNoTracking()
 18261                .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType)
 18262                .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
 18263                .ToListAsync(cancellationToken).ConfigureAwait(false);
 264
 18265            var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashS
 18266            var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList();
 18267            if (allKeys.Count == 0)
 268            {
 269                continue;
 270            }
 271
 6272            var candidateRows = await context.ItemValuesMap.AsNoTracking()
 6273                .Where(m => !hiddenVersionIds.Contains(m.ItemId) && m.ItemValue.Type == valueType && allKeys.Contains(m.
 6274                .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
 6275                .ToListAsync(cancellationToken).ConfigureAwait(false);
 276
 6277            var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId
 6278            ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result);
 6279        }
 280
 6281        var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking()
 6282            .Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType))
 6283            .Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType })
 6284            .ToListAsync(cancellationToken).ConfigureAwait(false);
 285
 6286        if (personSourceRows.Count > 0)
 287        {
 0288            var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
 0289                .Where(m => !hiddenVersionIds.Contains(m.ItemId))
 0290                .Where(m => context.PeopleBaseItemMap
 0291                    .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
 0292                    .Select(s => s.PeopleId)
 0293                    .Contains(m.PeopleId))
 0294                .Select(m => new { m.ItemId, m.PeopleId })
 0295                .ToListAsync(cancellationToken).ConfigureAwait(false);
 296
 0297            var personToCandidates = personCandidateRows
 0298                .GroupBy(r => r.PeopleId)
 0299                .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
 300
 0301            foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!]))
 302            {
 0303                var sourceMap = weightGroup
 0304                    .GroupBy(r => r.ItemId)
 0305                    .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet());
 0306                ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result);
 307            }
 308        }
 309
 24310        foreach (var sourceId in sourceIds)
 311        {
 6312            var scoreMap = result[sourceId];
 6313            scoreMap.Remove(sourceId);
 6314            if (scoreMap.Count == 0)
 315            {
 0316                result.Remove(sourceId);
 317            }
 318        }
 319
 6320        return result;
 6321    }
 322
 323    private static void ApplyDimensionScores<TKey>(
 324        List<Guid> sourceIds,
 325        Dictionary<Guid, HashSet<TKey>> sourceMap,
 326        Dictionary<TKey, List<Guid>> keyToCandidates,
 327        int weight,
 328        Dictionary<Guid, Dictionary<Guid, int>> result)
 329        where TKey : notnull
 330    {
 24331        foreach (var sourceId in sourceIds)
 332        {
 6333            if (!sourceMap.TryGetValue(sourceId, out var sourceKeys))
 334            {
 335                continue;
 336            }
 337
 6338            var scoreMap = result[sourceId];
 24339            foreach (var key in sourceKeys)
 340            {
 6341                if (!keyToCandidates.TryGetValue(key, out var candidates))
 342                {
 343                    continue;
 344                }
 345
 48346                foreach (var candidateId in candidates)
 347                {
 18348                    scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight;
 349                }
 350            }
 351        }
 6352    }
 353}