< 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
2%
Covered lines: 4
Uncovered lines: 155
Coverable lines: 159
Total lines: 333
Line coverage: 2.5%
Branch coverage
0%
Covered branches: 0
Total branches: 58
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: 333 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: 333

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
 57    /// <summary>
 58    /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
 59    /// </summary>
 60    /// <param name="dbProvider">The database context factory.</param>
 61    /// <param name="queryHelpers">The shared query helpers.</param>
 62    /// <param name="serverConfigurationManager">The server configuration manager.</param>
 63    public MovieSimilarItemsProvider(
 64        IDbContextFactory<JellyfinDbContext> dbProvider,
 65        IItemQueryHelpers queryHelpers,
 66        IServerConfigurationManager serverConfigurationManager)
 67    {
 2168        _dbProvider = dbProvider;
 2169        _queryHelpers = queryHelpers;
 2170        _serverConfigurationManager = serverConfigurationManager;
 2171    }
 72
 73    /// <inheritdoc/>
 074    public string Name => "Local Genre/Tag";
 75
 76    /// <inheritdoc/>
 077    public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
 78
 79    /// <inheritdoc/>
 80    public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, Cancellation
 81    {
 082        var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
 083        return results.TryGetValue(item.Id, out var items) ? items : [];
 084    }
 85
 86    /// <inheritdoc/>
 87    public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, Cancellati
 88    {
 089        var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false);
 090        return results.TryGetValue(item.Id, out var items) ? items : [];
 091    }
 92
 93    bool ILocalSimilarItemsProvider.Supports(Type itemType)
 094        => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType);
 95
 96    Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query
 097        => item switch
 098        {
 099            Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken),
 0100            Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken),
 0101            _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item))
 0102        };
 103
 104    /// <inheritdoc/>
 105    public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync(
 106        IReadOnlyList<BaseItemDto> sourceItems,
 107        SimilarItemsQuery query,
 108        CancellationToken cancellationToken)
 109    {
 0110        var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
 0111        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
 112        {
 0113            includeItemTypes.Add(BaseItemKind.Trailer);
 0114            includeItemTypes.Add(BaseItemKind.LiveTvProgram);
 115        }
 116
 0117        var limit = query.Limit ?? 50;
 0118        var dtoOptions = query.DtoOptions ?? new DtoOptions();
 119
 0120        if (sourceItems.Count > MaxBatchSourceItems)
 121        {
 0122            sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList();
 123        }
 124
 0125        var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0126        await using (context.ConfigureAwait(false))
 127        {
 128            // Phase 1: Score all candidates per source item
 0129            var sourceIds = sourceItems.Select(i => i.Id).ToList();
 0130            var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(fa
 131
 0132            var allCandidateIds = new HashSet<Guid>();
 0133            foreach (var (_, scores) in perSourceScores)
 134            {
 0135                allCandidateIds.UnionWith(
 0136                    scores.OrderByDescending(kvp => kvp.Value)
 0137                        .Take(limit * 3)
 0138                        .Select(kvp => kvp.Key));
 139            }
 140
 0141            var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>();
 0142            if (allCandidateIds.Count == 0)
 143            {
 0144                return result;
 145            }
 146
 147            // Phase 2: One access filter for all candidates
 0148            var filter = new InternalItemsQuery(query.User)
 0149            {
 0150                IncludeItemTypes = [.. includeItemTypes],
 0151                ExcludeItemIds = [.. query.ExcludeItemIds],
 0152                DtoOptions = dtoOptions,
 0153                EnableGroupByMetadataKey = true,
 0154                EnableTotalRecordCount = false,
 0155                IsMovie = true,
 0156                IsPlayed = false
 0157            };
 158
 0159            _queryHelpers.PrepareFilterQuery(filter);
 0160            var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
 0161            baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
 162
 0163            var allCandidateIdsList = allCandidateIds.ToList();
 0164            var accessibleItems = await baseQuery
 0165                .WhereOneOrMany(allCandidateIdsList, e => e.Id)
 0166                .Select(e => new { e.Id, e.PresentationUniqueKey })
 0167                .ToListAsync(cancellationToken).ConfigureAwait(false);
 168
 169            // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
 0170            var allOrderedIds = new HashSet<Guid>();
 0171            var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>();
 172
 0173            foreach (var item in sourceItems)
 174            {
 0175                if (!perSourceScores.TryGetValue(item.Id, out var scores))
 176                {
 177                    continue;
 178                }
 179
 0180                var orderedIds = accessibleItems
 0181                    .Where(x => scores.ContainsKey(x.Id))
 0182                    .OrderByDescending(x => scores.GetValueOrDefault(x.Id))
 0183                    .DistinctBy(x => x.PresentationUniqueKey)
 0184                    .Take(limit)
 0185                    .Select(x => x.Id)
 0186                    .ToList();
 187
 0188                if (orderedIds.Count > 0)
 189                {
 0190                    perSourceOrderedIds[item.Id] = orderedIds;
 0191                    allOrderedIds.UnionWith(orderedIds);
 192                }
 193            }
 194
 0195            if (allOrderedIds.Count == 0)
 196            {
 0197                return result;
 198            }
 199
 200            // Phase 4: One entity load for all results
 0201            var allOrderedIdsList = allOrderedIds.ToList();
 0202            var entities = await _queryHelpers.ApplyNavigations(
 0203                    context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id),
 0204                    filter)
 0205                .AsSplitQuery()
 0206                .ToListAsync(cancellationToken).ConfigureAwait(false);
 207
 0208            var entitiesById = entities
 0209                .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization))
 0210                .Where(dto => dto is not null)
 0211                .ToDictionary(i => i!.Id);
 212
 213            // Phase 5: Split by source, preserving score order
 0214            foreach (var (sourceId, orderedIds) in perSourceOrderedIds)
 215            {
 0216                var items = orderedIds
 0217                    .Where(entitiesById.ContainsKey)
 0218                    .Select(id => entitiesById[id]!)
 0219                    .ToList();
 220
 0221                if (items.Count > 0)
 222                {
 0223                    result[sourceId] = items;
 224                }
 225            }
 226
 0227            return result;
 228        }
 0229    }
 230
 231    private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, Jel
 232    {
 0233        var result = new Dictionary<Guid, Dictionary<Guid, int>>();
 0234        foreach (var id in sourceIds)
 235        {
 0236            result[id] = [];
 237        }
 238
 0239        foreach (var (valueType, weight) in _itemValueDimensions)
 240        {
 0241            var sourceRows = await context.ItemValuesMap.AsNoTracking()
 0242                .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType)
 0243                .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
 0244                .ToListAsync(cancellationToken).ConfigureAwait(false);
 245
 0246            var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashS
 0247            var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList();
 0248            if (allKeys.Count == 0)
 249            {
 250                continue;
 251            }
 252
 0253            var candidateRows = await context.ItemValuesMap.AsNoTracking()
 0254                .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
 0255                .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
 0256                .ToListAsync(cancellationToken).ConfigureAwait(false);
 257
 0258            var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId
 0259            ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result);
 0260        }
 261
 0262        var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking()
 0263            .Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType))
 0264            .Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType })
 0265            .ToListAsync(cancellationToken).ConfigureAwait(false);
 266
 0267        if (personSourceRows.Count > 0)
 268        {
 0269            var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
 0270                .Where(m => context.PeopleBaseItemMap
 0271                    .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
 0272                    .Select(s => s.PeopleId)
 0273                    .Contains(m.PeopleId))
 0274                .Select(m => new { m.ItemId, m.PeopleId })
 0275                .ToListAsync(cancellationToken).ConfigureAwait(false);
 276
 0277            var personToCandidates = personCandidateRows
 0278                .GroupBy(r => r.PeopleId)
 0279                .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList());
 280
 0281            foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!]))
 282            {
 0283                var sourceMap = weightGroup
 0284                    .GroupBy(r => r.ItemId)
 0285                    .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet());
 0286                ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result);
 287            }
 288        }
 289
 0290        foreach (var sourceId in sourceIds)
 291        {
 0292            var scoreMap = result[sourceId];
 0293            scoreMap.Remove(sourceId);
 0294            if (scoreMap.Count == 0)
 295            {
 0296                result.Remove(sourceId);
 297            }
 298        }
 299
 0300        return result;
 0301    }
 302
 303    private static void ApplyDimensionScores<TKey>(
 304        List<Guid> sourceIds,
 305        Dictionary<Guid, HashSet<TKey>> sourceMap,
 306        Dictionary<TKey, List<Guid>> keyToCandidates,
 307        int weight,
 308        Dictionary<Guid, Dictionary<Guid, int>> result)
 309        where TKey : notnull
 310    {
 0311        foreach (var sourceId in sourceIds)
 312        {
 0313            if (!sourceMap.TryGetValue(sourceId, out var sourceKeys))
 314            {
 315                continue;
 316            }
 317
 0318            var scoreMap = result[sourceId];
 0319            foreach (var key in sourceKeys)
 320            {
 0321                if (!keyToCandidates.TryGetValue(key, out var candidates))
 322                {
 323                    continue;
 324                }
 325
 0326                foreach (var candidateId in candidates)
 327                {
 0328                    scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight;
 329                }
 330            }
 331        }
 0332    }
 333}