#pragma warning disable RS0030 // Do not use banned APIs using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Persistence; using Microsoft.EntityFrameworkCore; using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; namespace Jellyfin.Server.Implementations.Item; /// /// Provides next-up episode query operations. /// public class NextUpService : INextUpService { private readonly IDbContextFactory _dbProvider; private readonly IItemTypeLookup _itemTypeLookup; private readonly IItemQueryHelpers _queryHelpers; /// /// Initializes a new instance of the class. /// /// The database context factory. /// The item type lookup. /// The shared query helpers. public NextUpService( IDbContextFactory dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) { _dbProvider = dbProvider; _itemTypeLookup = itemTypeLookup; _queryHelpers = queryHelpers; } /// public IReadOnlyList GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff) { ArgumentNullException.ThrowIfNull(filter); ArgumentNullException.ThrowIfNull(filter.User); using var context = _dbProvider.CreateDbContext(); var query = context.BaseItems .AsNoTracking() .Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value)) .Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]) .Join( context.UserData.AsNoTracking().Where(e => e.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)), i => new { UserId = filter.User.Id, ItemId = i.Id }, u => new { u.UserId, u.ItemId }, (entity, data) => new { Item = entity, UserData = data }) .GroupBy(g => g.Item.SeriesPresentationUniqueKey) .Select(g => new { g.Key, LastPlayedDate = g.Max(u => u.UserData.LastPlayedDate) }) .Where(g => g.Key != null && g.LastPlayedDate != null && g.LastPlayedDate >= dateCutoff) .OrderByDescending(g => g.LastPlayedDate) .Select(g => g.Key!); if (filter.Limit.HasValue) { query = query.Take(filter.Limit.Value); } return query.ToArray(); } /// public IReadOnlyDictionary GetNextUpEpisodesBatch( InternalItemsQuery filter, IReadOnlyList seriesKeys, bool includeSpecials, bool includeWatchedForRewatching) { ArgumentNullException.ThrowIfNull(filter); ArgumentNullException.ThrowIfNull(filter.User); if (seriesKeys.Count == 0) { return new Dictionary(); } _queryHelpers.PrepareFilterQuery(filter); using var context = _dbProvider.CreateDbContext(); var userId = filter.User.Id; var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; var lastWatchedBase = context.BaseItems .AsNoTracking() .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter); // Use lightweight projection + client-side grouping to avoid correlated scalar subquery // per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault. var allPlayedLite = lastWatchedBase .Select(e => new { e.Id, e.SeriesPresentationUniqueKey, e.ParentIndexNumber, e.IndexNumber }) .ToList(); var lastWatchedInfo = new Dictionary(); foreach (var group in allPlayedLite.GroupBy(e => e.SeriesPresentationUniqueKey)) { var lastWatched = group .OrderByDescending(e => e.ParentIndexNumber) .ThenByDescending(e => e.IndexNumber) .First(); lastWatchedInfo[group.Key!] = lastWatched.Id; } Dictionary lastWatchedByDateInfo = new(); if (includeWatchedForRewatching) { var lastWatchedByDateBase = context.BaseItems .AsNoTracking() .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); lastWatchedByDateBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedByDateBase, filter); // Use lightweight projection + client-side grouping instead of // SelectMany+GroupBy+OrderByDescending+FirstOrDefault (correlated subquery). var playedWithDates = lastWatchedByDateBase .SelectMany(e => e.UserData!.Where(ud => ud.UserId == userId && ud.Played) .Select(ud => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })) .ToList(); foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey)) { var mostRecent = group.OrderByDescending(x => x.LastPlayedDate).First(); lastWatchedByDateInfo[group.Key!] = mostRecent.EpisodeId; } } var allLastWatchedIds = lastWatchedInfo.Values .Concat(lastWatchedByDateInfo.Values) .Where(id => id != Guid.Empty) .Distinct() .ToList(); var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id)); lwQuery = _queryHelpers.ApplyNavigations(lwQuery, filter); var lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id); Dictionary> specialsBySeriesKey = new(); if (includeSpecials) { var specialsQuery = context.BaseItems .AsNoTracking() .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber == 0) .Where(e => !e.IsVirtualItem); specialsQuery = _queryHelpers.ApplyAccessFiltering(context, specialsQuery, filter); specialsQuery = _queryHelpers.ApplyNavigations(specialsQuery, filter).AsSingleQuery(); foreach (var special in specialsQuery) { var key = special.SeriesPresentationUniqueKey!; if (!specialsBySeriesKey.TryGetValue(key, out var list)) { list = new List(); specialsBySeriesKey[key] = list; } list.Add(special); } } var positionLookup = new Dictionary(); foreach (var kvp in lastWatchedInfo) { if (kvp.Value != Guid.Empty && lastWatchedEpisodes.TryGetValue(kvp.Value, out var lw) && lw.ParentIndexNumber.HasValue && lw.IndexNumber.HasValue) { positionLookup[kvp.Key] = (lw.ParentIndexNumber.Value, lw.IndexNumber.Value); } } var allUnplayedBase = context.BaseItems .AsNoTracking() .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter); var allUnplayedCandidates = allUnplayedBase .Select(e => new { e.Id, e.SeriesPresentationUniqueKey, e.ParentIndexNumber, EpisodeNumber = e.IndexNumber }) .ToList(); var nextEpisodeIds = new HashSet(); var seriesNextIdMap = new Dictionary(); foreach (var seriesKey in seriesKeys) { var candidates = allUnplayedCandidates .Where(c => c.SeriesPresentationUniqueKey == seriesKey); if (positionLookup.TryGetValue(seriesKey, out var pos)) { candidates = candidates.Where(c => c.ParentIndexNumber > pos.Season || (c.ParentIndexNumber == pos.Season && c.EpisodeNumber > pos.Episode)); } var nextCandidate = candidates .OrderBy(c => c.ParentIndexNumber) .ThenBy(c => c.EpisodeNumber) .FirstOrDefault(); if (nextCandidate is not null && nextCandidate.Id != Guid.Empty) { nextEpisodeIds.Add(nextCandidate.Id); seriesNextIdMap[seriesKey] = nextCandidate.Id; } } var seriesNextPlayedIdMap = new Dictionary(); if (includeWatchedForRewatching) { var allPlayedBase = context.BaseItems .AsNoTracking() .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter); var allPlayedCandidates = allPlayedBase .Select(e => new { e.Id, e.SeriesPresentationUniqueKey, e.ParentIndexNumber, EpisodeNumber = e.IndexNumber }) .ToList(); foreach (var seriesKey in seriesKeys) { if (!lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId)) { continue; } var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId); if (lastByDateEntity is null) { continue; } var playedCandidates = allPlayedCandidates .Where(c => c.SeriesPresentationUniqueKey == seriesKey); if (lastByDateEntity.ParentIndexNumber.HasValue && lastByDateEntity.IndexNumber.HasValue) { var lastSeason = lastByDateEntity.ParentIndexNumber.Value; var lastEp = lastByDateEntity.IndexNumber.Value; playedCandidates = playedCandidates.Where(c => c.ParentIndexNumber > lastSeason || (c.ParentIndexNumber == lastSeason && c.EpisodeNumber > lastEp)); } var nextPlayedCandidate = playedCandidates .OrderBy(c => c.ParentIndexNumber) .ThenBy(c => c.EpisodeNumber) .FirstOrDefault(); if (nextPlayedCandidate is not null && nextPlayedCandidate.Id != Guid.Empty) { nextEpisodeIds.Add(nextPlayedCandidate.Id); seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id; } } } var nextEpisodes = new Dictionary(); if (nextEpisodeIds.Count > 0) { var nextQuery = context.BaseItems.AsNoTracking().Where(e => nextEpisodeIds.Contains(e.Id)); nextQuery = _queryHelpers.ApplyNavigations(nextQuery, filter).AsSingleQuery(); nextEpisodes = nextQuery.ToDictionary(e => e.Id); } var result = new Dictionary(); foreach (var seriesKey in seriesKeys) { var batchResult = new NextUpEpisodeBatchResult(); if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty) { if (lastWatchedEpisodes.TryGetValue(lwId, out var entity)) { batchResult.LastWatched = _queryHelpers.DeserializeBaseItem(entity, filter.SkipDeserialization); } } if (seriesNextIdMap.TryGetValue(seriesKey, out var nextId) && nextEpisodes.TryGetValue(nextId, out var nextEntity)) { batchResult.NextUp = _queryHelpers.DeserializeBaseItem(nextEntity, filter.SkipDeserialization); } if (includeSpecials && specialsBySeriesKey.TryGetValue(seriesKey, out var specials)) { batchResult.Specials = specials.Select(s => _queryHelpers.DeserializeBaseItem(s, filter.SkipDeserialization)!).ToList(); } else { batchResult.Specials = Array.Empty(); } if (includeWatchedForRewatching) { if (lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId) && lastWatchedEpisodes.TryGetValue(lastByDateId, out var lastByDateEntity)) { batchResult.LastWatchedForRewatching = _queryHelpers.DeserializeBaseItem(lastByDateEntity, filter.SkipDeserialization); } if (seriesNextPlayedIdMap.TryGetValue(seriesKey, out var nextPlayedId) && nextEpisodes.TryGetValue(nextPlayedId, out var nextPlayedEntity)) { batchResult.NextPlayedForRewatching = _queryHelpers.DeserializeBaseItem(nextPlayedEntity, filter.SkipDeserialization); } } result[seriesKey] = batchResult; } return result; } }