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;
}
}
}