Jellyfin 12 moved to .NET 10 and changed the IUserManager surface the plugin relies on: Users/UsersIds became GetUsers()/GetUsersIds(), ChangePassword takes a user id, HasPassword left the provider contract, and the user cache is gone, so every lookup is a detached copy. The plugin now multi-targets net9.0 (against 10.11.5) and net10.0 (against 12.0.0). The differences sit behind a JELLYFIN_12 constant in Compat/UserManagerCompat.cs, whose ChangePasswordAsync also carries the stored hash back onto the caller's instance: on 12 the UpdateUserAsync that claims the account would otherwise write the stale null password back over the one provisioning just set. Each release ships one package per generation, with the fourth version segment naming the target (x.y.z.11 and x.y.z.12) so a 12 server picks the 12 package over the 10.11 one. scripts/package.sh wraps jprm for a single generation and the workflows call it twice. The builder image moves to the .NET 10 SDK, which builds both targets; the net9.0 test run rolls forward onto the .NET 10 runtime. CA1873 is a .NET 10 analyzer that flags the same log calls CA1848 does; it is set to Info, as in the upstream Jellyfin 12 tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
116 lines
4.0 KiB
C#
116 lines
4.0 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.WatchedTogether.Compat;
|
|
using MediaBrowser.Controller.Library;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
|
|
|
/// <summary>
|
|
/// Keeps group membership consistent with the set of users that actually exist.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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 <see cref="GroupService.GetEligibleMembers"/>, so this is about keeping
|
|
/// stored configuration tidy and disabling groups that have fallen below two members.
|
|
/// </remarks>
|
|
public sealed class UserLifecycleService : IHostedService
|
|
{
|
|
private readonly IUserManager _userManager;
|
|
private readonly IGroupService _groupService;
|
|
private readonly ILibraryAccessService _libraryAccessService;
|
|
private readonly ILogger<UserLifecycleService> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="UserLifecycleService"/> class.
|
|
/// </summary>
|
|
/// <param name="userManager">The user manager.</param>
|
|
/// <param name="groupService">The group service.</param>
|
|
/// <param name="libraryAccessService">The library access service.</param>
|
|
/// <param name="logger">The logger.</param>
|
|
public UserLifecycleService(
|
|
IUserManager userManager,
|
|
IGroupService groupService,
|
|
ILibraryAccessService libraryAccessService,
|
|
ILogger<UserLifecycleService> logger)
|
|
{
|
|
_userManager = userManager;
|
|
_groupService = groupService;
|
|
_libraryAccessService = libraryAccessService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recomputes every group's library access.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <returns>A task representing the update.</returns>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
/// <summary>
|
|
/// Removes references to users that no longer exist.
|
|
/// </summary>
|
|
private void Reconcile()
|
|
{
|
|
var config = Plugin.Instance?.Configuration;
|
|
if (config is null || config.Groups.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var liveIds = _userManager.GetAllUserIds().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);
|
|
}
|
|
}
|
|
}
|