diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 459ad1a17e..bc418b5c52 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -13,6 +13,7 @@ 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; @@ -124,53 +125,100 @@ namespace Emby.Server.Implementations.TV var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching); - var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>(); - + var results = new List(seriesKeys.Count); foreach (var seriesKey in seriesKeys) { - if (!batchResult.TryGetValue(seriesKey, out var result)) + if (batchResult.TryGetValue(seriesKey, out var result)) { - continue; + results.Add(result); } + } - var nextEpisode = DetermineNextEpisode(result, user, includeSpecials, request.EnableResumable, false); + // 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) { - // 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 (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user); - nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion); - - DateTime lastWatchedDate = DateTime.MinValue; - if (result.LastWatched is not null) - { - lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); - } - - nextUpList.Add((lastWatchedDate, nextEpisode)); + candidates.Add(new NextUpCandidate(nextEpisode, result.LastWatched, !request.EnableResumable)); } if (includeRewatching) { - var nextPlayedEpisode = DetermineNextEpisodeForRewatching(result, user, includeSpecials); - + var nextPlayedEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: true, selectionUserData); if (nextPlayedEpisode is not null) { - var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user); - nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion); - - DateTime rewatchLastWatchedDate = DateTime.MinValue; - if (result.LastWatchedForRewatching is not null) - { - rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); - } - - nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode)); + // 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); @@ -178,12 +226,25 @@ namespace Emby.Server.Implementations.TV return GetResult(sortedEpisodes, request); } - private Episode? DetermineNextEpisode( - MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result, + 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 includeResumable, - bool includePlayed) + bool includePlayed, + IReadOnlyDictionary prefetchedUserData) { var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode; var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode; @@ -217,60 +278,41 @@ namespace Emby.Server.Implementations.TV if (!includePlayed) { - sortedEpisodes = sortedEpisodes.Where(episode => _userDataManager.GetUserData(user, episode) is not { Played: true }); + sortedEpisodes = sortedEpisodes.Where(episode => GetUserData(user, episode, prefetchedUserData) is not { Played: true }); } nextEpisode = sortedEpisodes.FirstOrDefault(); } } - if (nextEpisode is not null && !includeResumable) - { - // The resume progress may live on an alternate version - foreach (var version in nextEpisode.GetAllVersions()) - { - if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0) - { - return null; - } - } - } - return nextEpisode; } - private Episode? DetermineNextEpisodeForRewatching( - MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result, - User user, - bool includeSpecials) - { - return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true); - } - /// /// 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 last watched episode (any version). + /// 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(BaseItem? lastWatched, User user) + private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion( + IReadOnlyList