#pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.TV; using MediaBrowser.Model.Querying; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Series = MediaBrowser.Controller.Entities.TV.Series; namespace Emby.Server.Implementations.TV { public class TVSeriesManager : ITVSeriesManager { private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; public TVSeriesManager(IUserDataManager userDataManager, ILibraryManager libraryManager, IServerConfigurationManager configurationManager) { _userDataManager = userDataManager; _libraryManager = libraryManager; _configurationManager = configurationManager; } public QueryResult GetNextUp(NextUpQuery query, DtoOptions options) { var user = query.User; string? presentationUniqueKey = null; if (!query.SeriesId.IsNullOrEmpty()) { if (_libraryManager.GetItemById(query.SeriesId.Value) is Series series) { presentationUniqueKey = GetUniqueSeriesKey(series); } } if (!string.IsNullOrEmpty(presentationUniqueKey)) { return GetNextUpBatched(query, user, [presentationUniqueKey], options); } BaseItem[] parents; if (query.ParentId.HasValue) { var parent = _libraryManager.GetItemById(query.ParentId.Value); if (parent is not null) { parents = [parent]; } else { parents = []; } } else { parents = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) .Where(i => !user.GetPreferenceValues(PreferenceKind.LatestItemExcludes).Contains(i.Id)) .ToArray(); } return GetNextUp(query, parents, options); } public QueryResult GetNextUp(NextUpQuery request, BaseItem[] parentsFolders, DtoOptions options) { var user = request.User; string? presentationUniqueKey = null; int? limit = null; if (!request.SeriesId.IsNullOrEmpty()) { if (_libraryManager.GetItemById(request.SeriesId.Value) is Series series) { presentationUniqueKey = GetUniqueSeriesKey(series); limit = 1; } } if (!string.IsNullOrEmpty(presentationUniqueKey)) { return GetNextUpBatched(request, user, [presentationUniqueKey], options); } if (limit.HasValue) { limit = limit.Value + 10; } var nextUpSeriesKeys = _libraryManager.GetNextUpSeriesKeys(new InternalItemsQuery(user) { Limit = limit }, parentsFolders, request.NextUpDateCutoff); return GetNextUpBatched(request, user, nextUpSeriesKeys, options); } private QueryResult GetNextUpBatched(NextUpQuery request, User user, IReadOnlyList seriesKeys, DtoOptions dtoOptions) { if (seriesKeys.Count == 0) { return new QueryResult(); } var includeSpecials = _configurationManager.Configuration.DisplaySpecialsWithinSeasons; var includeRewatching = request.EnableRewatching; var query = new InternalItemsQuery(user) { DtoOptions = dtoOptions }; var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching); var results = new List(seriesKeys.Count); foreach (var seriesKey in seriesKeys) { if (batchResult.TryGetValue(seriesKey, out var result)) { results.Add(result); } } // The selection below tests the played state of every episode it considers, so read the whole // series batch in one query rather than one query per series. var selectionCandidates = new List(); foreach (var result in results) { AddCandidate(selectionCandidates, result.NextUp); AddCandidate(selectionCandidates, result.LastWatched); AddCandidate(selectionCandidates, result.NextPlayedForRewatching); AddCandidate(selectionCandidates, result.LastWatchedForRewatching); if (result.Specials is not null) { selectionCandidates.AddRange(result.Specials); } } var selectionUserData = _userDataManager.GetUserDataBatch(selectionCandidates, user); var candidates = new List(); foreach (var result in results) { var nextEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: false, selectionUserData); if (nextEpisode is not null) { candidates.Add(new NextUpCandidate(nextEpisode, result.LastWatched, !request.EnableResumable)); } if (includeRewatching) { var nextPlayedEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: true, selectionUserData); if (nextPlayedEpisode is not null) { // A rewatch suggestion is dropped once it has been resumed, whatever the request asked for. candidates.Add(new NextUpCandidate(nextPlayedEpisode, result.LastWatchedForRewatching, true)); } } } // The resume progress may live on an alternate version, so read every version in one query. var episodeVersions = new List(); foreach (var candidate in candidates) { if (candidate.DropWhenResumed) { candidate.EpisodeVersions = candidate.Episode.GetAllVersions(); episodeVersions.AddRange(candidate.EpisodeVersions); } } if (episodeVersions.Count > 0) { var resumeUserData = _userDataManager.GetUserDataBatch(episodeVersions, user); candidates.RemoveAll(candidate => candidate.EpisodeVersions .Any(version => GetUserData(user, version, resumeUserData)?.PlaybackPositionTicks > 0)); } // The last played date and the version that was actually played live on the version item's user data // The played state propagated to the sibling versions carries no date var lastWatchedVersions = new List(); foreach (var candidate in candidates) { if (candidate.LastWatched is Video lastWatchedVideo) { candidate.LastWatchedVersions = lastWatchedVideo.GetAllVersions(); lastWatchedVersions.AddRange(candidate.LastWatchedVersions); } } var lastWatchedUserData = _userDataManager.GetUserDataBatch(lastWatchedVersions, user); var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>(candidates.Count); foreach (var candidate in candidates) { var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(candidate.LastWatchedVersions, user, lastWatchedUserData); var nextEpisode = GetPreferredVersion(candidate.Episode, candidate.LastWatched, playedVersion); DateTime lastWatchedDate = DateTime.MinValue; if (candidate.LastWatched is not null) { lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); } nextUpList.Add((lastWatchedDate, nextEpisode)); } var sortedEpisodes = nextUpList .OrderByDescending(x => x.LastWatchedDate) .Select(x => (BaseItem)x.Episode); return GetResult(sortedEpisodes, request); } private static void AddCandidate(List candidates, BaseItem? item) { if (item is not null) { candidates.Add(item); } } private UserItemData? GetUserData(User user, BaseItem item, IReadOnlyDictionary prefetchedUserData) => prefetchedUserData.TryGetValue(item.Id, out var userData) ? userData : _userDataManager.GetUserData(user, item); private Episode? SelectNextEpisode( NextUpEpisodeBatchResult result, User user, bool includeSpecials, bool includePlayed, IReadOnlyDictionary prefetchedUserData) { var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode; var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode; if (includeSpecials && result.Specials?.Count > 0) { var consideredEpisodes = result.Specials .Cast() .Where(episode => episode.AirsBeforeSeasonNumber is not null || episode.AirsAfterSeasonNumber is not null) .ToList(); if (lastWatchedEpisode is not null) { consideredEpisodes.Add(lastWatchedEpisode); } if (nextEpisode is not null) { consideredEpisodes.Add(nextEpisode); } if (consideredEpisodes.Count > 0) { var sortedEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)]) .Cast(); if (lastWatchedEpisode is not null) { sortedEpisodes = sortedEpisodes.SkipWhile(episode => !episode.Id.Equals(lastWatchedEpisode.Id)).Skip(1); } if (!includePlayed) { sortedEpisodes = sortedEpisodes.Where(episode => GetUserData(user, episode, prefetchedUserData) is not { Played: true }); } nextEpisode = sortedEpisodes.FirstOrDefault(); } } return nextEpisode; } /// /// Gets the version of the last watched episode that was actually played, together with its last played date. /// The version that was played carries the most recent LastPlayedDate. /// dates. /// /// The versions of the last watched episode. /// The user. /// User data read for every version up front. /// The played version and its last played date. private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion( IReadOnlyList