Implement Watched Together shared viewing accounts
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.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
|
||||
/// <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 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="logger">The logger.</param>
|
||||
public UserLifecycleService(
|
||||
IUserManager userManager,
|
||||
IGroupService groupService,
|
||||
ILogger<UserLifecycleService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_groupService = groupService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Reconcile();
|
||||
}
|
||||
#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");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user