Fix unplayed propagation

This commit is contained in:
Shadowghost
2026-06-02 11:10:50 +02:00
parent 5db84fee1a
commit f4d4fe42ae
3 changed files with 72 additions and 11 deletions

View File

@@ -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);
}
/// <summary>
/// Clears the played state on the supplied user data.
/// </summary>
/// <param name="data">The user data to reset.</param>
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);
}
/// <summary>

View File

@@ -364,9 +364,9 @@ namespace MediaBrowser.Controller.Entities
/// </summary>
/// <param name="user">The user.</param>
/// <param name="played">The played status to apply to the alternate versions.</param>
/// <param name="resetPosition">When <c>true</c>, the playback position of each version is also
/// reset, keeping the versions consistent with a deliberate played/unplayed toggle. When
/// <c>false</c>, only the played flag changes and each version keeps its own resume point.</param>
/// <param name="resetPosition">When marking played, controls whether each version's resume point
/// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed,
/// which always fully resets every version.</param>
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);
}
}
}

View File

@@ -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<Guid, UserItemData>
{
[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<UserItemData>();
var userDataManager = new Mock<IUserDataManager>();
userDataManager.Setup(x => x.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
.Returns((User _, BaseItem item) => existing.GetValueOrDefault(item.Id));
userDataManager
.Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), It.IsAny<UserDataSaveReason>(), It.IsAny<CancellationToken>()))
.Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>((_, _, 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)>();