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,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 — 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 — 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') +
|
||||
' · 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user