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; /// /// Keeps group membership consistent with the set of users that actually exist. /// /// /// 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 , so this is about keeping /// stored configuration tidy and disabling groups that have fallen below two members. /// public sealed class UserLifecycleService : IHostedService { private readonly IUserManager _userManager; private readonly IGroupService _groupService; private readonly ILibraryAccessService _libraryAccessService; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The user manager. /// The group service. /// The library access service. /// The logger. public UserLifecycleService( IUserManager userManager, IGroupService groupService, ILibraryAccessService libraryAccessService, ILogger logger) { _userManager = userManager; _groupService = groupService; _libraryAccessService = libraryAccessService; _logger = logger; } /// public async Task StartAsync(CancellationToken cancellationToken) { try { Reconcile(); await ReapplyLibraryAccessAsync().ConfigureAwait(false); } #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"); } } /// /// Recomputes every group's library access. /// /// /// A member's own library access can be narrowed at any time through the normal user editor, /// which would leave a group's stored intersection too wide. Recomputing at startup brings /// shared accounts back in line without needing to hook every policy change. /// /// A task representing the update. private async Task ReapplyLibraryAccessAsync() { var config = Plugin.Instance?.Configuration; if (config is null) { return; } foreach (var group in config.Groups.ToList()) { await _libraryAccessService .ApplyIntersectionAsync(group.SharedUserId, group.MemberUserIds) .ConfigureAwait(false); } } /// public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// /// Removes references to users that no longer exist. /// 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); } } }