< 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: 201
Coverable lines: 205
Total lines: 359
Line coverage: 1.9%
Branch coverage
0%
Covered branches: 0
Total branches: 80
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: 359 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: 359

Coverage delta

Coverage delta 1 -1

Metrics

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

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    {
 2136        _dbProvider = dbProvider;
 2137        _itemTypeLookup = itemTypeLookup;
 2138        _queryHelpers = queryHelpers;
 2139    }
 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 grouping to avoid 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 = new Dictionary<string, Guid>();
 0114        foreach (var group in allPlayedLite.GroupBy(e => e.SeriesPresentationUniqueKey))
 115        {
 0116            var lastWatched = group
 0117                .OrderByDescending(e => e.ParentIndexNumber)
 0118                .ThenByDescending(e => e.IndexNumber)
 0119                .First();
 0120            lastWatchedInfo[group.Key!] = lastWatched.Id;
 121        }
 122
 0123        Dictionary<string, Guid> lastWatchedByDateInfo = new();
 0124        if (includeWatchedForRewatching)
 125        {
 0126            var lastWatchedByDateBase = context.BaseItems
 0127                .AsNoTracking()
 0128                .Where(e => e.Type == episodeTypeName)
 0129                .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0130                .Where(e => e.ParentIndexNumber != 0);
 0131            lastWatchedByDateBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedByDateBase, filter);
 132
 133            // Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation.
 134            // SelectMany on UserData with a correlated Where would translate to APPLY,
 135            // which SQLite does not support.
 0136            var playedWithDates = lastWatchedByDateBase
 0137                .Join(
 0138                    context.UserData
 0139                        .AsNoTracking()
 0140                        .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
 0141                        .Where(ud => ud.Played),
 0142                    e => new { UserId = userId, ItemId = e.Id },
 0143                    ud => new { ud.UserId, ud.ItemId },
 0144                    (e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
 0145                .ToList();
 146
 0147            foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey))
 148            {
 0149                var mostRecent = group.OrderByDescending(x => x.LastPlayedDate).First();
 0150                lastWatchedByDateInfo[group.Key!] = mostRecent.EpisodeId;
 151            }
 152        }
 153
 0154        var allLastWatchedIds = lastWatchedInfo.Values
 0155            .Concat(lastWatchedByDateInfo.Values)
 0156            .Where(id => id != Guid.Empty)
 0157            .Distinct()
 0158            .ToList();
 0159        var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id));
 0160        lwQuery = _queryHelpers.ApplyNavigations(lwQuery, filter);
 0161        var lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id);
 162
 0163        Dictionary<string, List<BaseItemEntity>> specialsBySeriesKey = new();
 0164        if (includeSpecials)
 165        {
 0166            var specialsQuery = context.BaseItems
 0167                .AsNoTracking()
 0168                .Where(e => e.Type == episodeTypeName)
 0169                .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0170                .Where(e => e.ParentIndexNumber == 0)
 0171                .Where(e => !e.IsVirtualItem);
 0172            specialsQuery = _queryHelpers.ApplyAccessFiltering(context, specialsQuery, filter);
 0173            specialsQuery = _queryHelpers.ApplyNavigations(specialsQuery, filter).AsSingleQuery();
 174
 0175            foreach (var special in specialsQuery)
 176            {
 0177                var key = special.SeriesPresentationUniqueKey!;
 0178                if (!specialsBySeriesKey.TryGetValue(key, out var list))
 179                {
 0180                    list = new List<BaseItemEntity>();
 0181                    specialsBySeriesKey[key] = list;
 182                }
 183
 0184                list.Add(special);
 185            }
 186        }
 187
 0188        var positionLookup = new Dictionary<string, (int Season, int Episode)>();
 0189        foreach (var kvp in lastWatchedInfo)
 190        {
 0191            if (kvp.Value != Guid.Empty
 0192                && lastWatchedEpisodes.TryGetValue(kvp.Value, out var lw)
 0193                && lw.ParentIndexNumber.HasValue
 0194                && lw.IndexNumber.HasValue)
 195            {
 0196                positionLookup[kvp.Key] = (lw.ParentIndexNumber.Value, lw.IndexNumber.Value);
 197            }
 198        }
 199
 0200        var allUnplayedBase = context.BaseItems
 0201            .AsNoTracking()
 0202            .Where(e => e.Type == episodeTypeName)
 0203            .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0204            .Where(e => e.ParentIndexNumber != 0)
 0205            .Where(e => !e.IsVirtualItem)
 0206            .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
 0207        allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter);
 0208        var allUnplayedCandidates = allUnplayedBase
 0209            .Select(e => new
 0210            {
 0211                e.Id,
 0212                e.SeriesPresentationUniqueKey,
 0213                e.ParentIndexNumber,
 0214                EpisodeNumber = e.IndexNumber
 0215            })
 0216            .ToList();
 217
 0218        var nextEpisodeIds = new HashSet<Guid>();
 0219        var seriesNextIdMap = new Dictionary<string, Guid>();
 220
 0221        foreach (var seriesKey in seriesKeys)
 222        {
 0223            var candidates = allUnplayedCandidates
 0224                .Where(c => c.SeriesPresentationUniqueKey == seriesKey);
 225
 0226            if (positionLookup.TryGetValue(seriesKey, out var pos))
 227            {
 0228                candidates = candidates.Where(c =>
 0229                    c.ParentIndexNumber > pos.Season
 0230                    || (c.ParentIndexNumber == pos.Season && c.EpisodeNumber > pos.Episode));
 231            }
 232
 0233            var nextCandidate = candidates
 0234                .OrderBy(c => c.ParentIndexNumber)
 0235                .ThenBy(c => c.EpisodeNumber)
 0236                .FirstOrDefault();
 237
 0238            if (nextCandidate is not null && nextCandidate.Id != Guid.Empty)
 239            {
 0240                nextEpisodeIds.Add(nextCandidate.Id);
 0241                seriesNextIdMap[seriesKey] = nextCandidate.Id;
 242            }
 243        }
 244
 0245        var seriesNextPlayedIdMap = new Dictionary<string, Guid>();
 0246        if (includeWatchedForRewatching)
 247        {
 0248            var allPlayedBase = context.BaseItems
 0249                .AsNoTracking()
 0250                .Where(e => e.Type == episodeTypeName)
 0251                .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
 0252                .Where(e => e.ParentIndexNumber != 0)
 0253                .Where(e => !e.IsVirtualItem)
 0254                .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
 0255            allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter);
 0256            var allPlayedCandidates = allPlayedBase
 0257                .Select(e => new
 0258                {
 0259                    e.Id,
 0260                    e.SeriesPresentationUniqueKey,
 0261                    e.ParentIndexNumber,
 0262                    EpisodeNumber = e.IndexNumber
 0263                })
 0264                .ToList();
 265
 0266            foreach (var seriesKey in seriesKeys)
 267            {
 0268                if (!lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId))
 269                {
 270                    continue;
 271                }
 272
 0273                var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId);
 0274                if (lastByDateEntity is null)
 275                {
 276                    continue;
 277                }
 278
 0279                var playedCandidates = allPlayedCandidates
 0280                    .Where(c => c.SeriesPresentationUniqueKey == seriesKey);
 281
 0282                if (lastByDateEntity.ParentIndexNumber.HasValue && lastByDateEntity.IndexNumber.HasValue)
 283                {
 0284                    var lastSeason = lastByDateEntity.ParentIndexNumber.Value;
 0285                    var lastEp = lastByDateEntity.IndexNumber.Value;
 0286                    playedCandidates = playedCandidates.Where(c =>
 0287                        c.ParentIndexNumber > lastSeason
 0288                        || (c.ParentIndexNumber == lastSeason && c.EpisodeNumber > lastEp));
 289                }
 290
 0291                var nextPlayedCandidate = playedCandidates
 0292                    .OrderBy(c => c.ParentIndexNumber)
 0293                    .ThenBy(c => c.EpisodeNumber)
 0294                    .FirstOrDefault();
 295
 0296                if (nextPlayedCandidate is not null && nextPlayedCandidate.Id != Guid.Empty)
 297                {
 0298                    nextEpisodeIds.Add(nextPlayedCandidate.Id);
 0299                    seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id;
 300                }
 301            }
 302        }
 303
 0304        var nextEpisodes = new Dictionary<Guid, BaseItemEntity>();
 0305        if (nextEpisodeIds.Count > 0)
 306        {
 0307            var nextQuery = context.BaseItems.AsNoTracking().Where(e => nextEpisodeIds.Contains(e.Id));
 0308            nextQuery = _queryHelpers.ApplyNavigations(nextQuery, filter).AsSingleQuery();
 0309            nextEpisodes = nextQuery.ToDictionary(e => e.Id);
 310        }
 311
 0312        var result = new Dictionary<string, NextUpEpisodeBatchResult>();
 0313        foreach (var seriesKey in seriesKeys)
 314        {
 0315            var batchResult = new NextUpEpisodeBatchResult();
 316
 0317            if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty)
 318            {
 0319                if (lastWatchedEpisodes.TryGetValue(lwId, out var entity))
 320                {
 0321                    batchResult.LastWatched = _queryHelpers.DeserializeBaseItem(entity, filter.SkipDeserialization);
 322                }
 323            }
 324
 0325            if (seriesNextIdMap.TryGetValue(seriesKey, out var nextId) && nextEpisodes.TryGetValue(nextId, out var nextE
 326            {
 0327                batchResult.NextUp = _queryHelpers.DeserializeBaseItem(nextEntity, filter.SkipDeserialization);
 328            }
 329
 0330            if (includeSpecials && specialsBySeriesKey.TryGetValue(seriesKey, out var specials))
 331            {
 0332                batchResult.Specials = specials.Select(s => _queryHelpers.DeserializeBaseItem(s, filter.SkipDeserializat
 333            }
 334            else
 335            {
 0336                batchResult.Specials = Array.Empty<BaseItemDto>();
 337            }
 338
 0339            if (includeWatchedForRewatching)
 340            {
 0341                if (lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId) &&
 0342                    lastWatchedEpisodes.TryGetValue(lastByDateId, out var lastByDateEntity))
 343                {
 0344                    batchResult.LastWatchedForRewatching = _queryHelpers.DeserializeBaseItem(lastByDateEntity, filter.Sk
 345                }
 346
 0347                if (seriesNextPlayedIdMap.TryGetValue(seriesKey, out var nextPlayedId) &&
 0348                    nextEpisodes.TryGetValue(nextPlayedId, out var nextPlayedEntity))
 349                {
 0350                    batchResult.NextPlayedForRewatching = _queryHelpers.DeserializeBaseItem(nextPlayedEntity, filter.Ski
 351                }
 352            }
 353
 0354            result[seriesKey] = batchResult;
 355        }
 356
 0357        return result;
 0358    }
 359}