Fix watched-state sync so members' Next Up and Continue Watching follow
Synced rows only ever had the Played flag set. Jellyfin computes Next Up from LastPlayedDate on the member's own row and Continue Watching from the resume position, so a member watching through the shared account got the tick on each episode but their Next Up never advanced. Members are now written the way BaseItem.MarkPlayed/MarkUnplayed write: date and position included. Rows the old version ticked without a date are repaired the next time the item syncs. PlaybackFinished was also treated as an unwatched toggle. Jellyfin raises it on every stop, not just completion (and on 10.11 PlaybackStart resets Played to false first), so a stop halfway through on the shared account cleared members' own watched state whenever Sync unwatched was on. Playback-derived reasons (PlaybackFinished, PlaybackProgress, UpdateUserData) now only ever mirror "watched"; TogglePlayed and Import remain explicit and mirror either way. PlaybackProgress is acted on so the tick lands as soon as the completion threshold is crossed, including for clients that never report a stop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,7 @@ public class WatchedStateSyncTests
|
||||
|
||||
public Mock<IGroupService> Groups { get; } = new();
|
||||
|
||||
public List<(User Member, bool Played, int PlayCount)> Saves { get; } = new();
|
||||
public List<Save> Saves { get; } = new();
|
||||
|
||||
public WatchedStateSyncService Service { get; private set; } = null!;
|
||||
|
||||
@@ -34,7 +34,9 @@ public class WatchedStateSyncTests
|
||||
SharedGroup? group,
|
||||
IReadOnlyList<User> members,
|
||||
bool memberAlreadyPlayed = false,
|
||||
int memberPlayCount = 0)
|
||||
int memberPlayCount = 0,
|
||||
DateTime? memberLastPlayedDate = null,
|
||||
long memberPositionTicks = 0)
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
@@ -46,7 +48,9 @@ public class WatchedStateSyncTests
|
||||
{
|
||||
Key = "k",
|
||||
Played = memberAlreadyPlayed,
|
||||
PlayCount = memberPlayCount
|
||||
PlayCount = memberPlayCount,
|
||||
LastPlayedDate = memberLastPlayedDate,
|
||||
PlaybackPositionTicks = memberPositionTicks
|
||||
});
|
||||
|
||||
h.UserData.Setup(m => m.SaveUserData(
|
||||
@@ -56,7 +60,8 @@ public class WatchedStateSyncTests
|
||||
It.IsAny<UserDataSaveReason>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>(
|
||||
(u, _, d, _, _) => h.Saves.Add((u, d.Played, d.PlayCount)));
|
||||
(u, _, d, _, _) => h.Saves.Add(
|
||||
new Save(u, d.Played, d.PlayCount, d.LastPlayedDate, d.PlaybackPositionTicks)));
|
||||
|
||||
h.Service = new WatchedStateSyncService(
|
||||
h.UserData.Object,
|
||||
@@ -70,7 +75,7 @@ public class WatchedStateSyncTests
|
||||
/// <summary>
|
||||
/// Raises UserDataSaved as the server would, by starting the service so it subscribes.
|
||||
/// </summary>
|
||||
public void Raise(Guid userId, bool played, UserDataSaveReason reason)
|
||||
public void Raise(Guid userId, bool played, UserDataSaveReason reason, DateTime? lastPlayedDate = null)
|
||||
{
|
||||
Service.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
@@ -80,7 +85,7 @@ public class WatchedStateSyncTests
|
||||
{
|
||||
UserId = userId,
|
||||
Item = new Folder { Name = "Some Item" },
|
||||
UserData = new UserItemData { Key = "k", Played = played },
|
||||
UserData = new UserItemData { Key = "k", Played = played, LastPlayedDate = lastPlayedDate },
|
||||
SaveReason = reason
|
||||
});
|
||||
|
||||
@@ -88,6 +93,14 @@ public class WatchedStateSyncTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A snapshot of one member row as it was handed to SaveUserData. The service reuses the
|
||||
/// object it got from GetUserData, so the fields are copied rather than the reference kept.
|
||||
/// </summary>
|
||||
private sealed record Save(User Member, bool Played, int PlayCount, DateTime? LastPlayedDate, long PositionTicks);
|
||||
|
||||
private static readonly DateTime SharedWatchedAt = new(2026, 9, 18, 21, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static User MakeUser(string name) => new(name, "Prov", "ResetProv");
|
||||
|
||||
private static SharedGroup MakeGroup(bool syncUnwatched = true, bool syncPlayCount = false)
|
||||
@@ -126,11 +139,10 @@ public class WatchedStateSyncTests
|
||||
|
||||
[Theory]
|
||||
[InlineData(UserDataSaveReason.PlaybackStart)]
|
||||
[InlineData(UserDataSaveReason.PlaybackProgress)]
|
||||
[InlineData(UserDataSaveReason.UpdateUserRating)]
|
||||
public void IrrelevantSaveReasons_AreIgnored(UserDataSaveReason reason)
|
||||
{
|
||||
// UserDataSaved fires constantly during playback; only watched-state changes matter.
|
||||
// Neither of these ever carries a watched-state change, whatever the flag says.
|
||||
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
|
||||
|
||||
h.Raise(SharedId, true, reason);
|
||||
@@ -138,6 +150,100 @@ public class WatchedStateSyncTests
|
||||
Assert.Empty(h.Saves);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(UserDataSaveReason.PlaybackFinished)]
|
||||
[InlineData(UserDataSaveReason.PlaybackProgress)]
|
||||
[InlineData(UserDataSaveReason.UpdateUserData)]
|
||||
public void PlaybackDerivedReasons_SyncWatched(UserDataSaveReason reason)
|
||||
{
|
||||
// Progress crosses the completion threshold before the stop arrives, and some clients
|
||||
// never send a stop at all, so the tick has to land on the progress tick too.
|
||||
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
|
||||
|
||||
h.Raise(SharedId, true, reason);
|
||||
|
||||
Assert.Single(h.Saves);
|
||||
Assert.True(h.Saves[0].Played);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(UserDataSaveReason.PlaybackFinished)]
|
||||
[InlineData(UserDataSaveReason.PlaybackProgress)]
|
||||
[InlineData(UserDataSaveReason.UpdateUserData)]
|
||||
public void PlaybackDerivedReasons_NeverSyncUnwatched(UserDataSaveReason reason)
|
||||
{
|
||||
// PlaybackFinished fires on every stop, not just on completion, and on 10.11 PlaybackStart
|
||||
// resets Played to false first. Stopping halfway through on the shared account must not
|
||||
// clear what a member watched on their own - even with Sync unwatched on.
|
||||
var h = Harness.Create(MakeGroup(syncUnwatched: true), [MakeUser("alice")], memberAlreadyPlayed: true);
|
||||
|
||||
h.Raise(SharedId, false, reason);
|
||||
|
||||
Assert.Empty(h.Saves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Played_WritesWhatMarkPlayedWrites()
|
||||
{
|
||||
// Next Up is driven by LastPlayedDate on the member's own row, and a stale resume position
|
||||
// would keep the item in Continue Watching, so the tick alone is not enough.
|
||||
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberPositionTicks: 12_345);
|
||||
|
||||
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished, SharedWatchedAt);
|
||||
|
||||
var save = Assert.Single(h.Saves);
|
||||
Assert.True(save.Played);
|
||||
Assert.Equal(SharedWatchedAt, save.LastPlayedDate);
|
||||
Assert.Equal(0, save.PositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Played_FallsBackToNowWhenSharedAccountHasNoDate()
|
||||
{
|
||||
var before = DateTime.UtcNow;
|
||||
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
|
||||
|
||||
h.Raise(SharedId, true, UserDataSaveReason.TogglePlayed);
|
||||
|
||||
var save = Assert.Single(h.Saves);
|
||||
Assert.NotNull(save.LastPlayedDate);
|
||||
Assert.InRange(save.LastPlayedDate!.Value, before, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Played_RepairsAnAlreadyTickedRowThatHasNoDate()
|
||||
{
|
||||
// Rows synced by earlier versions have the tick but no date. They are not "already in
|
||||
// sync": without a date the member's Next Up never moves.
|
||||
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberAlreadyPlayed: true, memberLastPlayedDate: null);
|
||||
|
||||
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished, SharedWatchedAt);
|
||||
|
||||
var save = Assert.Single(h.Saves);
|
||||
Assert.True(save.Played);
|
||||
Assert.Equal(SharedWatchedAt, save.LastPlayedDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unwatched_WritesWhatMarkUnplayedWrites()
|
||||
{
|
||||
var h = Harness.Create(
|
||||
MakeGroup(syncUnwatched: true),
|
||||
[MakeUser("alice")],
|
||||
memberAlreadyPlayed: true,
|
||||
memberPlayCount: 3,
|
||||
memberLastPlayedDate: SharedWatchedAt,
|
||||
memberPositionTicks: 999);
|
||||
|
||||
h.Raise(SharedId, false, UserDataSaveReason.TogglePlayed);
|
||||
|
||||
var save = Assert.Single(h.Saves);
|
||||
Assert.False(save.Played);
|
||||
Assert.Null(save.LastPlayedDate);
|
||||
Assert.Equal(0, save.PositionTicks);
|
||||
Assert.Equal(3, save.PlayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unwatched_PropagatesWhenSyncUnwatchedEnabled()
|
||||
{
|
||||
@@ -159,13 +265,20 @@ public class WatchedStateSyncTests
|
||||
Assert.Empty(h.Saves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedundantWrites_AreSuppressed()
|
||||
[Theory]
|
||||
[InlineData(UserDataSaveReason.PlaybackFinished)]
|
||||
[InlineData(UserDataSaveReason.PlaybackProgress)]
|
||||
public void RedundantWrites_AreSuppressed(UserDataSaveReason reason)
|
||||
{
|
||||
// The member already matches the shared account, so there is nothing to write.
|
||||
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberAlreadyPlayed: true);
|
||||
// The member already matches the shared account, so there is nothing to write. This is
|
||||
// what keeps the stream of progress ticks after the completion threshold write-free.
|
||||
var h = Harness.Create(
|
||||
MakeGroup(),
|
||||
[MakeUser("alice")],
|
||||
memberAlreadyPlayed: true,
|
||||
memberLastPlayedDate: SharedWatchedAt);
|
||||
|
||||
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished);
|
||||
h.Raise(SharedId, true, reason);
|
||||
|
||||
Assert.Empty(h.Saves);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user