< Summary - Jellyfin

Information
Class: Jellyfin.Server.Implementations.Item.NextUpService
Assembly: Jellyfin.Server.Implementations
File(s): /srv/git/jellyfin/Jellyfin.Server.Implementations/Item/NextUpService.cs
Line coverage
50%
Covered lines: 105
Uncovered lines: 105
Coverable lines: 210
Total lines: 363
Line coverage: 50%
Branch coverage
53%
Covered branches: 41
Total branches: 76
Branch coverage: 53.9%
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: 1.9% (4/205) Branch coverage: 0% (0/80) Total lines: 3597/27/2026 - 12:16:14 AM Line coverage: 1.9% (4/204) Branch coverage: 0% (0/76) Total lines: 35410/29/2026 - 12:17:11 AM Line coverage: 50% (105/210) Branch coverage: 53.9% (41/76) Total lines: 363 5/16/2026 - 12:15:55 AM Line coverage: 1.9% (4/205) Branch coverage: 0% (0/80) Total lines: 3597/27/2026 - 12:16:14 AM Line coverage: 1.9% (4/204) Branch coverage: 0% (0/76) Total lines: 35410/29/2026 - 12:17:11 AM Line coverage: 50% (105/210) Branch coverage: 53.9% (41/76) Total lines: 363

Coverage delta

Coverage delta 54 -54

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetNextUpSeriesKeys(...)0%620%
GetNextUpEpisodesBatch(...)55.4%5877454.59%

File(s)

/srv/git/jellyfin/Jellyfin.Server.Implementations/Item/NextUpService.cs

#LineLine coverage
 1#pragma warning disable RS0030 // Do not use banned APIs
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Linq;
 6using Jellyfin.Data.Enums;
 7using Jellyfin.Database.Implementations;
 8using Jellyfin.Database.Implementations.Entities;
 9using MediaBrowser.Controller.Entities;
 10using MediaBrowser.Controller.Persistence;
 11using Microsoft.EntityFrameworkCore;
 12using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
 13
 14namespace Jellyfin.Server.Implementations.Item;
 15
 16/// <summary>
 17/// Provides next-up episode query operations.
 18/// </summary>
 19public class NextUpService : INextUpService
 20{
 21    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 22    private readonly IItemTypeLookup _itemTypeLookup;
 23    private readonly IItemQueryHelpers _queryHelpers;
 24
 25    /// <summary>
 26    /// Initializes a new instance of the <see cref="NextUpService"/> class.
 27    /// </summary>
 28    /// <param name="dbProvider">The database context factory.</param>
 29    /// <param name="itemTypeLookup">The item type lookup.</param>
 30    /// <param name="queryHelpers">The shared query helpers.</param>
 31    public NextUpService(
 32        IDbContextFactory<JellyfinDbContext> dbProvider,
 33        IItemTypeLookup itemTypeLookup,
 34        IItemQueryHelpers queryHelpers)
 35    {
 2836        _dbProvider = dbProvider;
 2837        _itemTypeLookup = itemTypeLookup;
 2838        _queryHelpers = queryHelpers;
 2839    }
 40
 41    /// <inheritdoc />
 42    public IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff)
 43    {
 044        ArgumentNullException.ThrowIfNull(filter);
 045        ArgumentNullException.ThrowIfNull(filter.User);
 46
 047        using var context = _dbProvider.CreateDbContext();
 48
 049        var query = context.BaseItems
 050            .AsNoTracking()
 051            .Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value))
 052            .Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode])
 053            .Join(
 054                context.UserData.AsNoTracking().Where(e => e.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)),
 055                i => new { UserId = filter.User.Id, ItemId = i.Id },
 056                u => new { u.UserId, u.ItemId },
 057                (entity, data) => new { Item = entity, UserData = data })
 058            .GroupBy(g => g.Item.SeriesPresentationUniqueKey)
 059            .Select(g => new { g.Key, LastPlayedDate = g.Max(u => u.UserData.LastPlayedDate) })
 060            .Where(g => g.Key != null && g.LastPlayedDate != null && g.LastPlayedDate >= dateCutoff)
 061            .OrderByDescending(g => g.LastPlayedDate)
 062            .Select(g => g.Key!);
 63
 064        if (filter.Limit.HasValue)
 65        {
 066            query = query.Take(filter.Limit.Value);
 67        }
 68
 069        return query.ToArray();
 070    }
 71
 72    /// <inheritdoc />
 73    public IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch(
 74        InternalItemsQuery filter,
 75        IReadOnlyList<string> seriesKeys,
 76        bool includeSpecials,
 77        bool includeWatchedForRewatching)
 78    {
 179        ArgumentNullException.ThrowIfNull(filter);
 180        ArgumentNullException.ThrowIfNull(filter.User);
 81
 182        if (seriesKeys.Count == 0)
 83        {
 084            return new Dictionary<string, NextUpEpisodeBatchResult>();
 85        }
 86
 187        _queryHelpers.PrepareFilterQuery(filter);
 188        using var context = _dbProvider.CreateDbContext();
 89
 190        var userId = filter.User.Id;
 191        var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
 92
 193        var lastWatchedBase = context.BaseItems
 194            .AsNoTracking()
 195            .Where(e => e.Type == episodeTypeName)
 196            .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 197            .Where(e => e.ParentIndexNumber != 0)
 198            .Where(DescendantQueryHelper.IsPlayedBy(userId));
 199        lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
 100
 101        // Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
 102        // per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault.
 1103        var allPlayedLite = lastWatchedBase
 1104            .Select(e => new
 1105            {
 1106                e.Id,
 1107                e.SeriesPresentationUniqueKey,
 1108                e.ParentIndexNumber,
 1109                e.IndexNumber
 1110            })
 1111            .ToList();
 112
 1113        var lastWatchedInfo = allPlayedLite
 1114            .OrderByDescending(e => e.ParentIndexNumber)
 1115            .ThenByDescending(e => e.IndexNumber)
 1116            .DistinctBy(e => e.SeriesPresentationUniqueKey)
 1117            .ToDictionary(e => e.SeriesPresentationUniqueKey!, e => e.Id);
 118
 1119        Dictionary<string, Guid> lastWatchedByDateInfo = new();
 1120        if (includeWatchedForRewatching)
 121        {
 0122            var lastWatchedByDateBase = context.BaseItems
 0123                .AsNoTracking()
 0124                .Where(e => e.Type == episodeTypeName)
 0125                .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0126                .Where(e => e.ParentIndexNumber != 0);
 0127            lastWatchedByDateBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedByDateBase, filter);
 128
 129            // Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation.
 130            // SelectMany on UserData with a correlated Where would translate to APPLY,
 131            // which SQLite does not support.
 132            // Access filtering leaves only primaries in the base query, but a play can be recorded
 133            // against any version, so each row is attributed to its group's primary before the join.
 0134            var playedByGroupPrimary = context.UserData
 0135                .AsNoTracking()
 0136                .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
 0137                .Where(ud => ud.Played)
 0138                .Join(
 0139                    context.BaseItems.AsNoTracking(),
 0140                    ud => ud.ItemId,
 0141                    bi => bi.Id,
 0142                    (ud, bi) => new { ud.UserId, ItemId = bi.PrimaryVersionId ?? bi.Id, ud.LastPlayedDate });
 143
 0144            var playedWithDates = lastWatchedByDateBase
 0145                .Join(
 0146                    playedByGroupPrimary,
 0147                    e => new { UserId = userId, ItemId = e.Id },
 0148                    ud => new { ud.UserId, ud.ItemId },
 0149                    (e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
 0150                .ToList();
 151
 0152            lastWatchedByDateInfo = playedWithDates
 0153                .OrderByDescending(x => x.LastPlayedDate)
 0154                .DistinctBy(x => x.SeriesPresentationUniqueKey)
 0155                .ToDictionary(x => x.SeriesPresentationUniqueKey!, x => x.EpisodeId);
 156        }
 157
 1158        var allLastWatchedIds = lastWatchedInfo.Values
 1159            .Concat(lastWatchedByDateInfo.Values)
 1160            .Where(id => id != Guid.Empty)
 1161            .Distinct()
 1162            .ToList();
 1163        var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id));
 1164        lwQuery = _queryHelpers.ApplyNavigations(lwQuery, filter);
 1165        var lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id);
 166
 1167        Dictionary<string, List<BaseItemEntity>> specialsBySeriesKey = new();
 1168        if (includeSpecials)
 169        {
 0170            var specialsQuery = context.BaseItems
 0171                .AsNoTracking()
 0172                .Where(e => e.Type == episodeTypeName)
 0173                .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0174                .Where(e => e.ParentIndexNumber == 0)
 0175                .Where(e => !e.IsVirtualItem);
 0176            specialsQuery = _queryHelpers.ApplyAccessFiltering(context, specialsQuery, filter);
 0177            specialsQuery = _queryHelpers.ApplyNavigations(specialsQuery, filter).AsSingleQuery();
 178
 0179            foreach (var special in specialsQuery)
 180            {
 0181                var key = special.SeriesPresentationUniqueKey!;
 0182                if (!specialsBySeriesKey.TryGetValue(key, out var list))
 183                {
 0184                    list = new List<BaseItemEntity>();
 0185                    specialsBySeriesKey[key] = list;
 186                }
 187
 0188                list.Add(special);
 189            }
 190        }
 191
 1192        var positionLookup = new Dictionary<string, (int Season, int Episode)>();
 4193        foreach (var kvp in lastWatchedInfo)
 194        {
 1195            if (kvp.Value != Guid.Empty
 1196                && lastWatchedEpisodes.TryGetValue(kvp.Value, out var lw)
 1197                && lw.ParentIndexNumber.HasValue
 1198                && lw.IndexNumber.HasValue)
 199            {
 1200                positionLookup[kvp.Key] = (lw.ParentIndexNumber.Value, lw.IndexNumber.Value);
 201            }
 202        }
 203
 1204        var allUnplayedBase = context.BaseItems
 1205            .AsNoTracking()
 1206            .Where(e => e.Type == episodeTypeName)
 1207            .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 1208            .Where(e => e.ParentIndexNumber != 0)
 1209            .Where(e => !e.IsVirtualItem)
 1210            .Where(DescendantQueryHelper.IsUnplayedBy(userId));
 1211        allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter);
 1212        var allUnplayedCandidates = allUnplayedBase
 1213            .Select(e => new
 1214            {
 1215                e.Id,
 1216                e.SeriesPresentationUniqueKey,
 1217                e.ParentIndexNumber,
 1218                EpisodeNumber = e.IndexNumber
 1219            })
 1220            .ToList();
 221
 1222        var nextEpisodeIds = new HashSet<Guid>();
 1223        var seriesNextIdMap = new Dictionary<string, Guid>();
 224
 4225        foreach (var seriesKey in seriesKeys)
 226        {
 1227            var candidates = allUnplayedCandidates
 1228                .Where(c => c.SeriesPresentationUniqueKey == seriesKey);
 229
 1230            if (positionLookup.TryGetValue(seriesKey, out var pos))
 231            {
 1232                candidates = candidates.Where(c =>
 1233                    c.ParentIndexNumber > pos.Season
 1234                    || (c.ParentIndexNumber == pos.Season && c.EpisodeNumber > pos.Episode));
 235            }
 236
 1237            var nextCandidate = candidates
 1238                .OrderBy(c => c.ParentIndexNumber)
 1239                .ThenBy(c => c.EpisodeNumber)
 1240                .FirstOrDefault();
 241
 1242            if (nextCandidate is not null && nextCandidate.Id != Guid.Empty)
 243            {
 1244                nextEpisodeIds.Add(nextCandidate.Id);
 1245                seriesNextIdMap[seriesKey] = nextCandidate.Id;
 246            }
 247        }
 248
 1249        var seriesNextPlayedIdMap = new Dictionary<string, Guid>();
 1250        if (includeWatchedForRewatching)
 251        {
 0252            var allPlayedBase = context.BaseItems
 0253                .AsNoTracking()
 0254                .Where(e => e.Type == episodeTypeName)
 0255                .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0256                .Where(e => e.ParentIndexNumber != 0)
 0257                .Where(e => !e.IsVirtualItem)
 0258                .Where(DescendantQueryHelper.IsPlayedBy(userId));
 0259            allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter);
 0260            var allPlayedCandidates = allPlayedBase
 0261                .Select(e => new
 0262                {
 0263                    e.Id,
 0264                    e.SeriesPresentationUniqueKey,
 0265                    e.ParentIndexNumber,
 0266                    EpisodeNumber = e.IndexNumber
 0267                })
 0268                .ToList();
 269
 0270            foreach (var seriesKey in seriesKeys)
 271            {
 0272                if (!lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId))
 273                {
 274                    continue;
 275                }
 276
 0277                var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId);
 0278                if (lastByDateEntity is null)
 279                {
 280                    continue;
 281                }
 282
 0283                var playedCandidates = allPlayedCandidates
 0284                    .Where(c => c.SeriesPresentationUniqueKey == seriesKey);
 285
 0286                if (lastByDateEntity.ParentIndexNumber.HasValue && lastByDateEntity.IndexNumber.HasValue)
 287                {
 0288                    var lastSeason = lastByDateEntity.ParentIndexNumber.Value;
 0289                    var lastEp = lastByDateEntity.IndexNumber.Value;
 0290                    playedCandidates = playedCandidates.Where(c =>
 0291                        c.ParentIndexNumber > lastSeason
 0292                        || (c.ParentIndexNumber == lastSeason && c.EpisodeNumber > lastEp));
 293                }
 294
 0295                var nextPlayedCandidate = playedCandidates
 0296                    .OrderBy(c => c.ParentIndexNumber)
 0297                    .ThenBy(c => c.EpisodeNumber)
 0298                    .FirstOrDefault();
 299
 0300                if (nextPlayedCandidate is not null && nextPlayedCandidate.Id != Guid.Empty)
 301                {
 0302                    nextEpisodeIds.Add(nextPlayedCandidate.Id);
 0303                    seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id;
 304                }
 305            }
 306        }
 307
 1308        var nextEpisodes = new Dictionary<Guid, BaseItemEntity>();
 1309        if (nextEpisodeIds.Count > 0)
 310        {
 1311            var nextQuery = context.BaseItems.AsNoTracking().Where(e => nextEpisodeIds.Contains(e.Id));
 1312            nextQuery = _queryHelpers.ApplyNavigations(nextQuery, filter).AsSingleQuery();
 1313            nextEpisodes = nextQuery.ToDictionary(e => e.Id);
 314        }
 315
 1316        var result = new Dictionary<string, NextUpEpisodeBatchResult>();
 4317        foreach (var seriesKey in seriesKeys)
 318        {
 1319            var batchResult = new NextUpEpisodeBatchResult();
 320
 1321            if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty)
 322            {
 1323                if (lastWatchedEpisodes.TryGetValue(lwId, out var entity))
 324                {
 1325                    batchResult.LastWatched = _queryHelpers.DeserializeBaseItem(entity, filter.SkipDeserialization);
 326                }
 327            }
 328
 1329            if (seriesNextIdMap.TryGetValue(seriesKey, out var nextId) && nextEpisodes.TryGetValue(nextId, out var nextE
 330            {
 1331                batchResult.NextUp = _queryHelpers.DeserializeBaseItem(nextEntity, filter.SkipDeserialization);
 332            }
 333
 1334            if (includeSpecials && specialsBySeriesKey.TryGetValue(seriesKey, out var specials))
 335            {
 0336                batchResult.Specials = specials.Select(s => _queryHelpers.DeserializeBaseItem(s, filter.SkipDeserializat
 337            }
 338            else
 339            {
 1340                batchResult.Specials = Array.Empty<BaseItemDto>();
 341            }
 342
 1343            if (includeWatchedForRewatching)
 344            {
 0345                if (lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId) &&
 0346                    lastWatchedEpisodes.TryGetValue(lastByDateId, out var lastByDateEntity))
 347                {
 0348                    batchResult.LastWatchedForRewatching = _queryHelpers.DeserializeBaseItem(lastByDateEntity, filter.Sk
 349                }
 350
 0351                if (seriesNextPlayedIdMap.TryGetValue(seriesKey, out var nextPlayedId) &&
 0352                    nextEpisodes.TryGetValue(nextPlayedId, out var nextPlayedEntity))
 353                {
 0354                    batchResult.NextPlayedForRewatching = _queryHelpers.DeserializeBaseItem(nextPlayedEntity, filter.Ski
 355                }
 356            }
 357
 1358            result[seriesKey] = batchResult;
 359        }
 360
 1361        return result;
 1362    }
 363}