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,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>
|
||||
Reference in New Issue
Block a user