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,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads group membership from plugin configuration and resolves it against live user records.
|
||||
/// </summary>
|
||||
public class GroupService : IGroupService
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<GroupService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GroupService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public GroupService(IUserManager userManager, ILogger<GroupService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SharedGroup? GetGroupForSharedUser(Guid sharedUserId)
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var group = config.Groups.FirstOrDefault(g => g.SharedUserId == sharedUserId);
|
||||
return group is null || group.IsDisabled ? null : group;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<User> GetEligibleMembers(SharedGroup group)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(group);
|
||||
|
||||
var members = new List<User>(group.MemberUserIds.Count);
|
||||
foreach (var memberId in group.MemberUserIds)
|
||||
{
|
||||
var member = _userManager.GetUserById(memberId);
|
||||
if (member is null)
|
||||
{
|
||||
// Stale entry; PruneDeletedUser clears these when the deletion is observed.
|
||||
continue;
|
||||
}
|
||||
|
||||
// A disabled member should no longer be able to unlock the shared account, and should
|
||||
// not receive its watched state either.
|
||||
if (member.HasPermission(PermissionKind.IsDisabled))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
members.Add(member);
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSharedAccount(Guid userId)
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
return config is not null && config.Groups.Any(g => g.SharedUserId == userId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void PruneDeletedUser(Guid userId)
|
||||
{
|
||||
var plugin = Plugin.Instance;
|
||||
if (plugin is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var config = plugin.Configuration;
|
||||
var changed = false;
|
||||
|
||||
// Drop groups whose shared account itself was deleted - there is nothing left to log into.
|
||||
var orphaned = config.Groups.Where(g => g.SharedUserId == userId).ToList();
|
||||
foreach (var group in orphaned)
|
||||
{
|
||||
config.Groups.Remove(group);
|
||||
changed = true;
|
||||
_logger.LogInformation("Removed group for deleted shared account {SharedUserId}", userId);
|
||||
}
|
||||
|
||||
foreach (var group in config.Groups)
|
||||
{
|
||||
if (!group.MemberUserIds.Remove(userId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
_logger.LogInformation(
|
||||
"Removed deleted member {MemberId} from group {SharedUserId}",
|
||||
userId,
|
||||
group.SharedUserId);
|
||||
|
||||
// A group needs at least two members to mean anything; suspend rather than delete so
|
||||
// an admin can add a replacement member and re-enable it.
|
||||
if (group.MemberUserIds.Count < 2 && !group.IsDisabled)
|
||||
{
|
||||
group.IsDisabled = true;
|
||||
_logger.LogWarning(
|
||||
"Group {SharedUserId} disabled: fewer than two members remain",
|
||||
group.SharedUserId);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
plugin.UpdateConfiguration(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user