< 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
3%
Covered lines: 5
Uncovered lines: 157
Coverable lines: 162
Total lines: 342
Line coverage: 3%
Branch coverage
0%
Covered branches: 0
Total branches: 60
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: 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: 342 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: 342

Coverage delta

Coverage delta 8 -8

Metrics

File(s)

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

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Threading;
 5using System.Threading.Tasks;
 6using Jellyfin.Data.Enums;
 7using Jellyfin.Database.Implementations;
 8using Jellyfin.Database.Implementations.Entities;
 9using Jellyfin.Extensions;
 10using MediaBrowser.Controller.Configuration;
 11using MediaBrowser.Controller.Dto;
 12using MediaBrowser.Controller.Entities;
 13using MediaBrowser.Controller.Entities.Movies;
 14using MediaBrowser.Controller.Library;
 15using MediaBrowser.Controller.Persistence;
 16using MediaBrowser.Model.Configuration;
 17using Microsoft.EntityFrameworkCore;
 18using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
 19
 20namespace Emby.Server.Implementations.Library.SimilarItems;
 21
 22/// <summary>
 23/// Provides similar items for movies and trailers using weighted scoring.
 24/// </summary>
 25public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, 
 26{
 27    private const int GenreWeight = 10;
 28    private const int TagWeight = 5;
 29    private const int StudioWeight = 5;
 30    private const int DirectorWeight = 50;
 31    private const int ActorWeight = 15;
 32
 33    // Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id
 34    // load, navigation includes) stay bounded regardless of caller input.
 35    private const int MaxBatchSourceItems = 64;
 36
 037    private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions =
 038    [
 039        (ItemValueType.Genre, GenreWeight),
 040        (ItemValueType.Tags, TagWeight),
 041        (ItemValueType.Studios, StudioWeight)
 042    ];
 43
 044    private static readonly Dictionary<string, int> _personTypeWeights = new(StringComparer.Ordinal)
 045    {
 046        [nameof(PersonKind.Director)] = DirectorWeight,
 047        [nameof(PersonKind.Actor)] = ActorWeight,
 048        [nameof(PersonKind.GuestStar)] = ActorWeight,
 049    };
 50
 051    private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys];
 52
 53    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 54    private readonly IItemQueryHelpers _queryHelpers;
 55    private readonly IServerConfigurationManager _serverConfigurationManager;
 56    private readonly ILibraryManager _libraryManager;
 57
 58    /// <summary>
 59    /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
 60    /// </summary>
 61    /// <param name="dbProvider">The database context factory.</param>
 62    /// <param name="queryHelpers">The shared query helpers.</param>
 63    /// <param name="serverConfigurationManager">The server configuration manager.</param>
 64    /// <param name="libraryManager">The library manager.</param>
 65    public MovieSimilarItemsProvider(
 66        IDbContextFactory<JellyfinDbContext> dbProvider,
 67        IItemQueryHelpers queryHelpers,
 68        IServerConfigurationManager serverConfigurationManager,
 69        ILibraryManager libraryManager)
 70    {
 2271        _dbProvider = dbProvider;
 2272        _queryHelpers = queryHelpers;
 2273        _serverConfigurationManager = serverConfigurationManager;
 2274        _libraryManager = libraryManager;
 2275    }
 76
 77    /// <inheritdoc/>
 078    public string Name => "Local Genre/Tag";
 79
 80    /// <inheritdoc/>
 081    public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
 82
 83    /// <inheritdoc/>
 84    public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, Cancellation
 85    {
 086        var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
 087        return results.TryGetValue(item.Id, out var items) ? items : [];
 088    }
 89
 90    /// <inheritdoc/>
 91    public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, Cancellati
 92    {
 093        var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
 094        return results.TryGetValue(item.Id, out var items) ? items : [];
 095    }
 96
 97    bool ILocalSimilarItemsProvider.Supports(Type itemType)
 098        => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType);
 99
 100    Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query
 0101        => item switch
 0102        {
 0103            Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken),
 0104            Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken),
 0105            _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
 0106        };
 107
 108    /// <inheritdoc/>
 109    public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
 110        IReadOnlyList<BaseItemDto> sourceItems,
 111        SimilarItemsQuery query,
 112        CancellationToken cancellationToken)
 113    {
 0114        var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
 0115        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
 116        {
 0117            includeItemTypes.Add(BaseItemKind.Trailer);
 0118            includeItemTypes.Add(BaseItemKind.LiveTvProgram);
 119        }
 120
 0121        var limit = query.Limit ?? 50;
 0122        var dtoOptions = query.DtoOptions ?? new DtoOptions();
 123
 0124        if (sourceItems.Count > MaxBatchSourceItems)
 125        {
 0126            sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList();
 127        }
 128
 0129        var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0130        await using (context.ConfigureAwait(false))
 131        {
 132            // Phase 1: Score all candidates per source item
 0133            var sourceIds = sourceItems.Select(i => i.Id).ToList();
 0134            var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(fa
 135
 0136            var allCandidateIds = new HashSet<Guid>();
 0137            foreach (var (_, scores) in perSourceScores)
 138            {
 0139                allCandidateIds.UnionWith(
 0140                    scores.OrderByDescending(kvp => kvp.Value)
 0141                        .Take(limit * 3)
 0142                        .Select(kvp => kvp.Key));
 143            }
 144
 0145            var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
 0146            if (allCandidateIds.Count == 0)
 147            {
 0148                return result;
 149            }
 150
 151            // Phase 2: One access filter for all candidates
 0152            var filter = new InternalItemsQuery(query.User)
 0153            {
 0154                IncludeItemTypes = [.. includeItemTypes],
 0155                ExcludeItemIds = [.. query.ExcludeItemIds],
 0156                DtoOptions = dtoOptions,
 0157                EnableGroupByMetadataKey = true,
 0158                EnableTotalRecordCount = false,
 0159                IsMovie = true,
 0160                IsPlayed = false
 0161            };
 162
 0163            if (query.User is not null)
 164            {
 0165                _libraryManager.ConfigureUserAccess(filter, query.User);
 166            }
 167
 0168            _queryHelpers.PrepareFilterQuery(filter);
 0169            var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
 0170            baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
 171
 0172            var allCandidateIdsList = allCandidateIds.ToList();
 0173            var accessibleItems = await baseQuery
 0174                .WhereOneOrMany(allCandidateIdsList, e => e.Id)
 0175                .Select(e => new { e.Id, e.PresentationUniqueKey })
 0176                .ToListAsync(cancellationToken).ConfigureAwait(false);
 177
 178            // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
 0179            var allOrderedIds = new HashSet<Guid>();
 0180            var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>();
 181
 0182            foreach (var item in sourceItems)
 183            {
 0184                if (!perSourceScores.TryGetValue(item.Id, out var scores))
 185                {
 186                    continue;
 187                }
 188
 0189                var orderedIds = accessibleItems
 0190                    .Where(x => scores.ContainsKey(x.Id))
 0191                    .OrderByDescending(x => scores.GetValueOrDefault(x.Id))
 0192                    .DistinctBy(x => x.PresentationUniqueKey)
 0193                    .Take(limit)
 0194                    .Select(x => x.Id)
 0195                    .ToList();
 196
 0197                if (orderedIds.Count > 0)
 198                {
 0199                    perSourceOrderedIds[item.Id] = orderedIds;
 0200                    allOrderedIds.UnionWith(orderedIds);
 201                }
 202            }
 203
 0204            if (allOrderedIds.Count == 0)
 205            {
 0206                return result;
 207            }
 208
 209            // Phase 4: One entity load for all results
 0210            var allOrderedIdsList = allOrderedIds.ToList();
 0211            var entities = await _queryHelpers.ApplyNavigations(
 0212                    context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id),
 0213                    filter)
 0214                .AsSplitQuery()
 0215                .ToListAsync(cancellationToken).ConfigureAwait(false);
 216
 0217            var entitiesById = entities
 0218                .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization))
 0219                .Where(dto => dto is not null)
 0220                .ToDictionary(i => i!.Id);
 221
 222            // Phase 5: Split by source, preserving score order
 0223            foreach (var (sourceId, orderedIds) in perSourceOrderedIds)
 224            {
 0225                var items = orderedIds
 0226                    .Where(entitiesById.ContainsKey)
 0227                    .Select(id => entitiesById[id]!)
 0228                    .ToList();
 229
 0230                if (items.Count > 0)
 231                {
 0232                    result[sourceId] = items;
 233                }
 234            }
 235
 0236            return result;
 237        }
 0238    }
 239
 240    private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, Jel
 241    {
 0242        var result = new Dictionary<Guid, Dictionary<Guid, int>>();
 0243        foreach (var id in sourceIds)
 244        {
 0245            result[id] = [];
 246        }
 247
 0248        foreach (var (valueType, weight) in _itemValueDimensions)
 249        {
 0250            var sourceRows = await context.ItemValuesMap.AsNoTracking()
 0251                .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType)
 0252                .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
 0253                .ToListAsync(cancellationToken).ConfigureAwait(false);
 254
 0255            var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashS
 0256            var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList();
 0257            if (allKeys.Count == 0)
 258            {
 259                continue;
 260            }
 261
 0262            var candidateRows = await context.ItemValuesMap.AsNoTracking()
 0263                .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
 0264                .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
 0265                .ToListAsync(cancellationToken).ConfigureAwait(false);
 266
 0267            var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId
 0268            ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result);
 0269        }
 270
 0271        var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking()
 0272            .Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType))
 0273            .Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType })
 0274            .ToListAsync(cancellationToken).ConfigureAwait(false);
 275
 0276        if (personSourceRows.Count > 0)
 277        {
 0278            var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
 0279                .Where(m => context.PeopleBaseItemMap
 0280                    .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
 0281                    .Select(s => s.PeopleId)
 0282                    .Contains(m.PeopleId))
 0283                .Select(m => new { m.ItemId, m.PeopleId })
 0284                .ToListAsync(cancellationToken).ConfigureAwait(false);
 285
 0286            var personToCandidates = personCandidateRows
 0287                .GroupBy(r => r.PeopleId)
 0288                .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
 289
 0290            foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!]))
 291            {
 0292                var sourceMap = weightGroup
 0293                    .GroupBy(r => r.ItemId)
 0294                    .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet());
 0295                ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result);
 296            }
 297        }
 298
 0299        foreach (var sourceId in sourceIds)
 300        {
 0301            var scoreMap = result[sourceId];
 0302            scoreMap.Remove(sourceId);
 0303            if (scoreMap.Count == 0)
 304            {
 0305                result.Remove(sourceId);
 306            }
 307        }
 308
 0309        return result;
 0310    }
 311
 312    private static void ApplyDimensionScores<TKey>(
 313        List<Guid> sourceIds,
 314        Dictionary<Guid, HashSet<TKey>> sourceMap,
 315        Dictionary<TKey, List<Guid>> keyToCandidates,
 316        int weight,
 317        Dictionary<Guid, Dictionary<Guid, int>> result)
 318        where TKey : notnull
 319    {
 0320        foreach (var sourceId in sourceIds)
 321        {
 0322            if (!sourceMap.TryGetValue(sourceId, out var sourceKeys))
 323            {
 324                continue;
 325            }
 326
 0327            var scoreMap = result[sourceId];
 0328            foreach (var key in sourceKeys)
 329            {
 0330                if (!keyToCandidates.TryGetValue(key, out var candidates))
 331                {
 332                    continue;
 333                }
 334
 0335                foreach (var candidateId in candidates)
 336                {
 0337                    scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight;
 338                }
 339            }
 340        }
 0341    }
 342}