Implement Watched Together shared viewing accounts
🏗️ Build Plugin / build (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled

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:
2026-07-29 00:00:13 +02:00
parent 7a9dbdafcc
commit 7be07d16a2
46 changed files with 3319 additions and 690 deletions
@@ -0,0 +1,179 @@
using System;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Model.Cryptography;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Auth;
/// <summary>
/// Authenticates a shared account against the passwords of each of its members.
/// </summary>
/// <remarks>
/// <para>
/// Jellyfin selects a provider per user via <c>User.AuthenticationProviderId</c>, so this provider
/// only ever sees shared accounts that provisioning assigned to it. Implementing
/// <see cref="IRequiresResolvedUser"/> means Jellyfin hands us the already-resolved shared account
/// rather than us having to look it up by name.
/// </para>
/// <para>
/// Verification reads each member's stored hash directly instead of calling
/// <c>IUserManager.AuthenticateUser</c>. Going through the normal flow would trip every member's
/// failed-attempt counter each time a <em>different</em> member's password was the one that
/// matched, eventually locking out members who did nothing wrong. Reading the live hash also means
/// member password changes take effect immediately, with no second copy of any credential stored.
/// </para>
/// </remarks>
public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
{
private readonly ICryptoProvider _cryptoProvider;
private readonly Services.IGroupService _groupService;
private readonly Services.IDynamicGroupService _dynamicGroupService;
private readonly ILogger<SharedAccountAuthenticationProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SharedAccountAuthenticationProvider"/> class.
/// </summary>
/// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param>
/// <param name="groupService">The group service.</param>
/// <param name="dynamicGroupService">The on-demand group creation service.</param>
/// <param name="logger">The logger.</param>
public SharedAccountAuthenticationProvider(
ICryptoProvider cryptoProvider,
Services.IGroupService groupService,
Services.IDynamicGroupService dynamicGroupService,
ILogger<SharedAccountAuthenticationProvider> logger)
{
_cryptoProvider = cryptoProvider;
_groupService = groupService;
_dynamicGroupService = dynamicGroupService;
_logger = logger;
}
/// <inheritdoc />
public string Name => "Watched Together Shared Account";
/// <inheritdoc />
public bool IsEnabled => true;
/// <inheritdoc />
/// <remarks>
/// Jellyfin calls the <see cref="IRequiresResolvedUser"/> overload instead, so this exists only
/// to satisfy the interface.
/// </remarks>
public Task<ProviderAuthenticationResult> Authenticate(string username, string password)
=> Authenticate(username, password, null);
/// <inheritdoc />
public async Task<ProviderAuthenticationResult> Authenticate(string username, string password, User? resolvedUser)
{
// Jellyfin passes a null user when no account matches the typed name, and offers the login
// to every enabled provider. That is the hook for "type alice+bob and the account appears":
// a real user with that exact name always resolves first and never reaches this branch.
if (resolvedUser is null)
{
var created = await _dynamicGroupService
.TryCreateFromLoginAsync(username, password)
.ConfigureAwait(false);
if (created is null)
{
throw new AuthenticationException("Invalid username or password.");
}
return new ProviderAuthenticationResult { Username = created.SharedUsername };
}
var group = _groupService.GetGroupForSharedUser(resolvedUser.Id);
if (group is null)
{
// Either not one of ours, or the group is disabled. Either way this account has no
// member passwords to check, so it cannot be unlocked.
_logger.LogWarning(
"Rejected login for {Username}: no enabled Watched Together group owns this account",
resolvedUser.Username);
throw new AuthenticationException("Invalid username or password.");
}
var members = _groupService.GetEligibleMembers(group);
if (members.Count == 0)
{
_logger.LogWarning(
"Rejected login for {Username}: group has no eligible members",
resolvedUser.Username);
throw new AuthenticationException("Invalid username or password.");
}
foreach (var member in members)
{
if (!VerifyPassword(member, password))
{
continue;
}
_logger.LogInformation(
"Shared account {SharedUsername} unlocked by member {MemberUsername}",
resolvedUser.Username,
member.Username);
return new ProviderAuthenticationResult
{
Username = resolvedUser.Username
};
}
_logger.LogWarning(
"Rejected login for {Username}: no member password matched",
resolvedUser.Username);
throw new AuthenticationException("Invalid username or password.");
}
/// <inheritdoc />
/// <remarks>
/// A shared account always has a password in the sense that matters to Jellyfin: some member
/// credential is required. Returning <c>false</c> would let clients offer a passwordless login.
/// </remarks>
public bool HasPassword(User user) => true;
/// <inheritdoc />
/// <remarks>
/// Shared accounts have no password of their own to change - members change their own passwords
/// in the normal way and the effect is picked up on the next login.
/// </remarks>
public Task ChangePassword(User user, string newPassword)
{
throw new NotSupportedException(
"A Watched Together shared account has no password of its own. Members change their own passwords instead.");
}
/// <summary>
/// Verifies a submitted password against a member's live stored hash.
/// </summary>
/// <param name="member">The member whose stored credential to check.</param>
/// <param name="password">The submitted password.</param>
/// <returns><c>true</c> if the password matches.</returns>
private bool VerifyPassword(User member, string password)
{
if (string.IsNullOrEmpty(member.Password))
{
// A member with no password set cannot contribute a credential to the group.
return false;
}
try
{
var hash = PasswordHash.Parse(member.Password);
return _cryptoProvider.Verify(hash, password);
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
// Never log the hash or the submitted password.
_logger.LogError(
ex,
"Could not parse the stored password hash for member {MemberId}; skipping",
member.Id);
return false;
}
}
}
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.WatchedTogether.Configuration;
/// <summary>
/// Plugin configuration. Holds the authoritative record of which members belong to which
/// shared account.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets the configured shared-account groups.
/// </summary>
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Plugin configuration is round-tripped by the XML serializer, which requires a settable List<T>.")]
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Plugin configuration is round-tripped by the XML serializer, which requires a settable List<T>.")]
public List<SharedGroup> Groups { get; set; } = new();
/// <summary>
/// Gets or sets the separator used to join member names into a shared account name. Also the
/// separator split at login when <see cref="EnableDynamicGroups"/> is on.
/// </summary>
public string NameSeparator { get; set; } = "+";
/// <summary>
/// Gets or sets a value indicating whether typing an unrecognised name like "alice+bob" at the
/// login screen creates the shared account on the spot.
/// </summary>
/// <remarks>
/// The account is only created if every named part is an existing, enabled, non-shared user
/// <em>and</em> the submitted password belongs to one of them. A real account whose name
/// happens to contain the separator always takes precedence, because Jellyfin only consults
/// this plugin once no local user matches the typed name.
/// </remarks>
public bool EnableDynamicGroups { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether accounts created on demand may access all libraries.
/// </summary>
/// <remarks>
/// Leaving this on means a dynamically created account sees every library, regardless of what
/// its members can each reach individually. Turn it off to have such accounts start with no
/// library access until an administrator grants it.
/// </remarks>
public bool DynamicGroupsEnableAllFolders { get; set; } = true;
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
namespace Jellyfin.Plugin.WatchedTogether.Configuration;
/// <summary>
/// The association between one shared account and the members who may unlock it.
/// </summary>
/// <remarks>
/// Membership is stored as GUIDs rather than being parsed out of the shared account's username.
/// The default separator ('+') is itself a legal username character, so a name like "alice+bob"
/// is ambiguous between the group [alice, bob] and a single user literally called "alice+bob".
/// GUIDs remove that ambiguity and support any number of members.
/// </remarks>
public class SharedGroup
{
/// <summary>
/// Gets or sets the identifier of the shared account that members log into collectively.
/// </summary>
public Guid SharedUserId { get; set; }
/// <summary>
/// Gets or sets the identifiers of the members whose passwords unlock the shared account,
/// and whose own accounts receive its watched state. A usable group has at least two.
/// </summary>
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Plugin configuration is round-tripped by the XML serializer, which requires a settable List<T>.")]
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Plugin configuration is round-tripped by the XML serializer, which requires a settable List<T>.")]
public List<Guid> MemberUserIds { get; set; } = new();
/// <summary>
/// Gets or sets a value indicating whether marking something unwatched on the shared account
/// also marks it unwatched for every member. When false, only the transition to watched
/// propagates.
/// </summary>
public bool SyncUnwatched { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether a member's play count is raised to at least one
/// when an item becomes watched. Play counts are never decremented.
/// </summary>
public bool SyncPlayCount { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this group is suspended. A group drops out of both
/// authentication and sync while disabled - set automatically if it falls below two members.
/// </summary>
public bool IsDisabled { get; set; }
}
@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Watched Together</title>
</head>
<body>
<div id="WatchedTogetherConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<div class="verticalSection">
<h2 class="sectionTitle">Watched Together</h2>
<p class="fieldDescription">
A shared account that several people log into with their own passwords. Anything
marked watched there is mirrored onto each member's own account.
This is not synchronized playback &mdash; for that, use Jellyfin's built-in SyncPlay.
</p>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Existing groups</h3>
<div id="groupsList"></div>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Create a group</h3>
<form id="CreateGroupForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="NewGroupName">Account name</label>
<input id="NewGroupName" name="NewGroupName" type="text" is="emby-input" />
<div class="fieldDescription">
Leave blank to join the member names with the separator below. Names are
cosmetic &mdash; membership is tracked internally, not parsed from the name.
</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="MemberSelect">Members (select at least two)</label>
<select is="emby-select" id="MemberSelect" multiple size="8"
class="emby-select-withcolor emby-select"></select>
<div class="fieldDescription">
Any selected member's password will unlock the shared account.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableAllFolders" type="checkbox" is="emby-checkbox" checked />
<span>Grant access to all libraries</span>
</label>
<div class="fieldDescription">
The shared account's library access is independent of each member's own
restrictions. If a member is normally blocked from a library but this
account is not, their password now reaches it.
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Create group</span>
</button>
</div>
</form>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Settings</h3>
<form id="SettingsForm">
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableDynamicGroups" type="checkbox" is="emby-checkbox" />
<span>Create groups automatically at login</span>
</label>
<div class="fieldDescription">
Typing an unrecognised name like <code>alice+bob</code> at the login
screen creates the shared account on the spot. Every name must belong to
an existing, enabled user, and the password must be one of theirs.
An existing account whose name contains the separator always wins.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="DynamicGroupsEnableAllFolders" type="checkbox" is="emby-checkbox" />
<span>Auto-created accounts can access all libraries</span>
</label>
<div class="fieldDescription">
Turn this off to have auto-created accounts start with no library access
until you grant it.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="NameSeparator">Name separator</label>
<input id="NameSeparator" name="NameSeparator" type="text" is="emby-input" maxlength="3" />
<div class="fieldDescription">
Joins member names into an account name, and is the character split at
login above. '+' is valid on current Jellyfin; use '_' or '-' if your
server rejects it.
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
</div>
<script type="text/javascript">
(function () {
var pluginUniqueId = 'aa3288a0-e8c1-43e2-8045-8c3411142a5b';
var page;
function apiUrl(path) {
return ApiClient.getUrl('Plugins/WatchedTogether/' + path);
}
function loadEligibleUsers() {
return ApiClient.getJSON(apiUrl('EligibleUsers')).then(function (users) {
var select = page.querySelector('#MemberSelect');
select.innerHTML = users.map(function (u) {
return '<option value="' + u.UserId + '">' + u.Username + '</option>';
}).join('');
});
}
function renderGroups(groups) {
var container = page.querySelector('#groupsList');
if (!groups.length) {
container.innerHTML = '<p class="fieldDescription">No groups configured yet.</p>';
return;
}
container.innerHTML = groups.map(function (g) {
var members = g.Members.map(function (m) { return m.Username; }).join(', ');
var status = g.IsDisabled ? ' <span style="opacity:.7">(disabled)</span>' : '';
return '<div class="listItem" style="padding:.6em 0;border-bottom:1px solid rgba(255,255,255,.1)">' +
'<h3 style="margin:0">' + g.SharedUsername + status + '</h3>' +
'<div class="fieldDescription">Members: ' + members + '</div>' +
'<div class="fieldDescription">' +
'Sync unwatched: ' + (g.SyncUnwatched ? 'yes' : 'no') +
' &middot; Sync play count: ' + (g.SyncPlayCount ? 'yes' : 'no') + '</div>' +
'<button is="emby-button" type="button" class="raised btnDeleteGroup" ' +
'data-id="' + g.SharedUserId + '" data-name="' + g.SharedUsername + '">' +
'<span>Delete</span></button>' +
'</div>';
}).join('');
container.querySelectorAll('.btnDeleteGroup').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
var name = btn.getAttribute('data-name');
// Deleting the account too is destructive, so make it an explicit choice.
Dashboard.confirm(
'Also delete the shared account "' + name + '"? Choose Cancel to keep the account and only remove the group.',
'Delete group',
function (deleteUser) {
var url = apiUrl('Groups/' + id + '?deleteSharedUser=' + (deleteUser ? 'true' : 'false'));
ApiClient.ajax({ type: 'DELETE', url: url }).then(function () {
Dashboard.alert('Group deleted.');
loadGroups();
loadEligibleUsers();
});
});
});
});
}
function loadGroups() {
return ApiClient.getJSON(apiUrl('Groups')).then(renderGroups);
}
document.querySelector('#WatchedTogetherConfigPage').addEventListener('pageshow', function () {
page = this;
Dashboard.showLoadingMsg();
Promise.all([
loadGroups(),
loadEligibleUsers(),
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
page.querySelector('#NameSeparator').value = config.NameSeparator || '+';
page.querySelector('#EnableDynamicGroups').checked = config.EnableDynamicGroups;
page.querySelector('#DynamicGroupsEnableAllFolders').checked = config.DynamicGroupsEnableAllFolders;
})
]).then(function () {
Dashboard.hideLoadingMsg();
}, function () {
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#CreateGroupForm').addEventListener('submit', function (e) {
e.preventDefault();
var selected = Array.prototype.slice
.call(page.querySelector('#MemberSelect').selectedOptions)
.map(function (o) { return o.value; });
if (selected.length < 2) {
Dashboard.alert('Select at least two members.');
return false;
}
Dashboard.showLoadingMsg();
ApiClient.ajax({
type: 'POST',
url: apiUrl('Groups'),
contentType: 'application/json',
data: JSON.stringify({
MemberUserIds: selected,
Name: page.querySelector('#NewGroupName').value || null,
EnableAllFolders: page.querySelector('#EnableAllFolders').checked,
EnabledFolders: null
})
}).then(function () {
Dashboard.hideLoadingMsg();
Dashboard.alert('Group created.');
page.querySelector('#NewGroupName').value = '';
loadGroups();
loadEligibleUsers();
}, function (response) {
Dashboard.hideLoadingMsg();
if (response && response.text) {
response.text().then(function (msg) {
Dashboard.alert({ title: 'Could not create group', message: msg });
});
} else {
Dashboard.alert('Could not create group.');
}
});
return false;
});
document.querySelector('#SettingsForm').addEventListener('submit', function (e) {
e.preventDefault();
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
config.NameSeparator = page.querySelector('#NameSeparator').value || '+';
config.EnableDynamicGroups = page.querySelector('#EnableDynamicGroups').checked;
config.DynamicGroupsEnableAllFolders = page.querySelector('#DynamicGroupsEnableAllFolders').checked;
ApiClient.updatePluginConfiguration(pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
return false;
});
})();
</script>
</div>
</body>
</html>
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Models;
using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Controllers;
/// <summary>
/// Administrative endpoints backing the configuration page.
/// </summary>
[ApiController]
[Authorize(Policy = Policies.RequiresElevation)]
[Route("Plugins/WatchedTogether")]
[Produces(MediaTypeNames.Application.Json)]
public class WatchedTogetherController : ControllerBase
{
private readonly IProvisioningService _provisioningService;
private readonly IUserManager _userManager;
private readonly ILogger<WatchedTogetherController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="WatchedTogetherController"/> class.
/// </summary>
/// <param name="provisioningService">The provisioning service.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="logger">The logger.</param>
public WatchedTogetherController(
IProvisioningService provisioningService,
IUserManager userManager,
ILogger<WatchedTogetherController> logger)
{
_provisioningService = provisioningService;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// Gets every configured group, resolved against current user records.
/// </summary>
/// <returns>The configured groups.</returns>
[HttpGet("Groups")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<GroupDto>> GetGroups()
{
var config = Plugin.Instance?.Configuration;
if (config is null)
{
return Ok(Array.Empty<GroupDto>());
}
var groups = config.Groups.Select(g => new GroupDto
{
SharedUserId = g.SharedUserId,
SharedUsername = _userManager.GetUserById(g.SharedUserId)?.Username ?? "(deleted)",
SyncUnwatched = g.SyncUnwatched,
SyncPlayCount = g.SyncPlayCount,
IsDisabled = g.IsDisabled,
Members = g.MemberUserIds.Select(id => new MemberDto
{
UserId = id,
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
}).ToList()
}).ToList();
return Ok(groups);
}
/// <summary>
/// Gets the users that may be selected as members - everyone who is not already a shared account.
/// </summary>
/// <returns>The eligible users.</returns>
[HttpGet("EligibleUsers")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<MemberDto>> GetEligibleUsers()
{
var sharedIds = Plugin.Instance?.Configuration.Groups
.Select(g => g.SharedUserId)
.ToHashSet() ?? [];
var users = _userManager.Users
.Where(u => !sharedIds.Contains(u.Id))
.Select(u => new MemberDto { UserId = u.Id, Username = u.Username })
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
.ToList();
return Ok(users);
}
/// <summary>
/// Creates a shared account and its group.
/// </summary>
/// <param name="request">The group to create.</param>
/// <returns>The created group.</returns>
[HttpPost("Groups")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<GroupDto>> CreateGroup([FromBody] CreateGroupRequest request)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var group = await _provisioningService.CreateGroupAsync(
request.MemberUserIds,
request.Name,
request.EnableAllFolders,
request.EnabledFolders).ConfigureAwait(false);
return Ok(new GroupDto
{
SharedUserId = group.SharedUserId,
SharedUsername = _userManager.GetUserById(group.SharedUserId)?.Username ?? string.Empty,
SyncUnwatched = group.SyncUnwatched,
SyncPlayCount = group.SyncPlayCount,
IsDisabled = group.IsDisabled,
Members = group.MemberUserIds.Select(id => new MemberDto
{
UserId = id,
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
}).ToList()
});
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Rejected group creation request");
return BadRequest(ex.Message);
}
}
/// <summary>
/// Updates an existing group's membership and options.
/// </summary>
/// <param name="sharedUserId">The shared account identifying the group.</param>
/// <param name="request">The new membership and options.</param>
/// <returns>No content on success.</returns>
[HttpPost("Groups/{sharedUserId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> UpdateGroup(
[FromRoute] Guid sharedUserId,
[FromBody] UpdateGroupRequest request)
{
ArgumentNullException.ThrowIfNull(request);
try
{
await _provisioningService.UpdateGroupAsync(
sharedUserId,
request.MemberUserIds,
request.SyncUnwatched,
request.SyncPlayCount,
request.IsDisabled).ConfigureAwait(false);
return NoContent();
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Rejected group update request");
return BadRequest(ex.Message);
}
}
/// <summary>
/// Deletes a group, optionally deleting its shared account too.
/// </summary>
/// <param name="sharedUserId">The shared account identifying the group.</param>
/// <param name="deleteSharedUser">Whether to delete the shared account as well.</param>
/// <returns>No content on success.</returns>
[HttpDelete("Groups/{sharedUserId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> DeleteGroup(
[FromRoute] Guid sharedUserId,
[FromQuery] bool deleteSharedUser = false)
{
try
{
await _provisioningService.DeleteGroupAsync(sharedUserId, deleteSharedUser).ConfigureAwait(false);
return NoContent();
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Rejected group deletion request");
return BadRequest(ex.Message);
}
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.WatchedTogether</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.Plugin.WatchedTogether.Models;
/// <summary>
/// A request to create a shared account and its group.
/// </summary>
public class CreateGroupRequest
{
/// <summary>
/// Gets or sets the members whose passwords will unlock the account. At least two.
/// </summary>
public IReadOnlyList<Guid> MemberUserIds { get; set; } = Array.Empty<Guid>();
/// <summary>
/// Gets or sets an explicit account name. When empty, one is generated from the member names.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the shared account may access all libraries.
/// </summary>
public bool EnableAllFolders { get; set; } = true;
/// <summary>
/// Gets or sets the explicit libraries the shared account may access.
/// </summary>
public IReadOnlyList<Guid>? EnabledFolders { get; set; }
}
@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.Plugin.WatchedTogether.Models;
/// <summary>
/// A configured group, resolved for display.
/// </summary>
public class GroupDto
{
/// <summary>
/// Gets or sets the shared account identifier.
/// </summary>
public Guid SharedUserId { get; set; }
/// <summary>
/// Gets or sets the shared account's username.
/// </summary>
public string SharedUsername { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the group's members.
/// </summary>
public IReadOnlyList<MemberDto> Members { get; set; } = Array.Empty<MemberDto>();
/// <summary>
/// Gets or sets a value indicating whether unwatched state propagates too.
/// </summary>
public bool SyncUnwatched { get; set; }
/// <summary>
/// Gets or sets a value indicating whether play counts are raised on watch.
/// </summary>
public bool SyncPlayCount { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the group is suspended.
/// </summary>
public bool IsDisabled { get; set; }
}
@@ -0,0 +1,19 @@
using System;
namespace Jellyfin.Plugin.WatchedTogether.Models;
/// <summary>
/// A member of a group, resolved to a current username.
/// </summary>
public class MemberDto
{
/// <summary>
/// Gets or sets the member's user identifier.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// Gets or sets the member's username.
/// </summary>
public string Username { get; set; } = string.Empty;
}
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.Plugin.WatchedTogether.Models;
/// <summary>
/// A request to update an existing group.
/// </summary>
public class UpdateGroupRequest
{
/// <summary>
/// Gets or sets the new member list. At least two.
/// </summary>
public IReadOnlyList<Guid> MemberUserIds { get; set; } = Array.Empty<Guid>();
/// <summary>
/// Gets or sets a value indicating whether unwatched state propagates too.
/// </summary>
public bool SyncUnwatched { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether play counts are raised on watch.
/// </summary>
public bool SyncPlayCount { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the group is suspended.
/// </summary>
public bool IsDisabled { get; set; }
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.WatchedTogether;
/// <summary>
/// The Watched Together plugin: shared viewing accounts whose watched state flows back to each
/// member's own account.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <inheritdoc />
public override string Name => "Watched Together";
/// <inheritdoc />
public override Guid Id => Guid.Parse("aa3288a0-e8c1-43e2-8045-8c3411142a5b");
/// <inheritdoc />
public override string Description =>
"Lets several users share one viewing account, with watched state syncing back to each member.";
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return
[
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
}
];
}
}
@@ -0,0 +1,28 @@
using Jellyfin.Plugin.WatchedTogether.Auth;
using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.WatchedTogether;
/// <summary>
/// Registers the plugin's services with the host.
/// </summary>
public class ServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<IGroupService, GroupService>();
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>();
// Discovered by Jellyfin and matched to shared accounts via User.AuthenticationProviderId.
serviceCollection.AddSingleton<IAuthenticationProvider, SharedAccountAuthenticationProvider>();
serviceCollection.AddHostedService<WatchedStateSyncService>();
serviceCollection.AddHostedService<UserLifecycleService>();
}
}
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Cryptography;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates a shared account the first time someone logs in as "alice+bob".
/// </summary>
/// <remarks>
/// <para>
/// Jellyfin only routes a login to the providers with a null resolved user when <em>no</em> local
/// user matches the typed name. A real account named "alice+bob" therefore always wins, and this
/// code never sees it - the ambiguity between a group and a same-named user resolves in favour of
/// the real user automatically.
/// </para>
/// <para>
/// Every named member must exist and none may be a shared account, so an unrelated username
/// containing the separator simply fails to resolve and is rejected.
/// </para>
/// </remarks>
public class DynamicGroupService : IDynamicGroupService
{
private readonly IUserManager _userManager;
private readonly IProvisioningService _provisioningService;
private readonly ICryptoProvider _cryptoProvider;
private readonly ILogger<DynamicGroupService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DynamicGroupService"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="provisioningService">The provisioning service.</param>
/// <param name="cryptoProvider">The crypto provider.</param>
/// <param name="logger">The logger.</param>
public DynamicGroupService(
IUserManager userManager,
IProvisioningService provisioningService,
ICryptoProvider cryptoProvider,
ILogger<DynamicGroupService> logger)
{
_userManager = userManager;
_provisioningService = provisioningService;
_cryptoProvider = cryptoProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<DynamicGroupResult?> TryCreateFromLoginAsync(string enteredUsername, string password)
{
var config = Plugin.Instance?.Configuration;
if (config is null || !config.EnableDynamicGroups)
{
return null;
}
if (string.IsNullOrWhiteSpace(enteredUsername))
{
return null;
}
var separator = string.IsNullOrEmpty(config.NameSeparator) ? "+" : config.NameSeparator;
var parts = enteredUsername
.Split(separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.ToList();
// Needs at least two names to be a group at all.
if (parts.Count < 2)
{
return null;
}
// Every part must name a real, non-shared, enabled user. Anything else means this is not a
// group login, so fall through and let the attempt fail normally.
var members = new List<User>(parts.Count);
foreach (var part in parts)
{
var member = _userManager.GetUserByName(part);
if (member is null)
{
_logger.LogDebug("Dynamic group login rejected: no user named {Part}", part);
return null;
}
if (config.Groups.Any(g => g.SharedUserId == member.Id))
{
_logger.LogWarning(
"Dynamic group login rejected: {Part} is itself a shared account",
part);
return null;
}
if (member.HasPermission(PermissionKind.IsDisabled))
{
_logger.LogWarning("Dynamic group login rejected: {Part} is disabled", part);
return null;
}
if (members.Any(m => m.Id == member.Id))
{
_logger.LogWarning("Dynamic group login rejected: {Part} named more than once", part);
return null;
}
members.Add(member);
}
// The password must belong to one of the named members. Without this any visitor could
// conjure a shared account out of two usernames they happened to know.
if (!members.Any(m => VerifyPassword(m, password)))
{
_logger.LogWarning(
"Dynamic group login for {Username} rejected: no named member's password matched",
enteredUsername);
return null;
}
var group = await _provisioningService.CreateGroupAsync(
members.Select(m => m.Id).ToList(),
enteredUsername,
enableAllFolders: config.DynamicGroupsEnableAllFolders,
enabledFolders: null).ConfigureAwait(false);
var sharedUser = _userManager.GetUserById(group.SharedUserId);
if (sharedUser is null)
{
return null;
}
_logger.LogInformation(
"Created shared account {Username} on demand for {MemberCount} members",
sharedUser.Username,
members.Count);
return new DynamicGroupResult(group, sharedUser.Username);
}
/// <summary>
/// Verifies a submitted password against a member's live stored hash.
/// </summary>
/// <param name="member">The member to check.</param>
/// <param name="password">The submitted password.</param>
/// <returns><c>true</c> if the password matches.</returns>
private bool VerifyPassword(User member, string password)
{
if (string.IsNullOrEmpty(member.Password))
{
return false;
}
try
{
return _cryptoProvider.Verify(PasswordHash.Parse(member.Password), password);
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
// Never log the hash or the submitted password.
_logger.LogError(ex, "Could not parse the stored password hash for member {MemberId}", member.Id);
return false;
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,29 @@
using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Configuration;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates shared accounts on demand from a separator-joined username typed at the login screen.
/// </summary>
public interface IDynamicGroupService
{
/// <summary>
/// Attempts to authenticate a not-yet-existing shared account named like "alice+bob", creating
/// it if the submitted password belongs to one of the named members.
/// </summary>
/// <param name="enteredUsername">The username typed at the login screen.</param>
/// <param name="password">The submitted password.</param>
/// <returns>
/// The created group and the shared account's username, or <c>null</c> if the name is not a
/// valid member combination or no named member's password matched.
/// </returns>
Task<DynamicGroupResult?> TryCreateFromLoginAsync(string enteredUsername, string password);
}
/// <summary>
/// The outcome of a successful on-demand group creation.
/// </summary>
/// <param name="Group">The group that was created.</param>
/// <param name="SharedUsername">The username of the shared account.</param>
public record DynamicGroupResult(SharedGroup Group, string SharedUsername);
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.WatchedTogether.Configuration;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Resolves shared-account groups from plugin configuration.
/// </summary>
public interface IGroupService
{
/// <summary>
/// Gets the active group owning the given shared account, if any.
/// </summary>
/// <param name="sharedUserId">The shared account identifier.</param>
/// <returns>The group, or <c>null</c> if this user is not an enabled shared account.</returns>
SharedGroup? GetGroupForSharedUser(Guid sharedUserId);
/// <summary>
/// Gets the members of a group that are currently eligible - existing and not disabled.
/// </summary>
/// <param name="group">The group whose members to resolve.</param>
/// <returns>The eligible member users.</returns>
IReadOnlyList<User> GetEligibleMembers(SharedGroup group);
/// <summary>
/// Determines whether the given user is a shared account managed by this plugin, regardless
/// of whether its group is currently enabled.
/// </summary>
/// <param name="userId">The user identifier to test.</param>
/// <returns><c>true</c> if the user is a managed shared account.</returns>
bool IsSharedAccount(Guid userId);
/// <summary>
/// Removes a deleted user from every group, disabling any group left with fewer than two
/// members, and persists the result.
/// </summary>
/// <param name="userId">The identifier of the user that was removed.</param>
void PruneDeletedUser(Guid userId);
}
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Configuration;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates, updates and removes shared accounts and their groups.
/// </summary>
public interface IProvisioningService
{
/// <summary>
/// Creates a shared account for the given members and records the group.
/// </summary>
/// <param name="memberIds">The members whose passwords will unlock the account. At least two.</param>
/// <param name="name">An explicit account name, or <c>null</c> to generate one from the member names.</param>
/// <param name="enableAllFolders">Whether the shared account may access all libraries.</param>
/// <param name="enabledFolders">Explicit library identifiers, used when <paramref name="enableAllFolders"/> is false.</param>
/// <returns>The created group.</returns>
Task<SharedGroup> CreateGroupAsync(
IReadOnlyList<Guid> memberIds,
string? name,
bool enableAllFolders,
IReadOnlyList<Guid>? enabledFolders);
/// <summary>
/// Replaces the membership and options of an existing group.
/// </summary>
/// <param name="sharedUserId">The shared account identifying the group.</param>
/// <param name="memberIds">The new member list. At least two.</param>
/// <param name="syncUnwatched">Whether unwatched state propagates too.</param>
/// <param name="syncPlayCount">Whether play counts are raised on watch.</param>
/// <param name="isDisabled">Whether the group is suspended.</param>
/// <returns>The updated group.</returns>
Task<SharedGroup> UpdateGroupAsync(
Guid sharedUserId,
IReadOnlyList<Guid> memberIds,
bool syncUnwatched,
bool syncPlayCount,
bool isDisabled);
/// <summary>
/// Removes a group, optionally deleting its shared account.
/// </summary>
/// <param name="sharedUserId">The shared account identifying the group.</param>
/// <param name="deleteSharedUser">Whether to delete the shared Jellyfin account as well.</param>
/// <returns>A task representing the removal.</returns>
Task DeleteGroupAsync(Guid sharedUserId, bool deleteSharedUser);
}
@@ -0,0 +1,276 @@
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;
/// <summary>
/// Creates and maintains shared accounts and the groups that describe them.
/// </summary>
public class ProvisioningService : IProvisioningService
{
/// <summary>
/// The database column limit on usernames. A generated name is shortened to fit.
/// </summary>
private const int MaxUsernameLength = 255;
/// <summary>
/// The provider key Jellyfin stores on a shared account to route its logins to us. Jellyfin
/// resolves providers by <c>GetType().FullName</c>, so this must match the provider type's
/// full name exactly.
/// </summary>
private static readonly string AuthProviderId =
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
private readonly IUserManager _userManager;
private readonly ILogger<ProvisioningService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ProvisioningService"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="logger">The logger.</param>
public ProvisioningService(IUserManager userManager, ILogger<ProvisioningService> logger)
{
_userManager = userManager;
_logger = logger;
}
/// <inheritdoc />
public async Task<SharedGroup> CreateGroupAsync(
IReadOnlyList<Guid> memberIds,
string? name,
bool enableAllFolders,
IReadOnlyList<Guid>? enabledFolders)
{
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<Jellyfin.Database.Implementations.Entities.User>(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);
}
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, enableAllFolders, enabledFolders).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;
}
/// <inheritdoc />
public Task<SharedGroup> UpdateGroupAsync(
Guid sharedUserId,
IReadOnlyList<Guid> 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));
}
}
group.MemberUserIds = distinctIds;
group.SyncUnwatched = syncUnwatched;
group.SyncPlayCount = syncPlayCount;
group.IsDisabled = isDisabled;
plugin.UpdateConfiguration(config);
_logger.LogInformation(
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}",
sharedUserId,
distinctIds.Count,
isDisabled);
return Task.FromResult(group);
}
/// <inheritdoc />
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);
}
/// <summary>
/// Joins member names into a display name, falling back to a generic name if the result would
/// exceed the username column limit. Membership is tracked by GUID, so the name is cosmetic.
/// </summary>
/// <param name="usernames">The member usernames.</param>
/// <param name="separator">The configured separator.</param>
/// <returns>A name that fits within the username length limit.</returns>
private static string BuildDefaultName(IEnumerable<string> usernames, string separator)
{
var sep = string.IsNullOrEmpty(separator) ? "+" : separator;
var joined = string.Join(sep, usernames);
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]);
}
/// <summary>
/// Generates a random password that is never used for authentication.
/// </summary>
/// <returns>A random password string.</returns>
private static string GenerateUnusedPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
/// <summary>
/// Sets library access on the shared account.
/// </summary>
/// <param name="sharedUserId">The shared account.</param>
/// <param name="enableAllFolders">Whether to grant access to every library.</param>
/// <param name="enabledFolders">The explicit library list when not granting all.</param>
/// <returns>A task representing the update.</returns>
private async Task ApplyLibraryAccessAsync(
Guid sharedUserId,
bool enableAllFolders,
IReadOnlyList<Guid>? enabledFolders)
{
var user = _userManager.GetUserById(sharedUserId);
if (user is null)
{
return;
}
// Library access on the shared account is deliberate and independent of what each member
// can reach individually - any member's password opens whatever this account can see.
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
if (enableAllFolders)
{
return;
}
var policy = _userManager.GetUserDto(user).Policy;
if (policy is null)
{
return;
}
policy.EnableAllFolders = false;
policy.EnabledFolders = enabledFolders?.ToArray() ?? [];
await _userManager.UpdatePolicyAsync(sharedUserId, policy).ConfigureAwait(false);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,147 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Propagates played state from a shared account to each of its members, one way.
/// </summary>
public sealed class WatchedStateSyncService : IHostedService, IDisposable
{
private readonly IUserDataManager _userDataManager;
private readonly IUserManager _userManager;
private readonly IGroupService _groupService;
private readonly ILogger<WatchedStateSyncService> _logger;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="WatchedStateSyncService"/> class.
/// </summary>
/// <param name="userDataManager">The user data manager.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="groupService">The group service.</param>
/// <param name="logger">The logger.</param>
public WatchedStateSyncService(
IUserDataManager userDataManager,
IUserManager userManager,
IGroupService groupService,
ILogger<WatchedStateSyncService> logger)
{
_userDataManager = userDataManager;
_userManager = userManager;
_groupService = groupService;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved += OnUserDataSaved;
_logger.LogInformation("Watched Together sync started");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= OnUserDataSaved;
_logger.LogInformation("Watched Together sync stopped");
return Task.CompletedTask;
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
_userDataManager.UserDataSaved -= OnUserDataSaved;
_disposed = true;
}
/// <summary>
/// Mirrors a shared account's played state onto its members.
/// </summary>
/// <remarks>
/// No loop guard is needed. Writing to a member raises this event again with that member's id,
/// which is not a shared account id, so the handler returns immediately. The
/// <c>Played</c> equality check below suppresses redundant writes on top of that.
/// </remarks>
private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
{
if (e?.UserData is null || e.Item is null)
{
return;
}
// UserDataSaved fires constantly during playback (progress ticks); only act on the reasons
// that actually represent a change in watched state.
if (e.SaveReason is not (UserDataSaveReason.PlaybackFinished
or UserDataSaveReason.TogglePlayed
or UserDataSaveReason.Import))
{
return;
}
var group = _groupService.GetGroupForSharedUser(e.UserId);
if (group is null)
{
return;
}
var played = e.UserData.Played;
if (!played && !group.SyncUnwatched)
{
return;
}
foreach (var member in _groupService.GetEligibleMembers(group))
{
try
{
var data = _userDataManager.GetUserData(member, e.Item);
if (data is null || data.Played == played)
{
continue;
}
data.Played = played;
if (group.SyncPlayCount && played && data.PlayCount < 1)
{
data.PlayCount = 1;
}
_userDataManager.SaveUserData(
member,
e.Item,
data,
UserDataSaveReason.TogglePlayed,
CancellationToken.None);
_logger.LogDebug(
"Synced played={Played} for {ItemName} to member {MemberUsername}",
played,
e.Item.Name,
member.Username);
}
#pragma warning disable CA1031 // One member failing must not stop the rest from syncing.
catch (Exception ex)
#pragma warning restore CA1031
{
_logger.LogError(
ex,
"Failed to sync played state for {ItemName} to member {MemberId}",
e.Item.Name,
member.Id);
}
}
}
}