Implement Watched Together shared viewing accounts
🏗️ Build Plugin / build (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled

Replaces the plugin template with a working plugin that lets several
users share one viewing account while keeping their individual watched
lists accurate.

Three pieces:

- Auto-creating groups. Logging in as "alice+bob" with any named
  member's own password provisions the shared account and signs you in.
  Verified against 10.11.5: AuthenticateUser offers unmatched usernames
  to every enabled provider and re-queries afterwards, which is the hook
  this relies on. Gated on a real member password so knowing two
  usernames is not enough to create an account.

- Multi-password authentication. IRequiresResolvedUser hands us the
  resolved shared account; each member's live stored hash is checked via
  ICryptoProvider.Verify. Deliberately avoids re-entering
  UserManager.AuthenticateUser, which would trip every member's
  failed-attempt counter whenever a different member's password matched.

- One-way played-state sync. Shared account to members only, filtered to
  PlaybackFinished/TogglePlayed/Import so playback progress ticks are
  ignored. No loop guard needed: member writes carry a non-shared id.

Membership is stored as user IDs rather than re-parsed from the username,
so shared accounts can be renamed freely. The +/name collision resolves
itself because Jellyfin only consults the plugin when no local user
matches the typed name.

Targets Jellyfin 10.11.x / net9.0. Adds Gitea CI (test, build, release),
a builder image, and 34 tests covering the auth and sync rules.
This commit is contained in:
2026-07-29 00:00:13 +02:00
parent 7a9dbdafcc
commit 7be07d16a2
46 changed files with 3319 additions and 690 deletions
@@ -0,0 +1,221 @@
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<(User Member, bool Played, int PlayCount)> 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)
{
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
});
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((u, d.Played, d.PlayCount)));
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)
{
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 },
SaveReason = reason
});
Service.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
}
}
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.PlaybackProgress)]
[InlineData(UserDataSaveReason.UpdateUserRating)]
public void IrrelevantSaveReasons_AreIgnored(UserDataSaveReason reason)
{
// UserDataSaved fires constantly during playback; only watched-state changes matter.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
h.Raise(SharedId, true, reason);
Assert.Empty(h.Saves);
}
[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);
}
[Fact]
public void RedundantWrites_AreSuppressed()
{
// The member already matches the shared account, so there is nothing to write.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberAlreadyPlayed: true);
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished);
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);
}
}