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,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>