< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.TV.TVSeriesManager
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/TV/TVSeriesManager.cs
Line coverage
3%
Covered lines: 4
Uncovered lines: 129
Coverable lines: 133
Total lines: 350
Line coverage: 3%
Branch coverage
0%
Covered branches: 0
Total branches: 98
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 3/26/2026 - 12:14:14 AM Line coverage: 4.8% (4/82) Branch coverage: 0% (0/36) Total lines: 2805/4/2026 - 12:15:16 AM Line coverage: 3.8% (4/104) Branch coverage: 0% (0/78) Total lines: 2727/6/2026 - 12:16:28 AM Line coverage: 3% (4/133) Branch coverage: 0% (0/98) Total lines: 350 3/26/2026 - 12:14:14 AM Line coverage: 4.8% (4/82) Branch coverage: 0% (0/36) Total lines: 2805/4/2026 - 12:15:16 AM Line coverage: 3.8% (4/104) Branch coverage: 0% (0/78) Total lines: 2727/6/2026 - 12:16:28 AM Line coverage: 3% (4/133) Branch coverage: 0% (0/98) Total lines: 350

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetNextUp(...)0%110100%
GetNextUp(...)0%7280%
GetNextUpBatched(...)0%420200%
DetermineNextEpisode(...)0%930300%
DetermineNextEpisodeForRewatching(...)100%210%
GetMostRecentlyPlayedVersion(...)0%2040%
GetPreferredVersion(...)0%342180%
GetUniqueSeriesKey(...)100%210%
GetResult(...)0%7280%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/TV/TVSeriesManager.cs

#LineLine coverage
 1#pragma warning disable CS1591
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Globalization;
 6using System.Linq;
 7using Jellyfin.Data;
 8using Jellyfin.Data.Enums;
 9using Jellyfin.Database.Implementations.Entities;
 10using Jellyfin.Database.Implementations.Enums;
 11using Jellyfin.Extensions;
 12using MediaBrowser.Controller.Configuration;
 13using MediaBrowser.Controller.Dto;
 14using MediaBrowser.Controller.Entities;
 15using MediaBrowser.Controller.Library;
 16using MediaBrowser.Controller.TV;
 17using MediaBrowser.Model.Querying;
 18using Episode = MediaBrowser.Controller.Entities.TV.Episode;
 19using Series = MediaBrowser.Controller.Entities.TV.Series;
 20
 21namespace Emby.Server.Implementations.TV
 22{
 23    public class TVSeriesManager : ITVSeriesManager
 24    {
 25        private readonly IUserDataManager _userDataManager;
 26        private readonly ILibraryManager _libraryManager;
 27        private readonly IServerConfigurationManager _configurationManager;
 28
 29        public TVSeriesManager(IUserDataManager userDataManager, ILibraryManager libraryManager, IServerConfigurationMan
 30        {
 2231            _userDataManager = userDataManager;
 2232            _libraryManager = libraryManager;
 2233            _configurationManager = configurationManager;
 2234        }
 35
 36        public QueryResult<BaseItem> GetNextUp(NextUpQuery query, DtoOptions options)
 37        {
 038            var user = query.User;
 39
 040            string? presentationUniqueKey = null;
 041            if (!query.SeriesId.IsNullOrEmpty())
 42            {
 043                if (_libraryManager.GetItemById(query.SeriesId.Value) is Series series)
 44                {
 045                    presentationUniqueKey = GetUniqueSeriesKey(series);
 46                }
 47            }
 48
 049            if (!string.IsNullOrEmpty(presentationUniqueKey))
 50            {
 051                return GetNextUpBatched(query, user, [presentationUniqueKey], options);
 52            }
 53
 54            BaseItem[] parents;
 55
 056            if (query.ParentId.HasValue)
 57            {
 058                var parent = _libraryManager.GetItemById(query.ParentId.Value);
 59
 060                if (parent is not null)
 61                {
 062                    parents = [parent];
 63                }
 64                else
 65                {
 066                    parents = [];
 67                }
 68            }
 69            else
 70            {
 071                parents = _libraryManager.GetUserRootFolder().GetChildren(user, true)
 072                   .Where(i => i is Folder)
 073                   .Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes).Contains(i.Id))
 074                   .ToArray();
 75            }
 76
 077            return GetNextUp(query, parents, options);
 78        }
 79
 80        public QueryResult<BaseItem> GetNextUp(NextUpQuery request, BaseItem[] parentsFolders, DtoOptions options)
 81        {
 082            var user = request.User;
 83
 084            string? presentationUniqueKey = null;
 085            int? limit = null;
 086            if (!request.SeriesId.IsNullOrEmpty())
 87            {
 088                if (_libraryManager.GetItemById(request.SeriesId.Value) is Series series)
 89                {
 090                    presentationUniqueKey = GetUniqueSeriesKey(series);
 091                    limit = 1;
 92                }
 93            }
 94
 095            if (!string.IsNullOrEmpty(presentationUniqueKey))
 96            {
 097                return GetNextUpBatched(request, user, [presentationUniqueKey], options);
 98            }
 99
 0100            if (limit.HasValue)
 101            {
 0102                limit = limit.Value + 10;
 103            }
 104
 0105            var nextUpSeriesKeys = _libraryManager.GetNextUpSeriesKeys(new InternalItemsQuery(user) { Limit = limit }, p
 106
 0107            return GetNextUpBatched(request, user, nextUpSeriesKeys, options);
 108        }
 109
 110        private QueryResult<BaseItem> GetNextUpBatched(NextUpQuery request, User user, IReadOnlyList<string> seriesKeys,
 111        {
 0112            if (seriesKeys.Count == 0)
 113            {
 0114                return new QueryResult<BaseItem>();
 115            }
 116
 0117            var includeSpecials = _configurationManager.Configuration.DisplaySpecialsWithinSeasons;
 0118            var includeRewatching = request.EnableRewatching;
 119
 0120            var query = new InternalItemsQuery(user)
 0121            {
 0122                DtoOptions = dtoOptions
 0123            };
 124
 0125            var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatchi
 126
 0127            var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>();
 128
 0129            foreach (var seriesKey in seriesKeys)
 130            {
 0131                if (!batchResult.TryGetValue(seriesKey, out var result))
 132                {
 133                    continue;
 134                }
 135
 0136                var nextEpisode = DetermineNextEpisode(result, user, includeSpecials, request.EnableResumable, false);
 137
 0138                if (nextEpisode is not null)
 139                {
 140                    // The last played date and the version that was actually played live on the version item's user dat
 141                    // The played state propagated to the sibling versions carries no date
 0142                    var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user);
 0143                    nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion);
 144
 0145                    DateTime lastWatchedDate = DateTime.MinValue;
 0146                    if (result.LastWatched is not null)
 147                    {
 0148                        lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
 149                    }
 150
 0151                    nextUpList.Add((lastWatchedDate, nextEpisode));
 152                }
 153
 0154                if (includeRewatching)
 155                {
 0156                    var nextPlayedEpisode = DetermineNextEpisodeForRewatching(result, user, includeSpecials);
 157
 0158                    if (nextPlayedEpisode is not null)
 159                    {
 0160                        var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatchi
 0161                        nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, play
 162
 0163                        DateTime rewatchLastWatchedDate = DateTime.MinValue;
 0164                        if (result.LastWatchedForRewatching is not null)
 165                        {
 0166                            rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
 167                        }
 168
 0169                        nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
 170                    }
 171                }
 172            }
 173
 0174            var sortedEpisodes = nextUpList
 0175                .OrderByDescending(x => x.LastWatchedDate)
 0176                .Select(x => (BaseItem)x.Episode);
 177
 0178            return GetResult(sortedEpisodes, request);
 179        }
 180
 181        private Episode? DetermineNextEpisode(
 182            MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
 183            User user,
 184            bool includeSpecials,
 185            bool includeResumable,
 186            bool includePlayed)
 187        {
 0188            var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode;
 0189            var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode;
 190
 0191            if (includeSpecials && result.Specials?.Count > 0)
 192            {
 0193                var consideredEpisodes = result.Specials
 0194                    .Cast<Episode>()
 0195                    .Where(episode => episode.AirsBeforeSeasonNumber is not null || episode.AirsAfterSeasonNumber is not
 0196                    .ToList();
 197
 0198                if (lastWatchedEpisode is not null)
 199                {
 0200                    consideredEpisodes.Add(lastWatchedEpisode);
 201                }
 202
 0203                if (nextEpisode is not null)
 204                {
 0205                    consideredEpisodes.Add(nextEpisode);
 206                }
 207
 0208                if (consideredEpisodes.Count > 0)
 209                {
 0210                    var sortedEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, 
 0211                        .Cast<Episode>();
 212
 0213                    if (lastWatchedEpisode is not null)
 214                    {
 0215                        sortedEpisodes = sortedEpisodes.SkipWhile(episode => !episode.Id.Equals(lastWatchedEpisode.Id)).
 216                    }
 217
 0218                    if (!includePlayed)
 219                    {
 0220                        sortedEpisodes = sortedEpisodes.Where(episode => _userDataManager.GetUserData(user, episode) is 
 221                    }
 222
 0223                    nextEpisode = sortedEpisodes.FirstOrDefault();
 224                }
 225            }
 226
 0227            if (nextEpisode is not null && !includeResumable)
 228            {
 229                // The resume progress may live on an alternate version
 0230                foreach (var version in nextEpisode.GetAllVersions())
 231                {
 0232                    if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0)
 233                    {
 0234                        return null;
 235                    }
 236                }
 237            }
 238
 0239            return nextEpisode;
 0240        }
 241
 242        private Episode? DetermineNextEpisodeForRewatching(
 243            MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
 244            User user,
 245            bool includeSpecials)
 246        {
 0247            return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
 248        }
 249
 250        /// <summary>
 251        /// Gets the version of the last watched episode that was actually played, together with its last played date.
 252        /// The version that was played carries the most recent LastPlayedDate.
 253        /// dates.
 254        /// </summary>
 255        /// <param name="lastWatched">The last watched episode (any version).</param>
 256        /// <param name="user">The user.</param>
 257        /// <returns>The played version and its last played date.</returns>
 258        private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, Use
 259        {
 0260            if (lastWatched is not Video lastWatchedVideo)
 261            {
 0262                return (null, null);
 263            }
 264
 0265            var versions = lastWatchedVideo.GetAllVersions();
 0266            var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
 267
 0268            var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
 0269                versions,
 0270                version => userDataByVersion.GetValueOrDefault(version.Id),
 0271                data => data.LastPlayedDate.HasValue);
 272
 0273            return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
 274        }
 275
 276        /// <summary>
 277        /// When the last watched episode was played as an alternate version, prefer the next episode's version with the
 278        /// so Next Up continues in the version the user has been watching instead of falling back to the primary.
 279        /// </summary>
 280        /// <param name="nextEpisode">The determined next episode (a primary).</param>
 281        /// <param name="lastWatched">The last watched episode.</param>
 282        /// <param name="playedVersion">The version of the last watched episode that was played.</param>
 283        /// <returns>The matching version of the next episode, or the episode itself.</returns>
 284        private Episode GetPreferredVersion(Episode nextEpisode, BaseItem? lastWatched, Video? playedVersion)
 285        {
 286            // No version preference, or the primary was played
 0287            if (lastWatched is not Video lastWatchedVideo
 0288                || playedVersion is null
 0289                || !playedVersion.PrimaryVersionId.HasValue)
 290            {
 0291                return nextEpisode;
 292            }
 293
 294            // Match by version name
 0295            var playedVersionId = playedVersion.Id.ToString("N", CultureInfo.InvariantCulture);
 0296            var playedVersionName = lastWatchedVideo.GetMediaSources(false)
 0297                .FirstOrDefault(source => string.Equals(source.Id, playedVersionId, StringComparison.OrdinalIgnoreCase))
 298
 0299            if (string.IsNullOrEmpty(playedVersionName))
 300            {
 0301                return nextEpisode;
 302            }
 303
 0304            var matchingSource = nextEpisode.GetMediaSources(false)
 0305                .FirstOrDefault(source => string.Equals(source.Name, playedVersionName, StringComparison.OrdinalIgnoreCa
 306
 0307            if (matchingSource is not null
 0308                && Guid.TryParse(matchingSource.Id, out var matchingId)
 0309                && !matchingId.Equals(nextEpisode.Id)
 0310                && _libraryManager.GetItemById<Episode>(matchingId) is { } matchingVersion)
 311            {
 0312                return matchingVersion;
 313            }
 314
 0315            return nextEpisode;
 316        }
 317
 318        private static string GetUniqueSeriesKey(Series series)
 319        {
 0320            return series.GetPresentationUniqueKey();
 321        }
 322
 323        private static QueryResult<BaseItem> GetResult(IEnumerable<BaseItem> items, NextUpQuery query)
 324        {
 0325            int totalCount = 0;
 326
 0327            if (query.EnableTotalRecordCount)
 328            {
 0329                var list = items.ToList();
 0330                totalCount = list.Count;
 0331                items = list;
 332            }
 333
 0334            if (query.StartIndex.HasValue)
 335            {
 0336                items = items.Skip(query.StartIndex.Value);
 337            }
 338
 0339            if (query.Limit.HasValue && query.Limit.Value > 0)
 340            {
 0341                items = items.Take(query.Limit.Value);
 342            }
 343
 0344            return new QueryResult<BaseItem>(
 0345                query.StartIndex,
 0346                totalCount,
 0347                items.ToArray());
 348        }
 349    }
 350}