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

Replaces the plugin template with a working plugin that lets several
users share one viewing account while keeping their individual watched
lists accurate.

Three pieces:

- Auto-creating groups. Logging in as "alice+bob" with any named
  member's own password provisions the shared account and signs you in.
  Verified against 10.11.5: AuthenticateUser offers unmatched usernames
  to every enabled provider and re-queries afterwards, which is the hook
  this relies on. Gated on a real member password so knowing two
  usernames is not enough to create an account.

- Multi-password authentication. IRequiresResolvedUser hands us the
  resolved shared account; each member's live stored hash is checked via
  ICryptoProvider.Verify. Deliberately avoids re-entering
  UserManager.AuthenticateUser, which would trip every member's
  failed-attempt counter whenever a different member's password matched.

- One-way played-state sync. Shared account to members only, filtered to
  PlaybackFinished/TogglePlayed/Import so playback progress ticks are
  ignored. No loop guard needed: member writes carry a non-shared id.

Membership is stored as user IDs rather than re-parsed from the username,
so shared accounts can be renamed freely. The +/name collision resolves
itself because Jellyfin only consults the plugin when no local user
matches the typed name.

Targets Jellyfin 10.11.x / net9.0. Adds Gitea CI (test, build, release),
a builder image, and 34 tests covering the auth and sync rules.
This commit is contained in:
2026-07-29 00:00:13 +02:00
parent 7a9dbdafcc
commit 7be07d16a2
46 changed files with 3319 additions and 690 deletions
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.WatchedTogether.Configuration;
/// <summary>
/// Plugin configuration. Holds the authoritative record of which members belong to which
/// shared account.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets the configured shared-account groups.
/// </summary>
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Plugin configuration is round-tripped by the XML serializer, which requires a settable List<T>.")]
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Plugin configuration is round-tripped by the XML serializer, which requires a settable List<T>.")]
public List<SharedGroup> Groups { get; set; } = new();
/// <summary>
/// Gets or sets the separator used to join member names into a shared account name. Also the
/// separator split at login when <see cref="EnableDynamicGroups"/> is on.
/// </summary>
public string NameSeparator { get; set; } = "+";
/// <summary>
/// Gets or sets a value indicating whether typing an unrecognised name like "alice+bob" at the
/// login screen creates the shared account on the spot.
/// </summary>
/// <remarks>
/// The account is only created if every named part is an existing, enabled, non-shared user
/// <em>and</em> the submitted password belongs to one of them. A real account whose name
/// happens to contain the separator always takes precedence, because Jellyfin only consults
/// this plugin once no local user matches the typed name.
/// </remarks>
public bool EnableDynamicGroups { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether accounts created on demand may access all libraries.
/// </summary>
/// <remarks>
/// Leaving this on means a dynamically created account sees every library, regardless of what
/// its members can each reach individually. Turn it off to have such accounts start with no
/// library access until an administrator grants it.
/// </remarks>
public bool DynamicGroupsEnableAllFolders { get; set; } = true;
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
namespace Jellyfin.Plugin.WatchedTogether.Configuration;
/// <summary>
/// The association between one shared account and the members who may unlock it.
/// </summary>
/// <remarks>
/// Membership is stored as GUIDs rather than being parsed out of the shared account's username.
/// The default separator ('+') is itself a legal username character, so a name like "alice+bob"
/// is ambiguous between the group [alice, bob] and a single user literally called "alice+bob".
/// GUIDs remove that ambiguity and support any number of members.
/// </remarks>
public class SharedGroup
{
/// <summary>
/// Gets or sets the identifier of the shared account that members log into collectively.
/// </summary>
public Guid SharedUserId { get; set; }
/// <summary>
/// Gets or sets the identifiers of the members whose passwords unlock the shared account,
/// and whose own accounts receive its watched state. A usable group has at least two.
/// </summary>
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Plugin configuration is round-tripped by the XML serializer, which requires a settable List<T>.")]
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Plugin configuration is round-tripped by the XML serializer, which requires a settable List<T>.")]
public List<Guid> MemberUserIds { get; set; } = new();
/// <summary>
/// Gets or sets a value indicating whether marking something unwatched on the shared account
/// also marks it unwatched for every member. When false, only the transition to watched
/// propagates.
/// </summary>
public bool SyncUnwatched { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether a member's play count is raised to at least one
/// when an item becomes watched. Play counts are never decremented.
/// </summary>
public bool SyncPlayCount { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this group is suspended. A group drops out of both
/// authentication and sync while disabled - set automatically if it falls below two members.
/// </summary>
public bool IsDisabled { get; set; }
}
@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Watched Together</title>
</head>
<body>
<div id="WatchedTogetherConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<div class="verticalSection">
<h2 class="sectionTitle">Watched Together</h2>
<p class="fieldDescription">
A shared account that several people log into with their own passwords. Anything
marked watched there is mirrored onto each member's own account.
This is not synchronized playback &mdash; for that, use Jellyfin's built-in SyncPlay.
</p>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Existing groups</h3>
<div id="groupsList"></div>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Create a group</h3>
<form id="CreateGroupForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="NewGroupName">Account name</label>
<input id="NewGroupName" name="NewGroupName" type="text" is="emby-input" />
<div class="fieldDescription">
Leave blank to join the member names with the separator below. Names are
cosmetic &mdash; membership is tracked internally, not parsed from the name.
</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="MemberSelect">Members (select at least two)</label>
<select is="emby-select" id="MemberSelect" multiple size="8"
class="emby-select-withcolor emby-select"></select>
<div class="fieldDescription">
Any selected member's password will unlock the shared account.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableAllFolders" type="checkbox" is="emby-checkbox" checked />
<span>Grant access to all libraries</span>
</label>
<div class="fieldDescription">
The shared account's library access is independent of each member's own
restrictions. If a member is normally blocked from a library but this
account is not, their password now reaches it.
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Create group</span>
</button>
</div>
</form>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Settings</h3>
<form id="SettingsForm">
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableDynamicGroups" type="checkbox" is="emby-checkbox" />
<span>Create groups automatically at login</span>
</label>
<div class="fieldDescription">
Typing an unrecognised name like <code>alice+bob</code> at the login
screen creates the shared account on the spot. Every name must belong to
an existing, enabled user, and the password must be one of theirs.
An existing account whose name contains the separator always wins.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="DynamicGroupsEnableAllFolders" type="checkbox" is="emby-checkbox" />
<span>Auto-created accounts can access all libraries</span>
</label>
<div class="fieldDescription">
Turn this off to have auto-created accounts start with no library access
until you grant it.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="NameSeparator">Name separator</label>
<input id="NameSeparator" name="NameSeparator" type="text" is="emby-input" maxlength="3" />
<div class="fieldDescription">
Joins member names into an account name, and is the character split at
login above. '+' is valid on current Jellyfin; use '_' or '-' if your
server rejects it.
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
</div>
<script type="text/javascript">
(function () {
var pluginUniqueId = 'aa3288a0-e8c1-43e2-8045-8c3411142a5b';
var page;
function apiUrl(path) {
return ApiClient.getUrl('Plugins/WatchedTogether/' + path);
}
function loadEligibleUsers() {
return ApiClient.getJSON(apiUrl('EligibleUsers')).then(function (users) {
var select = page.querySelector('#MemberSelect');
select.innerHTML = users.map(function (u) {
return '<option value="' + u.UserId + '">' + u.Username + '</option>';
}).join('');
});
}
function renderGroups(groups) {
var container = page.querySelector('#groupsList');
if (!groups.length) {
container.innerHTML = '<p class="fieldDescription">No groups configured yet.</p>';
return;
}
container.innerHTML = groups.map(function (g) {
var members = g.Members.map(function (m) { return m.Username; }).join(', ');
var status = g.IsDisabled ? ' <span style="opacity:.7">(disabled)</span>' : '';
return '<div class="listItem" style="padding:.6em 0;border-bottom:1px solid rgba(255,255,255,.1)">' +
'<h3 style="margin:0">' + g.SharedUsername + status + '</h3>' +
'<div class="fieldDescription">Members: ' + members + '</div>' +
'<div class="fieldDescription">' +
'Sync unwatched: ' + (g.SyncUnwatched ? 'yes' : 'no') +
' &middot; Sync play count: ' + (g.SyncPlayCount ? 'yes' : 'no') + '</div>' +
'<button is="emby-button" type="button" class="raised btnDeleteGroup" ' +
'data-id="' + g.SharedUserId + '" data-name="' + g.SharedUsername + '">' +
'<span>Delete</span></button>' +
'</div>';
}).join('');
container.querySelectorAll('.btnDeleteGroup').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
var name = btn.getAttribute('data-name');
// Deleting the account too is destructive, so make it an explicit choice.
Dashboard.confirm(
'Also delete the shared account "' + name + '"? Choose Cancel to keep the account and only remove the group.',
'Delete group',
function (deleteUser) {
var url = apiUrl('Groups/' + id + '?deleteSharedUser=' + (deleteUser ? 'true' : 'false'));
ApiClient.ajax({ type: 'DELETE', url: url }).then(function () {
Dashboard.alert('Group deleted.');
loadGroups();
loadEligibleUsers();
});
});
});
});
}
function loadGroups() {
return ApiClient.getJSON(apiUrl('Groups')).then(renderGroups);
}
document.querySelector('#WatchedTogetherConfigPage').addEventListener('pageshow', function () {
page = this;
Dashboard.showLoadingMsg();
Promise.all([
loadGroups(),
loadEligibleUsers(),
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
page.querySelector('#NameSeparator').value = config.NameSeparator || '+';
page.querySelector('#EnableDynamicGroups').checked = config.EnableDynamicGroups;
page.querySelector('#DynamicGroupsEnableAllFolders').checked = config.DynamicGroupsEnableAllFolders;
})
]).then(function () {
Dashboard.hideLoadingMsg();
}, function () {
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#CreateGroupForm').addEventListener('submit', function (e) {
e.preventDefault();
var selected = Array.prototype.slice
.call(page.querySelector('#MemberSelect').selectedOptions)
.map(function (o) { return o.value; });
if (selected.length < 2) {
Dashboard.alert('Select at least two members.');
return false;
}
Dashboard.showLoadingMsg();
ApiClient.ajax({
type: 'POST',
url: apiUrl('Groups'),
contentType: 'application/json',
data: JSON.stringify({
MemberUserIds: selected,
Name: page.querySelector('#NewGroupName').value || null,
EnableAllFolders: page.querySelector('#EnableAllFolders').checked,
EnabledFolders: null
})
}).then(function () {
Dashboard.hideLoadingMsg();
Dashboard.alert('Group created.');
page.querySelector('#NewGroupName').value = '';
loadGroups();
loadEligibleUsers();
}, function (response) {
Dashboard.hideLoadingMsg();
if (response && response.text) {
response.text().then(function (msg) {
Dashboard.alert({ title: 'Could not create group', message: msg });
});
} else {
Dashboard.alert('Could not create group.');
}
});
return false;
});
document.querySelector('#SettingsForm').addEventListener('submit', function (e) {
e.preventDefault();
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
config.NameSeparator = page.querySelector('#NameSeparator').value || '+';
config.EnableDynamicGroups = page.querySelector('#EnableDynamicGroups').checked;
config.DynamicGroupsEnableAllFolders = page.querySelector('#DynamicGroupsEnableAllFolders').checked;
ApiClient.updatePluginConfiguration(pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
return false;
});
})();
</script>
</div>
</body>
</html>