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; /// /// Covers propagation of played state from a shared account to its members. /// public class WatchedStateSyncTests { private static readonly Guid SharedId = Guid.NewGuid(); private sealed class Harness { public Mock UserData { get; } = new(); public Mock Groups { get; } = new(); public List Saves { get; } = new(); public WatchedStateSyncService Service { get; private set; } = null!; public static Harness Create( SharedGroup? group, IReadOnlyList 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())).Returns(group); h.Groups.Setup(g => g.GetEligibleMembers(It.IsAny())).Returns(members); h.UserData.Setup(m => m.GetUserData(It.IsAny(), It.IsAny())) .Returns(() => new UserItemData { Key = "k", Played = memberAlreadyPlayed, PlayCount = memberPlayCount, LastPlayedDate = memberLastPlayedDate, PlaybackPositionTicks = memberPositionTicks }); h.UserData.Setup(m => m.SaveUserData( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback( (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().Object, h.Groups.Object, NullLogger.Instance); return h; } /// /// Raises UserDataSaved as the server would, by starting the service so it subscribes. /// 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(); } } /// /// 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. /// 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); } }