From 0874a26131a5a1e9d62fb7231acbaf9eb921b5c8 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 9 Jun 2026 23:23:03 +0200 Subject: Coalesce alternate-version progress onto primary in resume filter --- .../Library/VersionResumeData.cs | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 MediaBrowser.Controller/Library/VersionResumeData.cs (limited to 'MediaBrowser.Controller/Library/VersionResumeData.cs') diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs new file mode 100644 index 0000000000..ca8a499d97 --- /dev/null +++ b/MediaBrowser.Controller/Library/VersionResumeData.cs @@ -0,0 +1,30 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library +{ + /// + /// The user data of the alternate version that should drive resume for a multi-version item. + /// + /// The resume version's user data. + /// The resume version's runtime, used for the progress percentage. + public record VersionResumeData(UserItemData UserData, long? RunTimeTicks) + { + /// + /// Applies the resume version's playback state to the supplied user data dto, so that an item + /// whose most recent progress lives on an alternate version still reports that progress. + /// + /// The user data dto to update. + public void ApplyTo(UserItemDataDto dto) + { + dto.PlaybackPositionTicks = UserData.PlaybackPositionTicks; + dto.Played = UserData.Played; + dto.LastPlayedDate = UserData.LastPlayedDate; + + if (RunTimeTicks > 0 && UserData.PlaybackPositionTicks > 0) + { + dto.PlayedPercentage = 100.0 * UserData.PlaybackPositionTicks / RunTimeTicks.Value; + } + } + } +} -- cgit v1.2.3 From 0fb042b7403ebd7578b696aba35ba0c582ccf6ba Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 19 Jun 2026 21:51:57 +0200 Subject: Surface the played version for resume --- .../Library/UserDataManager.cs | 7 ++- .../Item/BaseItemRepository.TranslateQuery.cs | 59 ++++++++++++---------- .../Item/BaseItemRepository.cs | 8 +++ .../Library/VersionResumeData.cs | 20 ++++---- .../Library/VersionResumeDataTests.cs | 30 ++++++----- .../Item/AlternateVersionQueryTranslationTests.cs | 36 +++++++------ 6 files changed, 90 insertions(+), 70 deletions(-) (limited to 'MediaBrowser.Controller/Library/VersionResumeData.cs') diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index bedaacee33..61372f8b56 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -329,22 +329,21 @@ namespace Emby.Server.Implementations.Library foreach (var (primaryId, versions) in versionGroups) { - Video? resumeVersion = null; UserItemData? resumeData = null; foreach (var version in versions) { + // Consider both in-progress and completed versions so a finished alternate still marks the primary as played. if (userDataByVersion.TryGetValue(version.Id, out var data) - && data.PlaybackPositionTicks > 0 + && (data.PlaybackPositionTicks > 0 || data.Played) && (resumeData is null || (data.LastPlayedDate ?? DateTime.MinValue) > (resumeData.LastPlayedDate ?? DateTime.MinValue))) { - resumeVersion = version; resumeData = data; } } if (resumeData is not null) { - result[primaryId] = new VersionResumeData(resumeData, resumeVersion!.RunTimeTicks); + result[primaryId] = new VersionResumeData(resumeData); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 96d6d2eaff..c234f333af 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -515,15 +515,15 @@ public sealed partial class BaseItemRepository var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); var userId = filter.User!.Id; var isResumable = filter.IsResumable.Value; + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; // In-progress user data rows; alternate versions track their own progress. var inProgress = context.UserData .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + IQueryable? resumableSeriesIds = null; if (hasSeries) { - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - // Aggregate per series in a single GROUP BY pass, instead of three full scans. var seriesEpisodeStats = context.BaseItems .AsNoTracking() @@ -539,39 +539,41 @@ public sealed partial class BaseItemRepository // A series is resumable if it has an in-progress episode, // or if it has both played and unplayed episodes (partially watched). - var resumableSeriesIds = seriesEpisodeStats + resumableSeriesIds = seriesEpisodeStats .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) .Select(s => s.SeriesId); - - // Non-series items: resumable if the item or any of its alternate versions has - // PlaybackPositionTicks > 0. Alternate versions (PrimaryVersionId set) are excluded - // from the base query, so coalesce their progress onto the primary's id. - var resumableMovieIds = inProgress - .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); - - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable) - || (e.Type != seriesTypeName && resumableMovieIds.Contains(e.Id) == isResumable)); - } - else - { - // Resumable if the item or any of its alternate versions has PlaybackPositionTicks > 0. - // Alternate versions (PrimaryVersionId set) are excluded from the base query, so - // coalesce their progress onto the primary's id. - var resumableMovieIds = inProgress - .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); - baseQuery = baseQuery.Where(e => resumableMovieIds.Contains(e.Id) == isResumable); } if (isResumable) { - // Multi-version items surface as the version that was actually played. + // Resume queries surface the version that was actually played, which may be an alternate. + // Match each version on its own progress rather than coalescing onto the primary. + var inProgressIds = inProgress.Select(ud => ud.ItemId); + + baseQuery = hasSeries + ? baseQuery.Where(e => + (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id)) + || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id))) + : baseQuery.Where(e => inProgressIds.Contains(e.Id)); + // When several versions of the same item are in progress, keep only the most recently played one. - baseQuery = baseQuery.Where(e => !context.BaseItems + baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) .Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate))); } + else + { + // Not-resumable queries operate on primaries only. + var resumableMovieIds = inProgress + .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); + + baseQuery = hasSeries + ? baseQuery.Where(e => + (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id)) + || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id))) + : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id)); + } } if (filter.ArtistIds.Length > 0) @@ -758,10 +760,13 @@ public sealed partial class BaseItemRepository } else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems) { - // Exclude alternate versions and owned non-extra items from general queries. - // Alternate versions have PrimaryVersionId set (pointing to their primary). + // Exclude owned non-extra items from general queries. // Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those. - baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries + // keep them so the actually-played version can surface instead of collapsing onto the primary. + baseQuery = filter.IsResumable == true + ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null) + : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); } if (filter.OwnerIds.Length > 0) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 94dedaeba8..57041276b7 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -167,6 +167,14 @@ public sealed partial class BaseItemRepository return false; } + // Resume queries surface the actually-played version (which may be an alternate sharing the + // primary's presentation key). The resumable filter already keeps one version per group, so + // presentation-key grouping must not collapse the surfaced version back onto the primary. + if (query.IsResumable == true) + { + return false; + } + if (query.GroupBySeriesPresentationUniqueKey) { return false; diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs index ca8a499d97..455fe739ce 100644 --- a/MediaBrowser.Controller/Library/VersionResumeData.cs +++ b/MediaBrowser.Controller/Library/VersionResumeData.cs @@ -1,29 +1,29 @@ +using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; namespace MediaBrowser.Controller.Library { /// - /// The user data of the alternate version that should drive resume for a multi-version item. + /// The user data of the most recently played alternate version that should drive the completion state of a multi-version item. /// /// The resume version's user data. - /// The resume version's runtime, used for the progress percentage. - public record VersionResumeData(UserItemData UserData, long? RunTimeTicks) + public record VersionResumeData(UserItemData UserData) { /// - /// Applies the resume version's playback state to the supplied user data dto, so that an item - /// whose most recent progress lives on an alternate version still reports that progress. + /// Merges the most recently played version's completion state into the supplied user data dto. + /// Only completion (played) propagates to the primary; the in-progress resume position stays on + /// the version that owns it, which is surfaced directly (e.g. in resume queries) so that playback + /// always targets the correct version rather than resuming the primary at another version's offset. /// /// The user data dto to update. public void ApplyTo(UserItemDataDto dto) { - dto.PlaybackPositionTicks = UserData.PlaybackPositionTicks; - dto.Played = UserData.Played; - dto.LastPlayedDate = UserData.LastPlayedDate; + dto.Played = dto.Played || UserData.Played; - if (RunTimeTicks > 0 && UserData.PlaybackPositionTicks > 0) + if ((UserData.LastPlayedDate ?? DateTime.MinValue) > (dto.LastPlayedDate ?? DateTime.MinValue)) { - dto.PlayedPercentage = 100.0 * UserData.PlaybackPositionTicks / RunTimeTicks.Value; + dto.LastPlayedDate = UserData.LastPlayedDate; } } } diff --git a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs index 5b0f003019..8642ab07f8 100644 --- a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs +++ b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs @@ -9,36 +9,40 @@ namespace Jellyfin.Controller.Tests.Library; public class VersionResumeDataTests { [Fact] - public void ApplyTo_OverridesResumeFieldsAndPercentage() + public void ApplyTo_PropagatesCompletionButNotPosition() { var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); var resume = new VersionResumeData( - new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = true, LastPlayedDate = lastPlayed }, - RunTimeTicks: 100); + new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = true, LastPlayedDate = lastPlayed }); var dto = new UserItemDataDto { Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 1 }; resume.ApplyTo(dto); - Assert.Equal(25, dto.PlaybackPositionTicks); + // Completion state propagates to the primary... Assert.True(dto.Played); Assert.Equal(lastPlayed, dto.LastPlayedDate); - // The percentage is based on the resume version's own runtime, not the primary's. - Assert.NotNull(dto.PlayedPercentage); - Assert.Equal(25.0, dto.PlayedPercentage.Value, 5); + // ...but the in-progress resume position stays on the version that owns it. + Assert.Equal(1, dto.PlaybackPositionTicks); + Assert.Equal(1.0, dto.PlayedPercentage); } [Fact] - public void ApplyTo_WithoutRuntime_LeavesPercentageUntouched() + public void ApplyTo_DoesNotUnsetExistingPlayedOrRegressLastPlayed() { - var resume = new VersionResumeData(new UserItemData { Key = "version", PlaybackPositionTicks = 25 }, null); - var dto = new UserItemDataDto { Key = "primary", PlayedPercentage = 42 }; + var primaryLastPlayed = new DateTime(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc); + var versionLastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + var resume = new VersionResumeData( + new UserItemData { Key = "version", Played = false, LastPlayedDate = versionLastPlayed }); + + var dto = new UserItemDataDto { Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed }; resume.ApplyTo(dto); - Assert.Equal(25, dto.PlaybackPositionTicks); - Assert.NotNull(dto.PlayedPercentage); - Assert.Equal(42.0, dto.PlayedPercentage.Value, 5); + // A not-yet-completed version must not clear the primary's own completion, and the more recent + // LastPlayedDate is kept. + Assert.True(dto.Played); + Assert.Equal(primaryLastPlayed, dto.LastPlayedDate); } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs index 823ff566a7..1f0de153a0 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs @@ -37,39 +37,43 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable } [Fact] - public void ResumeFilter_VersionProgress_SurfacesPrimary() + public void ResumeFilter_VersionProgress_SurfacesPlayedVersion() { - Guid userId, primaryId, otherId; + Guid userId, primaryId, versionId, otherId; using (var ctx = CreateDbContext()) { - (userId, primaryId, otherId) = Seed(ctx); + (userId, primaryId, versionId, otherId) = Seed(ctx); } using (var ctx = CreateDbContext()) { - // Mirrors the resumable filter in BaseItemRepository.TranslateQuery: progress on any - // version coalesces onto the primary's id. var inProgress = ctx.UserData .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); - var resumableMovieIds = inProgress - .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); // Scope to the seeded items; EnsureCreated also seeds a placeholder row. - var seededIds = new[] { primaryId, otherId }; + var seededIds = new[] { primaryId, versionId, otherId }; + // Mirrors the resumable=true filter in BaseItemRepository.TranslateQuery. + var inProgressIds = inProgress.Select(ud => ud.ItemId); var resumable = ctx.BaseItems - .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null) - .Where(e => resumableMovieIds.Contains(e.Id)) + .Where(e => seededIds.Contains(e.Id)) + .Where(e => inProgressIds.Contains(e.Id)) + .Where(e => !ctx.BaseItems + .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate))) .Select(e => e.Id) .ToList(); - Assert.Equal([primaryId], resumable); + Assert.Equal([versionId], resumable); - // The inverse (not-resumable) direction must exclude the primary as well. + // The not-resumable direction keeps primaries only. + var resumableMovieIds = inProgress + .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); var notResumable = ctx.BaseItems .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null) - .Where(e => resumableMovieIds.Contains(e.Id) == false) + .Where(e => !resumableMovieIds.Contains(e.Id)) .Select(e => e.Id) .ToList(); @@ -84,7 +88,7 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable using (var ctx = CreateDbContext()) { - (userId, primaryId, otherId) = Seed(ctx); + (userId, primaryId, _, otherId) = Seed(ctx); } using (var ctx = CreateDbContext()) @@ -106,7 +110,7 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable } } - private static (Guid UserId, Guid PrimaryId, Guid OtherId) Seed(JellyfinDbContext ctx) + private static (Guid UserId, Guid PrimaryId, Guid VersionId, Guid OtherId) Seed(JellyfinDbContext ctx) { var user = new User("test", "auth-provider", "reset-provider"); ctx.Users.Add(user); @@ -129,7 +133,7 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable }); ctx.SaveChanges(); - return (user.Id, primary.Id, other.Id); + return (user.Id, primary.Id, version.Id, other.Id); } private JellyfinDbContext CreateDbContext() -- cgit v1.2.3 From 38f1d9749ee67f18264937807b2f5882e1421557 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 2 Jul 2026 08:49:11 +0200 Subject: Fix review comments --- Emby.Server.Implementations/Dto/DtoService.cs | 5 +- .../Library/MediaSourceManager.cs | 25 +++---- .../Library/UserDataManager.cs | 20 ++--- .../Session/SessionManager.cs | 11 ++- Emby.Server.Implementations/TV/TVSeriesManager.cs | 20 ++--- Jellyfin.Api/Controllers/ItemsController.cs | 8 +- .../Item/BaseItemRepository.TranslateQuery.cs | 10 ++- .../Item/OrderMapper.cs | 4 + .../Library/VersionPlaybackSelector.cs | 59 +++++++++++++++ .../Library/VersionResumeData.cs | 19 ++++- .../Library/VersionResumeDataTests.cs | 53 ++++++++++++-- .../Item/AlternateVersionQueryTranslationTests.cs | 85 +++++++++++++++++++++- 12 files changed, 259 insertions(+), 60 deletions(-) create mode 100644 MediaBrowser.Controller/Library/VersionPlaybackSelector.cs (limited to 'MediaBrowser.Controller/Library/VersionResumeData.cs') diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 2bf478953e..9881565cd2 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1272,8 +1272,9 @@ namespace Emby.Server.Implementations.Dto // Match the per-user filtering of the media sources: versions the user cannot // access are not selectable, so they must not count towards the badge either. var mediaSourceCount = user is null - ? video.MediaSourceCount - : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user)); + || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions) + ? video.MediaSourceCount + : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user)); if (mediaSourceCount != 1) { dto.MediaSourceCount = mediaSourceCount; diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index c0e45ab6c7..c64833ddaa 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -451,26 +451,23 @@ namespace Emby.Server.Implementations.Library } } - MediaSourceInfo resumeSource = null; - UserItemData resumeData = null; foreach (var source in sources) { - if (source.Id is null - || !dataBySourceId.TryGetValue(source.Id, out var data) - || data.PlaybackPositionTicks <= 0) + if (source.Id is not null + && dataBySourceId.TryGetValue(source.Id, out var data) + && data.PlaybackPositionTicks > 0) { - continue; - } - - source.PlaybackPositionTicks = data.PlaybackPositionTicks; - - if (resumeData is null || (data.LastPlayedDate ?? DateTime.MinValue) > (resumeData.LastPlayedDate ?? DateTime.MinValue)) - { - resumeSource = source; - resumeData = data; + source.PlaybackPositionTicks = data.PlaybackPositionTicks; } } + // Reorder only for a resumable (in-progress) version; + // a completed version has no position to resume, so it must not be pulled to the front here. + var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed( + sources, + source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null, + data => data.PlaybackPositionTicks > 0); + if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource)) { var reordered = new List(sources.Count) { resumeSource }; diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 61372f8b56..40cd2bb69c 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -329,21 +329,15 @@ namespace Emby.Server.Implementations.Library foreach (var (primaryId, versions) in versionGroups) { - UserItemData? resumeData = null; - foreach (var version in versions) - { - // Consider both in-progress and completed versions so a finished alternate still marks the primary as played. - if (userDataByVersion.TryGetValue(version.Id, out var data) - && (data.PlaybackPositionTicks > 0 || data.Played) - && (resumeData is null || (data.LastPlayedDate ?? DateTime.MinValue) > (resumeData.LastPlayedDate ?? DateTime.MinValue))) - { - resumeData = data; - } - } + // Consider both in-progress and completed versions so a finished alternate still marks the primary as played. + var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed( + versions, + version => userDataByVersion.GetValueOrDefault(version.Id), + data => data.PlaybackPositionTicks > 0 || data.Played); - if (resumeData is not null) + if (resumeVersion is not null) { - result[primaryId] = new VersionResumeData(resumeData); + result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]); } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 6017b7cbf6..f652634c69 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -980,14 +980,17 @@ namespace Emby.Server.Implementations.Session { _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None); + // A completed version marks every alternate version played and clears their resume points, so the + // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions + // only persist while nothing has been completed yet.) if (data.Played == true && item is Video playedVideo) { playedVideo.PropagatePlayedState(user, true); } } - if ((!user.RememberAudioSelections && data.AudioStreamIndex.HasValue) - || (!user.RememberSubtitleSelections && data.SubtitleStreamIndex.HasValue)) + if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue) + || (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue)) { _userDataManager.ResetPlaybackStreamSelections(user, item); } @@ -1177,7 +1180,9 @@ namespace Emby.Server.Implementations.Session _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None); - // A completed version marks all of its alternate versions played; positions stay per-version. + // A completed version marks every alternate version played and clears their resume points, so the + // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions + // only persist while nothing has been completed yet.) if (data.Played == true && item is Video playedVideo) { playedVideo.PropagatePlayedState(user, true); diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 9a402a5738..459ad1a17e 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -262,19 +262,15 @@ namespace Emby.Server.Implementations.TV return (null, null); } - Video? playedVersion = null; - DateTime? lastPlayedDate = null; - foreach (var version in lastWatchedVideo.GetAllVersions()) - { - var data = _userDataManager.GetUserData(user, version); - if (data?.LastPlayedDate is { } date && (lastPlayedDate is null || date > lastPlayedDate)) - { - lastPlayedDate = date; - playedVersion = version; - } - } + var versions = lastWatchedVideo.GetAllVersions(); + var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user); + + var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed( + versions, + version => userDataByVersion.GetValueOrDefault(version.Id), + data => data.LastPlayedDate.HasValue); - return (playedVersion, lastPlayedDate); + return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate); } /// diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 5f23f2fcee..0c6477cd5b 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -963,9 +963,15 @@ public class ItemsController : BaseJellyfinApiController var excludeItemIds = Array.Empty(); if (excludeActiveSessions) { + // NowPlayingItem.Id is the displayed/primary id, but resume queries surface the actually-played + // alternate version's own id. Expand each active session to every version id so an in-progress + // alternate is excluded too, instead of leaking back into the resume list. excludeItemIds = _sessionManager.Sessions .Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null) - .Select(s => s.NowPlayingItem.Id) + .SelectMany(s => _libraryManager.GetItemById(s.NowPlayingItem.Id) is Video video + ? video.GetAllVersions().Select(v => v.Id) + : [s.NowPlayingItem.Id]) + .Distinct() .ToArray(); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index c234f333af..ed5c353139 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -556,11 +556,15 @@ public sealed partial class BaseItemRepository || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id))) : baseQuery.Where(e => inProgressIds.Contains(e.Id)); - // When several versions of the same item are in progress, keep only the most recently played one. + // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) - .Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) - > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate))); + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))); } else { diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index eeeeda8193..aac85d0131 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -38,6 +38,10 @@ public static class OrderMapper jellyfinDbContext.UserData .Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)) .Max(f => f.LastPlayedDate), + (ItemSortBy.DatePlayed, null) => e => + jellyfinDbContext.UserData + .Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id) + .Max(f => f.LastPlayedDate), (ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount, (ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false, (ItemSortBy.IsFolder, _) => e => e.IsFolder, diff --git a/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs new file mode 100644 index 0000000000..1766c50141 --- /dev/null +++ b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library +{ + /// + /// Single definition of "which alternate version was most recently played" shared by the resume tile + /// (), the media-source default ordering and Next Up. + /// Each call site declares its own eligibility rule so the intentional differences (resumable-only vs. + /// resumable-or-completed) are visible in one place instead of being re-implemented divergently. + /// The SQL resume query keeps its own translation of the same rule. + /// + public static class VersionPlaybackSelector + { + /// + /// Selects the entry whose user data has the greatest , + /// considering only entries that satisfy . On an exact tie the first + /// encountered entry wins. + /// + /// The candidate type (e.g. a version item or a media source). + /// The candidates to choose from. + /// Resolves the user data for a candidate, or null when it has none. + /// Whether a candidate's user data makes it a valid winner. + /// The most recently played eligible candidate, or default when none qualify. + public static T? SelectMostRecentlyPlayed( + IEnumerable items, + Func dataSelector, + Func isEligible) + { + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(dataSelector); + ArgumentNullException.ThrowIfNull(isEligible); + + T? winner = default; + var winnerDate = DateTime.MinValue; + var hasWinner = false; + + foreach (var item in items) + { + var data = dataSelector(item); + if (data is null || !isEligible(data)) + { + continue; + } + + var date = data.LastPlayedDate ?? DateTime.MinValue; + if (!hasWinner || date > winnerDate) + { + winner = item; + winnerDate = date; + hasWinner = true; + } + } + + return winner; + } + } +} diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs index 455fe739ce..772e2bf3a7 100644 --- a/MediaBrowser.Controller/Library/VersionResumeData.cs +++ b/MediaBrowser.Controller/Library/VersionResumeData.cs @@ -7,14 +7,17 @@ namespace MediaBrowser.Controller.Library /// /// The user data of the most recently played alternate version that should drive the completion state of a multi-version item. /// + /// The id of the version that owns . /// The resume version's user data. - public record VersionResumeData(UserItemData UserData) + public record VersionResumeData(Guid VersionId, UserItemData UserData) { /// /// Merges the most recently played version's completion state into the supplied user data dto. - /// Only completion (played) propagates to the primary; the in-progress resume position stays on - /// the version that owns it, which is surfaced directly (e.g. in resume queries) so that playback - /// always targets the correct version rather than resuming the primary at another version's offset. + /// Completion (played) propagates to the primary. An in-progress resume position stays on the version + /// that owns it, which is surfaced directly (e.g. in resume queries) so that playback always targets + /// the correct version rather than resuming the primary at another version's offset. When the movie was + /// finished on a different version, the primary's own stale resume position is cleared so it does not + /// render as "watched and resumable" at the same time. /// /// The user data dto to update. public void ApplyTo(UserItemDataDto dto) @@ -25,6 +28,14 @@ namespace MediaBrowser.Controller.Library { dto.LastPlayedDate = UserData.LastPlayedDate; } + + // A different version was finished (played, no resume position of its own) and is the most + // recently played: the whole movie is watched. + if (!VersionId.Equals(dto.ItemId) && UserData.Played && UserData.PlaybackPositionTicks <= 0) + { + dto.PlaybackPositionTicks = 0; + dto.PlayedPercentage = null; + } } } } diff --git a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs index 8642ab07f8..7d87d5ee92 100644 --- a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs +++ b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs @@ -9,13 +9,14 @@ namespace Jellyfin.Controller.Tests.Library; public class VersionResumeDataTests { [Fact] - public void ApplyTo_PropagatesCompletionButNotPosition() + public void ApplyTo_CompletedOtherVersion_PropagatesCompletionAndClearsStaleResume() { var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); var resume = new VersionResumeData( - new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = true, LastPlayedDate = lastPlayed }); + Guid.NewGuid(), + new UserItemData { Key = "version", PlaybackPositionTicks = 0, Played = true, LastPlayedDate = lastPlayed }); - var dto = new UserItemDataDto { Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 1 }; + var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 }; resume.ApplyTo(dto); @@ -23,9 +24,48 @@ public class VersionResumeDataTests Assert.True(dto.Played); Assert.Equal(lastPlayed, dto.LastPlayedDate); - // ...but the in-progress resume position stays on the version that owns it. + // ...and because the movie was finished on a different version, the primary's own stale resume bar is cleared. + Assert.Equal(0, dto.PlaybackPositionTicks); + Assert.Null(dto.PlayedPercentage); + } + + [Fact] + public void ApplyTo_PrimaryOwnProgress_KeepsResumePosition() + { + var primaryId = Guid.NewGuid(); + var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + + // The winning version is the primary itself (e.g. rewatching): its resume bar must survive. + var resume = new VersionResumeData( + primaryId, + new UserItemData { Key = "primary", PlaybackPositionTicks = 5, Played = true, LastPlayedDate = lastPlayed }); + + var dto = new UserItemDataDto { ItemId = primaryId, Key = "primary", PlaybackPositionTicks = 5, Played = true, PlayedPercentage = 20 }; + + resume.ApplyTo(dto); + + Assert.True(dto.Played); + Assert.Equal(5, dto.PlaybackPositionTicks); + Assert.Equal(20, dto.PlayedPercentage); + } + + [Fact] + public void ApplyTo_InProgressOtherVersion_KeepsPrimaryResumePosition() + { + var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + + // A different version that is in-progress (not finished) must not clear the primary's position. + var resume = new VersionResumeData( + Guid.NewGuid(), + new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = false, LastPlayedDate = lastPlayed }); + + var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 }; + + resume.ApplyTo(dto); + + Assert.False(dto.Played); Assert.Equal(1, dto.PlaybackPositionTicks); - Assert.Equal(1.0, dto.PlayedPercentage); + Assert.Equal(50, dto.PlayedPercentage); } [Fact] @@ -34,9 +74,10 @@ public class VersionResumeDataTests var primaryLastPlayed = new DateTime(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc); var versionLastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); var resume = new VersionResumeData( + Guid.NewGuid(), new UserItemData { Key = "version", Played = false, LastPlayedDate = versionLastPlayed }); - var dto = new UserItemDataDto { Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed }; + var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed }; resume.ApplyTo(dto); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs index 1f0de153a0..c8aa14af58 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs @@ -61,8 +61,12 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable .Where(e => inProgressIds.Contains(e.Id)) .Where(e => !ctx.BaseItems .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) - .Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) - > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate))) + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))) .Select(e => e.Id) .ToList(); @@ -81,6 +85,46 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable } } + [Fact] + public void ResumeFilter_TiedLastPlayedDate_KeepsSingleVersion() + { + Guid userId, primaryId, versionAId, versionBId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, versionAId, versionBId) = SeedTiedVersions(ctx); + } + + using (var ctx = CreateDbContext()) + { + var inProgress = ctx.UserData + .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + + var seededIds = new[] { primaryId, versionAId, versionBId }; + var inProgressIds = inProgress.Select(ud => ud.ItemId); + + // The exact production dedup, including the Guid.CompareTo tie-break. This asserts the + // expression translates on SQLite and that two versions sharing an identical LastPlayedDate + // collapse to a single row instead of double-listing the item in Continue Watching. + var resumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id)) + .Where(e => inProgressIds.Contains(e.Id)) + .Where(e => !ctx.BaseItems + .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))) + .Select(e => e.Id) + .ToList(); + + var survivor = Assert.Single(resumable); + Assert.Contains(survivor, new[] { versionAId, versionBId }); + } + } + [Fact] public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate() { @@ -136,6 +180,43 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable return (user.Id, primary.Id, version.Id, other.Id); } + private static (Guid UserId, Guid PrimaryId, Guid VersionAId, Guid VersionBId) SeedTiedVersions(JellyfinDbContext ctx) + { + var user = new User("test", "auth-provider", "reset-provider"); + ctx.Users.Add(user); + + var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + var versionA = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + var versionB = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + ctx.BaseItems.AddRange(primary, versionA, versionB); + + // Both versions in progress with the exact same LastPlayedDate - the tie that a strict '>' cannot break. + var tied = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + ctx.UserData.Add(new UserData + { + ItemId = versionA.Id, + Item = versionA, + UserId = user.Id, + User = user, + CustomDataKey = versionA.Id.ToString("N"), + PlaybackPositionTicks = 1000, + LastPlayedDate = tied + }); + ctx.UserData.Add(new UserData + { + ItemId = versionB.Id, + Item = versionB, + UserId = user.Id, + User = user, + CustomDataKey = versionB.Id.ToString("N"), + PlaybackPositionTicks = 2000, + LastPlayedDate = tied + }); + + ctx.SaveChanges(); + return (user.Id, primary.Id, versionA.Id, versionB.Id); + } + private JellyfinDbContext CreateDbContext() { return new JellyfinDbContext( -- cgit v1.2.3