< 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
1%
Covered lines: 4
Uncovered lines: 200
Coverable lines: 204
Total lines: 354
Line coverage: 1.9%
Branch coverage
0%
Covered branches: 0
Total branches: 76
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/4/2026 - 12:15:16 AM Line coverage: 2% (4/200) Branch coverage: 0% (0/80) Total lines: 3535/7/2026 - 12:15:44 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: 354 5/4/2026 - 12:15:16 AM Line coverage: 2% (4/200) Branch coverage: 0% (0/80) Total lines: 3535/7/2026 - 12:15:44 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: 354

Coverage delta

Coverage delta 1 -1

Metrics

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

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    {
 2236        _dbProvider = dbProvider;
 2237        _itemTypeLookup = itemTypeLookup;
 2238        _queryHelpers = queryHelpers;
 2239    }
 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    {
 079        ArgumentNullException.ThrowIfNull(filter);
 080        ArgumentNullException.ThrowIfNull(filter.User);
 81
 082        if (seriesKeys.Count == 0)
 83        {
 084            return new Dictionary<string, NextUpEpisodeBatchResult>();
 85        }
 86
 087        _queryHelpers.PrepareFilterQuery(filter);
 088        using var context = _dbProvider.CreateDbContext();
 89
 090        var userId = filter.User.Id;
 091        var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
 92
 093        var lastWatchedBase = context.BaseItems
 094            .AsNoTracking()
 095            .Where(e => e.Type == episodeTypeName)
 096            .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 097            .Where(e => e.ParentIndexNumber != 0)
 098            .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
 099        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.
 0103        var allPlayedLite = lastWatchedBase
 0104            .Select(e => new
 0105            {
 0106                e.Id,
 0107                e.SeriesPresentationUniqueKey,
 0108                e.ParentIndexNumber,
 0109                e.IndexNumber
 0110            })
 0111            .ToList();
 112
 0113        var lastWatchedInfo = allPlayedLite
 0114            .OrderByDescending(e => e.ParentIndexNumber)
 0115            .ThenByDescending(e => e.IndexNumber)
 0116            .DistinctBy(e => e.SeriesPresentationUniqueKey)
 0117            .ToDictionary(e => e.SeriesPresentationUniqueKey!, e => e.Id);
 118
 0119        Dictionary<string, Guid> lastWatchedByDateInfo = new();
 0120        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.
 0132            var playedWithDates = lastWatchedByDateBase
 0133                .Join(
 0134                    context.UserData
 0135                        .AsNoTracking()
 0136                        .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
 0137                        .Where(ud => ud.Played),
 0138                    e => new { UserId = userId, ItemId = e.Id },
 0139                    ud => new { ud.UserId, ud.ItemId },
 0140                    (e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
 0141                .ToList();
 142
 0143            lastWatchedByDateInfo = playedWithDates
 0144                .OrderByDescending(x => x.LastPlayedDate)
 0145                .DistinctBy(x => x.SeriesPresentationUniqueKey)
 0146                .ToDictionary(x => x.SeriesPresentationUniqueKey!, x => x.EpisodeId);
 147        }
 148
 0149        var allLastWatchedIds = lastWatchedInfo.Values
 0150            .Concat(lastWatchedByDateInfo.Values)
 0151            .Where(id => id != Guid.Empty)
 0152            .Distinct()
 0153            .ToList();
 0154        var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id));
 0155        lwQuery = _queryHelpers.ApplyNavigations(lwQuery, filter);
 0156        var lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id);
 157
 0158        Dictionary<string, List<BaseItemEntity>> specialsBySeriesKey = new();
 0159        if (includeSpecials)
 160        {
 0161            var specialsQuery = context.BaseItems
 0162                .AsNoTracking()
 0163                .Where(e => e.Type == episodeTypeName)
 0164                .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0165                .Where(e => e.ParentIndexNumber == 0)
 0166                .Where(e => !e.IsVirtualItem);
 0167            specialsQuery = _queryHelpers.ApplyAccessFiltering(context, specialsQuery, filter);
 0168            specialsQuery = _queryHelpers.ApplyNavigations(specialsQuery, filter).AsSingleQuery();
 169
 0170            foreach (var special in specialsQuery)
 171            {
 0172                var key = special.SeriesPresentationUniqueKey!;
 0173                if (!specialsBySeriesKey.TryGetValue(key, out var list))
 174                {
 0175                    list = new List<BaseItemEntity>();
 0176                    specialsBySeriesKey[key] = list;
 177                }
 178
 0179                list.Add(special);
 180            }
 181        }
 182
 0183        var positionLookup = new Dictionary<string, (int Season, int Episode)>();
 0184        foreach (var kvp in lastWatchedInfo)
 185        {
 0186            if (kvp.Value != Guid.Empty
 0187                && lastWatchedEpisodes.TryGetValue(kvp.Value, out var lw)
 0188                && lw.ParentIndexNumber.HasValue
 0189                && lw.IndexNumber.HasValue)
 190            {
 0191                positionLookup[kvp.Key] = (lw.ParentIndexNumber.Value, lw.IndexNumber.Value);
 192            }
 193        }
 194
 0195        var allUnplayedBase = context.BaseItems
 0196            .AsNoTracking()
 0197            .Where(e => e.Type == episodeTypeName)
 0198            .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0199            .Where(e => e.ParentIndexNumber != 0)
 0200            .Where(e => !e.IsVirtualItem)
 0201            .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
 0202        allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter);
 0203        var allUnplayedCandidates = allUnplayedBase
 0204            .Select(e => new
 0205            {
 0206                e.Id,
 0207                e.SeriesPresentationUniqueKey,
 0208                e.ParentIndexNumber,
 0209                EpisodeNumber = e.IndexNumber
 0210            })
 0211            .ToList();
 212
 0213        var nextEpisodeIds = new HashSet<Guid>();
 0214        var seriesNextIdMap = new Dictionary<string, Guid>();
 215
 0216        foreach (var seriesKey in seriesKeys)
 217        {
 0218            var candidates = allUnplayedCandidates
 0219                .Where(c => c.SeriesPresentationUniqueKey == seriesKey);
 220
 0221            if (positionLookup.TryGetValue(seriesKey, out var pos))
 222            {
 0223                candidates = candidates.Where(c =>
 0224                    c.ParentIndexNumber > pos.Season
 0225                    || (c.ParentIndexNumber == pos.Season && c.EpisodeNumber > pos.Episode));
 226            }
 227
 0228            var nextCandidate = candidates
 0229                .OrderBy(c => c.ParentIndexNumber)
 0230                .ThenBy(c => c.EpisodeNumber)
 0231                .FirstOrDefault();
 232
 0233            if (nextCandidate is not null && nextCandidate.Id != Guid.Empty)
 234            {
 0235                nextEpisodeIds.Add(nextCandidate.Id);
 0236                seriesNextIdMap[seriesKey] = nextCandidate.Id;
 237            }
 238        }
 239
 0240        var seriesNextPlayedIdMap = new Dictionary<string, Guid>();
 0241        if (includeWatchedForRewatching)
 242        {
 0243            var allPlayedBase = context.BaseItems
 0244                .AsNoTracking()
 0245                .Where(e => e.Type == episodeTypeName)
 0246                .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0247                .Where(e => e.ParentIndexNumber != 0)
 0248                .Where(e => !e.IsVirtualItem)
 0249                .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
 0250            allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter);
 0251            var allPlayedCandidates = allPlayedBase
 0252                .Select(e => new
 0253                {
 0254                    e.Id,
 0255                    e.SeriesPresentationUniqueKey,
 0256                    e.ParentIndexNumber,
 0257                    EpisodeNumber = e.IndexNumber
 0258                })
 0259                .ToList();
 260
 0261            foreach (var seriesKey in seriesKeys)
 262            {
 0263                if (!lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId))
 264                {
 265                    continue;
 266                }
 267
 0268                var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId);
 0269                if (lastByDateEntity is null)
 270                {
 271                    continue;
 272                }
 273
 0274                var playedCandidates = allPlayedCandidates
 0275                    .Where(c => c.SeriesPresentationUniqueKey == seriesKey);
 276
 0277                if (lastByDateEntity.ParentIndexNumber.HasValue && lastByDateEntity.IndexNumber.HasValue)
 278                {
 0279                    var lastSeason = lastByDateEntity.ParentIndexNumber.Value;
 0280                    var lastEp = lastByDateEntity.IndexNumber.Value;
 0281                    playedCandidates = playedCandidates.Where(c =>
 0282                        c.ParentIndexNumber > lastSeason
 0283                        || (c.ParentIndexNumber == lastSeason && c.EpisodeNumber > lastEp));
 284                }
 285
 0286                var nextPlayedCandidate = playedCandidates
 0287                    .OrderBy(c => c.ParentIndexNumber)
 0288                    .ThenBy(c => c.EpisodeNumber)
 0289                    .FirstOrDefault();
 290
 0291                if (nextPlayedCandidate is not null && nextPlayedCandidate.Id != Guid.Empty)
 292                {
 0293                    nextEpisodeIds.Add(nextPlayedCandidate.Id);
 0294                    seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id;
 295                }
 296            }
 297        }
 298
 0299        var nextEpisodes = new Dictionary<Guid, BaseItemEntity>();
 0300        if (nextEpisodeIds.Count > 0)
 301        {
 0302            var nextQuery = context.BaseItems.AsNoTracking().Where(e => nextEpisodeIds.Contains(e.Id));
 0303            nextQuery = _queryHelpers.ApplyNavigations(nextQuery, filter).AsSingleQuery();
 0304            nextEpisodes = nextQuery.ToDictionary(e => e.Id);
 305        }
 306
 0307        var result = new Dictionary<string, NextUpEpisodeBatchResult>();
 0308        foreach (var seriesKey in seriesKeys)
 309        {
 0310            var batchResult = new NextUpEpisodeBatchResult();
 311
 0312            if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty)
 313            {
 0314                if (lastWatchedEpisodes.TryGetValue(lwId, out var entity))
 315                {
 0316                    batchResult.LastWatched = _queryHelpers.DeserializeBaseItem(entity, filter.SkipDeserialization);
 317                }
 318            }
 319
 0320            if (seriesNextIdMap.TryGetValue(seriesKey, out var nextId) && nextEpisodes.TryGetValue(nextId, out var nextE
 321            {
 0322                batchResult.NextUp = _queryHelpers.DeserializeBaseItem(nextEntity, filter.SkipDeserialization);
 323            }
 324
 0325            if (includeSpecials && specialsBySeriesKey.TryGetValue(seriesKey, out var specials))
 326            {
 0327                batchResult.Specials = specials.Select(s => _queryHelpers.DeserializeBaseItem(s, filter.SkipDeserializat
 328            }
 329            else
 330            {
 0331                batchResult.Specials = Array.Empty<BaseItemDto>();
 332            }
 333
 0334            if (includeWatchedForRewatching)
 335            {
 0336                if (lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId) &&
 0337                    lastWatchedEpisodes.TryGetValue(lastByDateId, out var lastByDateEntity))
 338                {
 0339                    batchResult.LastWatchedForRewatching = _queryHelpers.DeserializeBaseItem(lastByDateEntity, filter.Sk
 340                }
 341
 0342                if (seriesNextPlayedIdMap.TryGetValue(seriesKey, out var nextPlayedId) &&
 0343                    nextEpisodes.TryGetValue(nextPlayedId, out var nextPlayedEntity))
 344                {
 0345                    batchResult.NextPlayedForRewatching = _queryHelpers.DeserializeBaseItem(nextPlayedEntity, filter.Ski
 346                }
 347            }
 348
 0349            result[seriesKey] = batchResult;
 350        }
 351
 0352        return result;
 0353    }
 354}