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,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Cryptography;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates a shared account the first time someone logs in as "alice+bob".
/// </summary>
/// <remarks>
/// <para>
/// Jellyfin only routes a login to the providers with a null resolved user when <em>no</em> local
/// user matches the typed name. A real account named "alice+bob" therefore always wins, and this
/// code never sees it - the ambiguity between a group and a same-named user resolves in favour of
/// the real user automatically.
/// </para>
/// <para>
/// Every named member must exist and none may be a shared account, so an unrelated username
/// containing the separator simply fails to resolve and is rejected.
/// </para>
/// </remarks>
public class DynamicGroupService : IDynamicGroupService
{
private readonly IUserManager _userManager;
private readonly IProvisioningService _provisioningService;
private readonly ICryptoProvider _cryptoProvider;
private readonly ILogger<DynamicGroupService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DynamicGroupService"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="provisioningService">The provisioning service.</param>
/// <param name="cryptoProvider">The crypto provider.</param>
/// <param name="logger">The logger.</param>
public DynamicGroupService(
IUserManager userManager,
IProvisioningService provisioningService,
ICryptoProvider cryptoProvider,
ILogger<DynamicGroupService> logger)
{
_userManager = userManager;
_provisioningService = provisioningService;
_cryptoProvider = cryptoProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<DynamicGroupResult?> TryCreateFromLoginAsync(string enteredUsername, string password)
{
var config = Plugin.Instance?.Configuration;
if (config is null || !config.EnableDynamicGroups)
{
return null;
}
if (string.IsNullOrWhiteSpace(enteredUsername))
{
return null;
}
var separator = string.IsNullOrEmpty(config.NameSeparator) ? "+" : config.NameSeparator;
var parts = enteredUsername
.Split(separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.ToList();
// Needs at least two names to be a group at all.
if (parts.Count < 2)
{
return null;
}
// Every part must name a real, non-shared, enabled user. Anything else means this is not a
// group login, so fall through and let the attempt fail normally.
var members = new List<User>(parts.Count);
foreach (var part in parts)
{
var member = _userManager.GetUserByName(part);
if (member is null)
{
_logger.LogDebug("Dynamic group login rejected: no user named {Part}", part);
return null;
}
if (config.Groups.Any(g => g.SharedUserId == member.Id))
{
_logger.LogWarning(
"Dynamic group login rejected: {Part} is itself a shared account",
part);
return null;
}
if (member.HasPermission(PermissionKind.IsDisabled))
{
_logger.LogWarning("Dynamic group login rejected: {Part} is disabled", part);
return null;
}
if (members.Any(m => m.Id == member.Id))
{
_logger.LogWarning("Dynamic group login rejected: {Part} named more than once", part);
return null;
}
members.Add(member);
}
// The password must belong to one of the named members. Without this any visitor could
// conjure a shared account out of two usernames they happened to know.
if (!members.Any(m => VerifyPassword(m, password)))
{
_logger.LogWarning(
"Dynamic group login for {Username} rejected: no named member's password matched",
enteredUsername);
return null;
}
var group = await _provisioningService.CreateGroupAsync(
members.Select(m => m.Id).ToList(),
enteredUsername,
enableAllFolders: config.DynamicGroupsEnableAllFolders,
enabledFolders: null).ConfigureAwait(false);
var sharedUser = _userManager.GetUserById(group.SharedUserId);
if (sharedUser is null)
{
return null;
}
_logger.LogInformation(
"Created shared account {Username} on demand for {MemberCount} members",
sharedUser.Username,
members.Count);
return new DynamicGroupResult(group, sharedUser.Username);
}
/// <summary>
/// Verifies a submitted password against a member's live stored hash.
/// </summary>
/// <param name="member">The member to check.</param>
/// <param name="password">The submitted password.</param>
/// <returns><c>true</c> if the password matches.</returns>
private bool VerifyPassword(User member, string password)
{
if (string.IsNullOrEmpty(member.Password))
{
return false;
}
try
{
return _cryptoProvider.Verify(PasswordHash.Parse(member.Password), password);
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
// Never log the hash or the submitted password.
_logger.LogError(ex, "Could not parse the stored password hash for member {MemberId}", member.Id);
return false;
}
}
}
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Reads group membership from plugin configuration and resolves it against live user records.
/// </summary>
public class GroupService : IGroupService
{
private readonly IUserManager _userManager;
private readonly ILogger<GroupService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="GroupService"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="logger">The logger.</param>
public GroupService(IUserManager userManager, ILogger<GroupService> logger)
{
_userManager = userManager;
_logger = logger;
}
/// <inheritdoc />
public SharedGroup? GetGroupForSharedUser(Guid sharedUserId)
{
var config = Plugin.Instance?.Configuration;
if (config is null)
{
return null;
}
var group = config.Groups.FirstOrDefault(g => g.SharedUserId == sharedUserId);
return group is null || group.IsDisabled ? null : group;
}
/// <inheritdoc />
public IReadOnlyList<User> GetEligibleMembers(SharedGroup group)
{
ArgumentNullException.ThrowIfNull(group);
var members = new List<User>(group.MemberUserIds.Count);
foreach (var memberId in group.MemberUserIds)
{
var member = _userManager.GetUserById(memberId);
if (member is null)
{
// Stale entry; PruneDeletedUser clears these when the deletion is observed.
continue;
}
// A disabled member should no longer be able to unlock the shared account, and should
// not receive its watched state either.
if (member.HasPermission(PermissionKind.IsDisabled))
{
continue;
}
members.Add(member);
}
return members;
}
/// <inheritdoc />
public bool IsSharedAccount(Guid userId)
{
var config = Plugin.Instance?.Configuration;
return config is not null && config.Groups.Any(g => g.SharedUserId == userId);
}
/// <inheritdoc />
public void PruneDeletedUser(Guid userId)
{
var plugin = Plugin.Instance;
if (plugin is null)
{
return;
}
var config = plugin.Configuration;
var changed = false;
// Drop groups whose shared account itself was deleted - there is nothing left to log into.
var orphaned = config.Groups.Where(g => g.SharedUserId == userId).ToList();
foreach (var group in orphaned)
{
config.Groups.Remove(group);
changed = true;
_logger.LogInformation("Removed group for deleted shared account {SharedUserId}", userId);
}
foreach (var group in config.Groups)
{
if (!group.MemberUserIds.Remove(userId))
{
continue;
}
changed = true;
_logger.LogInformation(
"Removed deleted member {MemberId} from group {SharedUserId}",
userId,
group.SharedUserId);
// A group needs at least two members to mean anything; suspend rather than delete so
// an admin can add a replacement member and re-enable it.
if (group.MemberUserIds.Count < 2 && !group.IsDisabled)
{
group.IsDisabled = true;
_logger.LogWarning(
"Group {SharedUserId} disabled: fewer than two members remain",
group.SharedUserId);
}
}
if (changed)
{
plugin.UpdateConfiguration(config);
}
}
}
@@ -0,0 +1,29 @@
using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Configuration;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates shared accounts on demand from a separator-joined username typed at the login screen.
/// </summary>
public interface IDynamicGroupService
{
/// <summary>
/// Attempts to authenticate a not-yet-existing shared account named like "alice+bob", creating
/// it if the submitted password belongs to one of the named members.
/// </summary>
/// <param name="enteredUsername">The username typed at the login screen.</param>
/// <param name="password">The submitted password.</param>
/// <returns>
/// The created group and the shared account's username, or <c>null</c> if the name is not a
/// valid member combination or no named member's password matched.
/// </returns>
Task<DynamicGroupResult?> TryCreateFromLoginAsync(string enteredUsername, string password);
}
/// <summary>
/// The outcome of a successful on-demand group creation.
/// </summary>
/// <param name="Group">The group that was created.</param>
/// <param name="SharedUsername">The username of the shared account.</param>
public record DynamicGroupResult(SharedGroup Group, string SharedUsername);
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.WatchedTogether.Configuration;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Resolves shared-account groups from plugin configuration.
/// </summary>
public interface IGroupService
{
/// <summary>
/// Gets the active group owning the given shared account, if any.
/// </summary>
/// <param name="sharedUserId">The shared account identifier.</param>
/// <returns>The group, or <c>null</c> if this user is not an enabled shared account.</returns>
SharedGroup? GetGroupForSharedUser(Guid sharedUserId);
/// <summary>
/// Gets the members of a group that are currently eligible - existing and not disabled.
/// </summary>
/// <param name="group">The group whose members to resolve.</param>
/// <returns>The eligible member users.</returns>
IReadOnlyList<User> GetEligibleMembers(SharedGroup group);
/// <summary>
/// Determines whether the given user is a shared account managed by this plugin, regardless
/// of whether its group is currently enabled.
/// </summary>
/// <param name="userId">The user identifier to test.</param>
/// <returns><c>true</c> if the user is a managed shared account.</returns>
bool IsSharedAccount(Guid userId);
/// <summary>
/// Removes a deleted user from every group, disabling any group left with fewer than two
/// members, and persists the result.
/// </summary>
/// <param name="userId">The identifier of the user that was removed.</param>
void PruneDeletedUser(Guid userId);
}
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Configuration;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates, updates and removes shared accounts and their groups.
/// </summary>
public interface IProvisioningService
{
/// <summary>
/// Creates a shared account for the given members and records the group.
/// </summary>
/// <param name="memberIds">The members whose passwords will unlock the account. At least two.</param>
/// <param name="name">An explicit account name, or <c>null</c> to generate one from the member names.</param>
/// <param name="enableAllFolders">Whether the shared account may access all libraries.</param>
/// <param name="enabledFolders">Explicit library identifiers, used when <paramref name="enableAllFolders"/> is false.</param>
/// <returns>The created group.</returns>
Task<SharedGroup> CreateGroupAsync(
IReadOnlyList<Guid> memberIds,
string? name,
bool enableAllFolders,
IReadOnlyList<Guid>? enabledFolders);
/// <summary>
/// Replaces the membership and options of an existing group.
/// </summary>
/// <param name="sharedUserId">The shared account identifying the group.</param>
/// <param name="memberIds">The new member list. At least two.</param>
/// <param name="syncUnwatched">Whether unwatched state propagates too.</param>
/// <param name="syncPlayCount">Whether play counts are raised on watch.</param>
/// <param name="isDisabled">Whether the group is suspended.</param>
/// <returns>The updated group.</returns>
Task<SharedGroup> UpdateGroupAsync(
Guid sharedUserId,
IReadOnlyList<Guid> memberIds,
bool syncUnwatched,
bool syncPlayCount,
bool isDisabled);
/// <summary>
/// Removes a group, optionally deleting its shared account.
/// </summary>
/// <param name="sharedUserId">The shared account identifying the group.</param>
/// <param name="deleteSharedUser">Whether to delete the shared Jellyfin account as well.</param>
/// <returns>A task representing the removal.</returns>
Task DeleteGroupAsync(Guid sharedUserId, bool deleteSharedUser);
}
@@ -0,0 +1,276 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates and maintains shared accounts and the groups that describe them.
/// </summary>
public class ProvisioningService : IProvisioningService
{
/// <summary>
/// The database column limit on usernames. A generated name is shortened to fit.
/// </summary>
private const int MaxUsernameLength = 255;
/// <summary>
/// The provider key Jellyfin stores on a shared account to route its logins to us. Jellyfin
/// resolves providers by <c>GetType().FullName</c>, so this must match the provider type's
/// full name exactly.
/// </summary>
private static readonly string AuthProviderId =
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
private readonly IUserManager _userManager;
private readonly ILogger<ProvisioningService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ProvisioningService"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="logger">The logger.</param>
public ProvisioningService(IUserManager userManager, ILogger<ProvisioningService> logger)
{
_userManager = userManager;
_logger = logger;
}
/// <inheritdoc />
public async Task<SharedGroup> CreateGroupAsync(
IReadOnlyList<Guid> memberIds,
string? name,
bool enableAllFolders,
IReadOnlyList<Guid>? enabledFolders)
{
ArgumentNullException.ThrowIfNull(memberIds);
var plugin = Plugin.Instance
?? throw new InvalidOperationException("The plugin is not initialised.");
var config = plugin.Configuration;
var distinctIds = memberIds.Distinct().ToList();
if (distinctIds.Count < 2)
{
throw new ArgumentException("A group needs at least two distinct members.", nameof(memberIds));
}
var members = new List<Jellyfin.Database.Implementations.Entities.User>(distinctIds.Count);
foreach (var id in distinctIds)
{
var member = _userManager.GetUserById(id)
?? throw new ArgumentException(
string.Format(CultureInfo.InvariantCulture, "No user exists with id {0}.", id),
nameof(memberIds));
// A shared account must not become a member of another group: its own login is already
// a union of other people's credentials, and nesting would compound that invisibly.
if (config.Groups.Any(g => g.SharedUserId == id))
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"'{0}' is itself a shared account and cannot be a member of a group.",
member.Username),
nameof(memberIds));
}
members.Add(member);
}
var accountName = string.IsNullOrWhiteSpace(name)
? BuildDefaultName(members.Select(m => m.Username), config.NameSeparator)
: name.Trim();
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false);
// Route this account's logins through our provider. Jellyfin matches providers by
// GetType().FullName, the same key the SSO plugin uses, and the assignment only sticks
// once the user is updated.
sharedUser.AuthenticationProviderId = AuthProviderId;
// The shared account never authenticates against its own password - our provider checks
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
// if the provider is ever unassigned.
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
await ApplyLibraryAccessAsync(sharedUser.Id, enableAllFolders, enabledFolders).ConfigureAwait(false);
var group = new SharedGroup
{
SharedUserId = sharedUser.Id,
MemberUserIds = distinctIds
};
config.Groups.Add(group);
plugin.UpdateConfiguration(config);
_logger.LogInformation(
"Created shared account {Username} ({SharedUserId}) with {MemberCount} members",
sharedUser.Username,
sharedUser.Id,
distinctIds.Count);
return group;
}
/// <inheritdoc />
public Task<SharedGroup> UpdateGroupAsync(
Guid sharedUserId,
IReadOnlyList<Guid> memberIds,
bool syncUnwatched,
bool syncPlayCount,
bool isDisabled)
{
ArgumentNullException.ThrowIfNull(memberIds);
var plugin = Plugin.Instance
?? throw new InvalidOperationException("The plugin is not initialised.");
var config = plugin.Configuration;
var group = config.Groups.FirstOrDefault(g => g.SharedUserId == sharedUserId)
?? throw new ArgumentException("No group exists for that shared account.", nameof(sharedUserId));
var distinctIds = memberIds.Distinct().ToList();
if (distinctIds.Count < 2)
{
throw new ArgumentException("A group needs at least two distinct members.", nameof(memberIds));
}
foreach (var id in distinctIds)
{
if (_userManager.GetUserById(id) is null)
{
throw new ArgumentException(
string.Format(CultureInfo.InvariantCulture, "No user exists with id {0}.", id),
nameof(memberIds));
}
if (id == sharedUserId || config.Groups.Any(g => g.SharedUserId == id))
{
throw new ArgumentException(
"A shared account cannot be a member of a group.",
nameof(memberIds));
}
}
group.MemberUserIds = distinctIds;
group.SyncUnwatched = syncUnwatched;
group.SyncPlayCount = syncPlayCount;
group.IsDisabled = isDisabled;
plugin.UpdateConfiguration(config);
_logger.LogInformation(
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}",
sharedUserId,
distinctIds.Count,
isDisabled);
return Task.FromResult(group);
}
/// <inheritdoc />
public async Task DeleteGroupAsync(Guid sharedUserId, bool deleteSharedUser)
{
var plugin = Plugin.Instance
?? throw new InvalidOperationException("The plugin is not initialised.");
var config = plugin.Configuration;
var group = config.Groups.FirstOrDefault(g => g.SharedUserId == sharedUserId)
?? throw new ArgumentException("No group exists for that shared account.", nameof(sharedUserId));
// Drop the group first: if account deletion fails we are left with an orphaned account
// rather than a group pointing at a user that may be half-deleted.
config.Groups.Remove(group);
plugin.UpdateConfiguration(config);
if (deleteSharedUser && _userManager.GetUserById(sharedUserId) is not null)
{
await _userManager.DeleteUserAsync(sharedUserId).ConfigureAwait(false);
_logger.LogInformation("Deleted shared account {SharedUserId}", sharedUserId);
}
_logger.LogInformation("Removed group {SharedUserId}", sharedUserId);
}
/// <summary>
/// Joins member names into a display name, falling back to a generic name if the result would
/// exceed the username column limit. Membership is tracked by GUID, so the name is cosmetic.
/// </summary>
/// <param name="usernames">The member usernames.</param>
/// <param name="separator">The configured separator.</param>
/// <returns>A name that fits within the username length limit.</returns>
private static string BuildDefaultName(IEnumerable<string> usernames, string separator)
{
var sep = string.IsNullOrEmpty(separator) ? "+" : separator;
var joined = string.Join(sep, usernames);
if (joined.Length <= MaxUsernameLength)
{
return joined;
}
// Truncating mid-name would produce something misleading, so switch to a neutral label
// with a short unique suffix instead.
return string.Format(
CultureInfo.InvariantCulture,
"Shared-{0}",
Guid.NewGuid().ToString("N")[..8]);
}
/// <summary>
/// Generates a random password that is never used for authentication.
/// </summary>
/// <returns>A random password string.</returns>
private static string GenerateUnusedPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
/// <summary>
/// Sets library access on the shared account.
/// </summary>
/// <param name="sharedUserId">The shared account.</param>
/// <param name="enableAllFolders">Whether to grant access to every library.</param>
/// <param name="enabledFolders">The explicit library list when not granting all.</param>
/// <returns>A task representing the update.</returns>
private async Task ApplyLibraryAccessAsync(
Guid sharedUserId,
bool enableAllFolders,
IReadOnlyList<Guid>? enabledFolders)
{
var user = _userManager.GetUserById(sharedUserId);
if (user is null)
{
return;
}
// Library access on the shared account is deliberate and independent of what each member
// can reach individually - any member's password opens whatever this account can see.
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
if (enableAllFolders)
{
return;
}
var policy = _userManager.GetUserDto(user).Policy;
if (policy is null)
{
return;
}
policy.EnableAllFolders = false;
policy.EnabledFolders = enabledFolders?.ToArray() ?? [];
await _userManager.UpdatePolicyAsync(sharedUserId, policy).ConfigureAwait(false);
}
}
@@ -0,0 +1,86 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Keeps group membership consistent with the set of users that actually exist.
/// </summary>
/// <remarks>
/// Jellyfin raises no user-deleted event that carries the removed id, so instead of subscribing to
/// deletions this reconciles configuration against live users at startup. Stale entries are also
/// skipped at read time by <see cref="GroupService.GetEligibleMembers"/>, so this is about keeping
/// stored configuration tidy and disabling groups that have fallen below two members.
/// </remarks>
public sealed class UserLifecycleService : IHostedService
{
private readonly IUserManager _userManager;
private readonly IGroupService _groupService;
private readonly ILogger<UserLifecycleService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="UserLifecycleService"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="groupService">The group service.</param>
/// <param name="logger">The logger.</param>
public UserLifecycleService(
IUserManager userManager,
IGroupService groupService,
ILogger<UserLifecycleService> logger)
{
_userManager = userManager;
_groupService = groupService;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
Reconcile();
}
#pragma warning disable CA1031 // Reconciliation must never prevent the server from starting.
catch (Exception ex)
#pragma warning restore CA1031
{
_logger.LogError(ex, "Failed to reconcile Watched Together groups at startup");
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// Removes references to users that no longer exist.
/// </summary>
private void Reconcile()
{
var config = Plugin.Instance?.Configuration;
if (config is null || config.Groups.Count == 0)
{
return;
}
var liveIds = _userManager.UsersIds.ToHashSet();
var referenced = config.Groups
.SelectMany(g => g.MemberUserIds.Append(g.SharedUserId))
.Distinct()
.ToList();
foreach (var id in referenced.Where(id => !liveIds.Contains(id)))
{
_logger.LogInformation("Pruning deleted user {UserId} from Watched Together groups", id);
_groupService.PruneDeletedUser(id);
}
}
}
@@ -0,0 +1,147 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Propagates played state from a shared account to each of its members, one way.
/// </summary>
public sealed class WatchedStateSyncService : IHostedService, IDisposable
{
private readonly IUserDataManager _userDataManager;
private readonly IUserManager _userManager;
private readonly IGroupService _groupService;
private readonly ILogger<WatchedStateSyncService> _logger;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="WatchedStateSyncService"/> class.
/// </summary>
/// <param name="userDataManager">The user data manager.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="groupService">The group service.</param>
/// <param name="logger">The logger.</param>
public WatchedStateSyncService(
IUserDataManager userDataManager,
IUserManager userManager,
IGroupService groupService,
ILogger<WatchedStateSyncService> logger)
{
_userDataManager = userDataManager;
_userManager = userManager;
_groupService = groupService;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved += OnUserDataSaved;
_logger.LogInformation("Watched Together sync started");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= OnUserDataSaved;
_logger.LogInformation("Watched Together sync stopped");
return Task.CompletedTask;
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
_userDataManager.UserDataSaved -= OnUserDataSaved;
_disposed = true;
}
/// <summary>
/// Mirrors a shared account's played state onto its members.
/// </summary>
/// <remarks>
/// No loop guard is needed. Writing to a member raises this event again with that member's id,
/// which is not a shared account id, so the handler returns immediately. The
/// <c>Played</c> equality check below suppresses redundant writes on top of that.
/// </remarks>
private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
{
if (e?.UserData is null || e.Item is null)
{
return;
}
// UserDataSaved fires constantly during playback (progress ticks); only act on the reasons
// that actually represent a change in watched state.
if (e.SaveReason is not (UserDataSaveReason.PlaybackFinished
or UserDataSaveReason.TogglePlayed
or UserDataSaveReason.Import))
{
return;
}
var group = _groupService.GetGroupForSharedUser(e.UserId);
if (group is null)
{
return;
}
var played = e.UserData.Played;
if (!played && !group.SyncUnwatched)
{
return;
}
foreach (var member in _groupService.GetEligibleMembers(group))
{
try
{
var data = _userDataManager.GetUserData(member, e.Item);
if (data is null || data.Played == played)
{
continue;
}
data.Played = played;
if (group.SyncPlayCount && played && data.PlayCount < 1)
{
data.PlayCount = 1;
}
_userDataManager.SaveUserData(
member,
e.Item,
data,
UserDataSaveReason.TogglePlayed,
CancellationToken.None);
_logger.LogDebug(
"Synced played={Played} for {ItemName} to member {MemberUsername}",
played,
e.Item.Name,
member.Username);
}
#pragma warning disable CA1031 // One member failing must not stop the rest from syncing.
catch (Exception ex)
#pragma warning restore CA1031
{
_logger.LogError(
ex,
"Failed to sync played state for {ItemName} to member {MemberId}",
e.Item.Name,
member.Id);
}
}
}
}