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>
335 lines
12 KiB
C#
335 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using Jellyfin.Database.Implementations.Entities;
|
|
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
|
using Jellyfin.Plugin.WatchedTogether.Services;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Model.Entities;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
|
|
|
/// <summary>
|
|
/// Covers propagation of played state from a shared account to its members.
|
|
/// </summary>
|
|
public class WatchedStateSyncTests
|
|
{
|
|
private static readonly Guid SharedId = Guid.NewGuid();
|
|
|
|
private sealed class Harness
|
|
{
|
|
public Mock<IUserDataManager> UserData { get; } = new();
|
|
|
|
public Mock<IGroupService> Groups { get; } = new();
|
|
|
|
public List<Save> Saves { get; } = new();
|
|
|
|
public WatchedStateSyncService Service { get; private set; } = null!;
|
|
|
|
public static Harness Create(
|
|
SharedGroup? group,
|
|
IReadOnlyList<User> members,
|
|
bool memberAlreadyPlayed = false,
|
|
int memberPlayCount = 0,
|
|
DateTime? memberLastPlayedDate = null,
|
|
long memberPositionTicks = 0)
|
|
{
|
|
var h = new Harness();
|
|
|
|
h.Groups.Setup(g => g.GetGroupForSharedUser(It.IsAny<Guid>())).Returns(group);
|
|
h.Groups.Setup(g => g.GetEligibleMembers(It.IsAny<SharedGroup>())).Returns(members);
|
|
|
|
h.UserData.Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
|
|
.Returns(() => new UserItemData
|
|
{
|
|
Key = "k",
|
|
Played = memberAlreadyPlayed,
|
|
PlayCount = memberPlayCount,
|
|
LastPlayedDate = memberLastPlayedDate,
|
|
PlaybackPositionTicks = memberPositionTicks
|
|
});
|
|
|
|
h.UserData.Setup(m => m.SaveUserData(
|
|
It.IsAny<User>(),
|
|
It.IsAny<BaseItem>(),
|
|
It.IsAny<UserItemData>(),
|
|
It.IsAny<UserDataSaveReason>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>(
|
|
(u, _, d, _, _) => h.Saves.Add(
|
|
new Save(u, d.Played, d.PlayCount, d.LastPlayedDate, d.PlaybackPositionTicks)));
|
|
|
|
h.Service = new WatchedStateSyncService(
|
|
h.UserData.Object,
|
|
new Mock<IUserManager>().Object,
|
|
h.Groups.Object,
|
|
NullLogger<WatchedStateSyncService>.Instance);
|
|
|
|
return h;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raises UserDataSaved as the server would, by starting the service so it subscribes.
|
|
/// </summary>
|
|
public void Raise(Guid userId, bool played, UserDataSaveReason reason, DateTime? lastPlayedDate = null)
|
|
{
|
|
Service.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
UserData.Raise(
|
|
m => m.UserDataSaved += null,
|
|
new UserDataSaveEventArgs
|
|
{
|
|
UserId = userId,
|
|
Item = new Folder { Name = "Some Item" },
|
|
UserData = new UserItemData { Key = "k", Played = played, LastPlayedDate = lastPlayedDate },
|
|
SaveReason = reason
|
|
});
|
|
|
|
Service.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
=> new()
|
|
{
|
|
SharedUserId = SharedId,
|
|
MemberUserIds = [Guid.NewGuid(), Guid.NewGuid()],
|
|
SyncUnwatched = syncUnwatched,
|
|
SyncPlayCount = syncPlayCount
|
|
};
|
|
|
|
[Fact]
|
|
public void Played_PropagatesToEveryMember()
|
|
{
|
|
var alice = MakeUser("alice");
|
|
var bob = MakeUser("bob");
|
|
var h = Harness.Create(MakeGroup(), [alice, bob]);
|
|
|
|
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished);
|
|
|
|
Assert.Equal(2, h.Saves.Count);
|
|
Assert.All(h.Saves, s => Assert.True(s.Played));
|
|
}
|
|
|
|
[Fact]
|
|
public void WritesFromAMember_AreIgnored()
|
|
{
|
|
// The loop guard: a member's own save must not be treated as a shared-account change.
|
|
// GetGroupForSharedUser returns null for any id that is not a shared account.
|
|
var h = Harness.Create(null, []);
|
|
|
|
h.Raise(Guid.NewGuid(), true, UserDataSaveReason.PlaybackFinished);
|
|
|
|
Assert.Empty(h.Saves);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(UserDataSaveReason.PlaybackStart)]
|
|
[InlineData(UserDataSaveReason.UpdateUserRating)]
|
|
public void IrrelevantSaveReasons_AreIgnored(UserDataSaveReason reason)
|
|
{
|
|
// 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);
|
|
|
|
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()
|
|
{
|
|
var h = Harness.Create(MakeGroup(syncUnwatched: true), [MakeUser("alice")], memberAlreadyPlayed: true);
|
|
|
|
h.Raise(SharedId, false, UserDataSaveReason.TogglePlayed);
|
|
|
|
Assert.Single(h.Saves);
|
|
Assert.False(h.Saves[0].Played);
|
|
}
|
|
|
|
[Fact]
|
|
public void Unwatched_IsSuppressedWhenSyncUnwatchedDisabled()
|
|
{
|
|
var h = Harness.Create(MakeGroup(syncUnwatched: false), [MakeUser("alice")], memberAlreadyPlayed: true);
|
|
|
|
h.Raise(SharedId, false, UserDataSaveReason.TogglePlayed);
|
|
|
|
Assert.Empty(h.Saves);
|
|
}
|
|
|
|
[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. 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, reason);
|
|
|
|
Assert.Empty(h.Saves);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayCount_IsRaisedWhenEnabled()
|
|
{
|
|
var h = Harness.Create(MakeGroup(syncPlayCount: true), [MakeUser("alice")]);
|
|
|
|
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished);
|
|
|
|
Assert.Single(h.Saves);
|
|
Assert.Equal(1, h.Saves[0].PlayCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayCount_IsNotDecrementedForAlreadyWatchedItems()
|
|
{
|
|
// A member who has watched something five times keeps that count.
|
|
var h = Harness.Create(
|
|
MakeGroup(syncPlayCount: true),
|
|
[MakeUser("alice")],
|
|
memberAlreadyPlayed: false,
|
|
memberPlayCount: 5);
|
|
|
|
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished);
|
|
|
|
Assert.Single(h.Saves);
|
|
Assert.Equal(5, h.Saves[0].PlayCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayCount_IsLeftAloneWhenDisabled()
|
|
{
|
|
var h = Harness.Create(MakeGroup(syncPlayCount: false), [MakeUser("alice")]);
|
|
|
|
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished);
|
|
|
|
Assert.Single(h.Saves);
|
|
Assert.Equal(0, h.Saves[0].PlayCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void DisabledGroup_DoesNotSync()
|
|
{
|
|
// GetGroupForSharedUser returns null for suspended groups.
|
|
var h = Harness.Create(null, [MakeUser("alice")]);
|
|
|
|
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished);
|
|
|
|
Assert.Empty(h.Saves);
|
|
}
|
|
}
|