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; /// /// Creates and maintains shared accounts and the groups that describe them. /// public class ProvisioningService : IProvisioningService { /// /// The database column limit on usernames. A generated name is shortened to fit. /// private const int MaxUsernameLength = 255; /// /// The provider key Jellyfin stores on a shared account to route its logins to us. Jellyfin /// resolves providers by GetType().FullName, so this must match the provider type's /// full name exactly. /// private static readonly string AuthProviderId = typeof(Auth.SharedAccountAuthenticationProvider).FullName!; private readonly IUserManager _userManager; private readonly ILibraryAccessService _libraryAccessService; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The user manager. /// The library access service. /// The logger. public ProvisioningService( IUserManager userManager, ILibraryAccessService libraryAccessService, ILogger logger) { _userManager = userManager; _libraryAccessService = libraryAccessService; _logger = logger; } /// public async Task CreateGroupAsync(IReadOnlyList memberIds, string? name) { 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(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); } // Store members in the same alphabetical order the generated name uses, so that the stored // order is canonical however the members were supplied. Password checks then always run in // a predictable order too. members = members.OrderBy(m => m.Username, StringComparer.OrdinalIgnoreCase).ToList(); distinctIds = members.Select(m => m.Id).ToList(); var accountName = string.IsNullOrWhiteSpace(name) ? BuildDefaultName(members.Select(m => m.Username), config.NameSeparator) : name.Trim(); var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false); // 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. // // This must happen before the account is claimed below. IUserManager.ChangePassword // dispatches to the provider the user is currently assigned to, and ours refuses the call // by design, so claiming first would make provisioning throw NotSupportedException. A // freshly created user is still on Jellyfin's default provider, which stores the hash. await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).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; await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false); await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).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; } /// public async Task UpdateGroupAsync( Guid sharedUserId, IReadOnlyList 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)); } } // Keep the stored order canonical, matching how groups are created. distinctIds = distinctIds .OrderBy(id => _userManager.GetUserById(id)?.Username, StringComparer.OrdinalIgnoreCase) .ToList(); group.MemberUserIds = distinctIds; group.SyncUnwatched = syncUnwatched; group.SyncPlayCount = syncPlayCount; group.IsDisabled = isDisabled; plugin.UpdateConfiguration(config); // Membership drives library access, so recompute it: adding a member can only narrow the // intersection, and removing one may widen it. await ApplyLibraryAccessAsync(sharedUserId, distinctIds).ConfigureAwait(false); _logger.LogInformation( "Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}", sharedUserId, distinctIds.Count, isDisabled); return group; } /// 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); } /// /// Joins member names into a display name, falling back to a generic name if the result would /// exceed the username column limit. /// /// /// Names are sorted alphabetically so that a given set of members always produces the same /// account name. Without this, "jane+john" and "john+jane" would be two different names for the /// same group and would end up as two separate accounts. /// /// The member usernames. /// The configured separator. /// A name that fits within the username length limit. private static string BuildDefaultName(IEnumerable usernames, string separator) { var sep = string.IsNullOrEmpty(separator) ? "+" : separator; var joined = string.Join(sep, usernames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)); 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]); } /// /// Generates a random password that is never used for authentication. /// /// A random password string. private static string GenerateUnusedPassword() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)); /// /// Sets library access on the shared account. /// /// The shared account. /// The members whose access is intersected. /// A task representing the update. private async Task ApplyLibraryAccessAsync(Guid sharedUserId, IReadOnlyList memberIds) { var user = _userManager.GetUserById(sharedUserId); if (user is null) { return; } // Never "all folders": the shared account gets an explicit list of the libraries every // member can already reach, so joining a group can never grant access to anything. user.SetPermission(PermissionKind.EnableAllFolders, false); await _userManager.UpdateUserAsync(user).ConfigureAwait(false); await _libraryAccessService.ApplyIntersectionAsync(sharedUserId, memberIds).ConfigureAwait(false); } }