From f4d4fe42aeb5348bf61ee1fb132a561860c64821 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 2 Jun 2026 11:10:50 +0200 Subject: [PATCH] Fix unplayed propagation --- MediaBrowser.Controller/Entities/BaseItem.cs | 15 ++++++-- MediaBrowser.Controller/Entities/Video.cs | 32 ++++++++++++----- .../Entities/BaseItemTests.cs | 36 +++++++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 40c5d20162..2e00e6d372 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -2112,12 +2112,23 @@ namespace MediaBrowser.Controller.Entities // I think it is okay to do this here. // if this is only called when a user is manually forcing something to un-played // then it probably is what we want to do... + ResetPlayedState(data); + + UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); + } + + /// + /// Clears the played state on the supplied user data. + /// + /// The user data to reset. + protected static void ResetPlayedState(UserItemData data) + { + ArgumentNullException.ThrowIfNull(data); + data.PlayCount = 0; data.PlaybackPositionTicks = 0; data.LastPlayedDate = null; data.Played = false; - - UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); } /// diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 58643cf896..0c3ed11535 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -364,9 +364,9 @@ namespace MediaBrowser.Controller.Entities /// /// The user. /// The played status to apply to the alternate versions. - /// When true, the playback position of each version is also - /// reset, keeping the versions consistent with a deliberate played/unplayed toggle. When - /// false, only the played flag changes and each version keeps its own resume point. + /// When marking played, controls whether each version's resume point + /// is also reset (true) or left untouched (false). Ignored when marking unplayed, + /// which always fully resets every version. public void PropagatePlayedState(User user, bool played, bool resetPosition = true) { ArgumentNullException.ThrowIfNull(user); @@ -383,14 +383,28 @@ namespace MediaBrowser.Controller.Entities continue; } - var dto = new UpdateUserItemDataDto { Played = played }; - if (resetPosition) + if (played) { - dto.PlaybackPositionTicks = 0; - } + var dto = new UpdateUserItemDataDto { Played = true }; + if (resetPosition) + { + dto.PlaybackPositionTicks = 0; + } - // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved. - UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed); + // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved. + UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed); + } + else + { + var data = UserDataManager.GetUserData(user, item); + if (data is null) + { + continue; + } + + ResetPlayedState(data); + UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); + } } } diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index 0eda961d9d..2ee95f076b 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Threading; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -168,6 +169,41 @@ public class BaseItemTests }); } + [Fact] + public void PropagatePlayedState_Unwatched_ClearsAllWatchedStateOnVersions() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + // Each alternate starts out watched, with a play count, resume point and last-played date. + var existing = new Dictionary + { + [alt1.Id] = new UserItemData { Key = "alt1", Played = true, PlayCount = 3, PlaybackPositionTicks = 1000, LastPlayedDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, + [alt2.Id] = new UserItemData { Key = "alt2", Played = true, PlayCount = 1, PlaybackPositionTicks = 500, LastPlayedDate = new DateTime(2021, 2, 2, 0, 0, 0, DateTimeKind.Utc) }, + }; + + var saved = new List(); + var userDataManager = new Mock(); + userDataManager.Setup(x => x.GetUserData(It.IsAny(), It.IsAny())) + .Returns((User _, BaseItem item) => existing.GetValueOrDefault(item.Id)); + userDataManager + .Setup(x => x.SaveUserData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, data, _, _) => saved.Add(data)); + BaseItem.UserDataManager = userDataManager.Object; + + primary.PropagatePlayedState(new User("test", "default", "default"), false); + + // Every alternate is fully reset to an unwatched state, mirroring MarkUnplayed: the played flag, + // play count, resume point and last-played date are all cleared so no watched state lingers. + Assert.Equal(2, saved.Count); + Assert.All(saved, d => + { + Assert.False(d.Played); + Assert.Equal(0, d.PlayCount); + Assert.Equal(0, d.PlaybackPositionTicks); + Assert.Null(d.LastPlayedDate); + }); + } + private static List<(Guid ItemId, UpdateUserItemDataDto Dto)> CaptureSaves() { var saved = new List<(Guid ItemId, UpdateUserItemDataDto Dto)>();