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.
277 lines
10 KiB
C#
277 lines
10 KiB
C#
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);
|
|
}
|
|
}
|