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);
// 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, 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);
}
}