A shared account previously inherited its members' library access but none of their content restrictions, so a child could log into "alice+kid" with their own password and get around their own rating cap. The shared account now gets the strictest member's parental rating, unrated-item block, blocked tags and allowed tags, recomputed at creation, on membership change and at startup. An admin can raise the rating cap on a slider between the strictest and the loosest member; unrated and tag rules stay strictest-wins. What makes raising the cap safe is the unlock rule: after a member's password matches, both users' live policies are compared and the login is refused if the account is looser than the member on any field. So raising the cap above the child's rating means the child's password no longer opens the account, while the parent's still does. The same rule bounds the slider - past the loosest member nobody could unlock the account - so a chosen cap is clamped back into range whenever applied. Allowed tags need care: Jellyfin reads an empty list as "no whitelist", so an empty intersection of members' whitelists is written as a sentinel tag no item carries. Access schedules and channels are not inherited yet. The shared account is never an administrator. Groups created at the login screen always inherit and are restricted before the first session exists. The dashboard shows each member's cap, who a chosen cap shuts out, and the restrictions in effect, and gains a per-group edit form for the sync options. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
468 lines
24 KiB
HTML
468 lines
24 KiB
HTML
<!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,emby-slider">
|
|
<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="fieldDescription" style="margin:1em 0">
|
|
The shared account is granted only the libraries <em>every</em> member can
|
|
already reach, and inherits the strictest member's parental rating, unrated
|
|
block and tag rules. If one member is blocked from something, the group cannot
|
|
see it either, so sharing an account never grants anyone new access. To let a
|
|
group watch above a member's rating, raise its cap with the slider afterwards:
|
|
members stricter than the chosen cap then can no longer unlock the account.
|
|
</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="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('');
|
|
});
|
|
}
|
|
|
|
// Jellyfin's parental rating levels for this server, one entry per distinct score,
|
|
// ascending. Several names can share a score (e.g. "PG-13" and "TV-14").
|
|
var ratingLevels = [];
|
|
|
|
function loadRatingLevels() {
|
|
return ApiClient.getParentalRatings().then(function (ratings) {
|
|
var byValue = {};
|
|
ratings.forEach(function (r) {
|
|
if (r.Value === null || r.Value === undefined) { return; }
|
|
byValue[r.Value] = byValue[r.Value] || [];
|
|
byValue[r.Value].push(r.Name);
|
|
});
|
|
ratingLevels = Object.keys(byValue).map(function (v) {
|
|
return { value: Number(v), label: byValue[v].join(' / ') };
|
|
}).sort(function (a, b) { return a.value - b.value; });
|
|
}, function () {
|
|
// Names are cosmetic; scores still display without them.
|
|
});
|
|
}
|
|
|
|
function ratingLabel(score) {
|
|
if (score === null || score === undefined) { return 'No cap'; }
|
|
var level = ratingLevels.filter(function (l) { return l.value === score; })[0];
|
|
return level ? level.label : 'score ' + score;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return String(text).replace(/[&<>"']/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
});
|
|
}
|
|
|
|
// The positions the cap slider can take: every rating level from the strictest member
|
|
// up to the loosest, plus "No cap" when some member has none. Null when no member is
|
|
// capped, since there is then nothing to choose.
|
|
function capStops(g) {
|
|
if (g.StrictestMemberRating === null || g.StrictestMemberRating === undefined) {
|
|
return null;
|
|
}
|
|
var lo = g.StrictestMemberRating;
|
|
var hi = g.LoosestMemberRating;
|
|
var stops = ratingLevels.filter(function (l) {
|
|
return l.value >= lo && (hi === null || hi === undefined || l.value <= hi);
|
|
}).map(function (l) { return { value: l.value, label: l.label }; });
|
|
if (!stops.length || stops[0].value !== lo) {
|
|
stops.unshift({ value: lo, label: ratingLabel(lo) });
|
|
}
|
|
if (hi === null || hi === undefined) {
|
|
stops.push({ value: null, label: 'No cap' });
|
|
} else if (stops[stops.length - 1].value !== hi) {
|
|
stops.push({ value: hi, label: ratingLabel(hi) });
|
|
}
|
|
return stops;
|
|
}
|
|
|
|
function currentStop(g, stops) {
|
|
if (g.InheritParentalRating) { return 0; }
|
|
for (var i = 0; i < stops.length; i++) {
|
|
if (stops[i].value === g.ParentalRatingCap) { return i; }
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Who the account still unlocks for at a given cap: members no stricter than it.
|
|
function describeCap(g, stop, index) {
|
|
var can = [], cannot = [];
|
|
g.Members.forEach(function (m) {
|
|
var own = m.MaxParentalRating;
|
|
var ok = own === null || own === undefined || (stop.value !== null && own >= stop.value);
|
|
(ok ? can : cannot).push(escapeHtml(m.Username));
|
|
});
|
|
var text = '<strong>' + escapeHtml(stop.label) + '</strong>';
|
|
if (index === 0) {
|
|
text += ' — inherited from the strictest member. Every member can unlock the account.';
|
|
} else {
|
|
text += ' — unlocks for ' + can.join(', ') + '.';
|
|
if (cannot.length) {
|
|
text += ' <span style="opacity:.8">' + cannot.join(', ') +
|
|
(cannot.length === 1 ? '\'s password no longer opens' : ' can no longer open') +
|
|
' this account.</span>';
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function describeRestrictions(r) {
|
|
if (!r) { return 'Shared account not found.'; }
|
|
var parts = [];
|
|
if (r.BlockUnratedItems.length) {
|
|
parts.push('Unrated blocked: ' + escapeHtml(r.BlockUnratedItems.join(', ')));
|
|
}
|
|
if (r.BlockedTags.length) {
|
|
parts.push('Blocked tags: ' + escapeHtml(r.BlockedTags.join(', ')));
|
|
}
|
|
if (r.AllowsNothing) {
|
|
parts.push('<strong>Allowed tags have nothing in common: the account can see nothing</strong>');
|
|
} else if (r.AllowedTags.length) {
|
|
parts.push('Allowed tags: ' + escapeHtml(r.AllowedTags.join(', ')));
|
|
}
|
|
return parts.length ? parts.join(' · ') : 'No unrated or tag rules from members.';
|
|
}
|
|
|
|
function updateGroup(group, changes, onDone) {
|
|
Dashboard.showLoadingMsg();
|
|
var body = {
|
|
MemberUserIds: group.Members.map(function (m) { return m.UserId; }),
|
|
SyncUnwatched: group.SyncUnwatched,
|
|
SyncPlayCount: group.SyncPlayCount,
|
|
IsDisabled: group.IsDisabled,
|
|
InheritParentalRating: group.InheritParentalRating,
|
|
ParentalRatingCap: group.ParentalRatingCap
|
|
};
|
|
Object.keys(changes).forEach(function (k) { body[k] = changes[k]; });
|
|
ApiClient.ajax({
|
|
type: 'POST',
|
|
url: apiUrl('Groups/' + group.SharedUserId),
|
|
contentType: 'application/json',
|
|
data: JSON.stringify(body)
|
|
}).then(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
if (onDone) { onDone(); }
|
|
loadGroups();
|
|
}, function (response) {
|
|
Dashboard.hideLoadingMsg();
|
|
if (response && response.text) {
|
|
response.text().then(function (msg) {
|
|
Dashboard.alert({ title: 'Could not update group', message: msg });
|
|
});
|
|
} else {
|
|
Dashboard.alert('Could not update group.');
|
|
}
|
|
loadGroups();
|
|
});
|
|
}
|
|
|
|
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) {
|
|
var own = m.MaxParentalRating;
|
|
var cap = (own === null || own === undefined) ? 'no cap' : ratingLabel(own);
|
|
return escapeHtml(m.Username) + ' <span style="opacity:.7">(' + escapeHtml(cap) + ')</span>';
|
|
}).join(', ');
|
|
var status = g.IsDisabled ? ' <span style="opacity:.7">(disabled)</span>' : '';
|
|
var stops = capStops(g);
|
|
var capHtml;
|
|
if (!stops) {
|
|
capHtml = '<div class="fieldDescription">Parental rating: no member has a cap, so there is nothing to choose.</div>';
|
|
} else {
|
|
var idx = currentStop(g, stops);
|
|
capHtml = '<div class="sliderContainer-settings" style="margin-top:.6em">' +
|
|
'<label class="sliderLabel">Parental rating cap</label>' +
|
|
'<div style="display:flex;align-items:center;gap:.8em">' +
|
|
'<span class="fieldDescription" style="white-space:nowrap">' + escapeHtml(stops[0].label) + '</span>' +
|
|
'<input type="range" is="emby-slider" class="ratingCapSlider" data-id="' + g.SharedUserId + '" ' +
|
|
'min="0" max="' + (stops.length - 1) + '" step="1" value="' + idx + '" style="flex:1" />' +
|
|
'<span class="fieldDescription" style="white-space:nowrap">' + escapeHtml(stops[stops.length - 1].label) + '</span>' +
|
|
'</div>' +
|
|
'<div class="fieldDescription ratingCapText" data-id="' + g.SharedUserId + '">' + describeCap(g, stops[idx], idx) + '</div>' +
|
|
'</div>';
|
|
}
|
|
return '<div class="listItem" style="padding:.6em 0;border-bottom:1px solid rgba(255,255,255,.1)">' +
|
|
'<h3 style="margin:0">' + escapeHtml(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>' +
|
|
capHtml +
|
|
'<div class="fieldDescription" style="opacity:.8">' + describeRestrictions(g.Restrictions) + '</div>' +
|
|
'<div style="margin-top:.4em">' +
|
|
'<button is="emby-button" type="button" class="raised btnEditGroup" data-id="' + g.SharedUserId + '">' +
|
|
'<span>Edit</span></button> ' +
|
|
'<button is="emby-button" type="button" class="raised btnDeleteGroup" ' +
|
|
'data-id="' + g.SharedUserId + '" data-name="' + escapeHtml(g.SharedUsername) + '">' +
|
|
'<span>Delete</span></button>' +
|
|
'</div>' +
|
|
'<form class="editGroupForm" data-id="' + g.SharedUserId + '" style="display:none;margin:.8em 0 .4em 1em">' +
|
|
'<div class="checkboxContainer"><label class="emby-checkbox-label">' +
|
|
'<input type="checkbox" is="emby-checkbox" name="SyncUnwatched"' + (g.SyncUnwatched ? ' checked' : '') + ' />' +
|
|
'<span>Sync unwatched</span></label></div>' +
|
|
'<div class="checkboxContainer"><label class="emby-checkbox-label">' +
|
|
'<input type="checkbox" is="emby-checkbox" name="SyncPlayCount"' + (g.SyncPlayCount ? ' checked' : '') + ' />' +
|
|
'<span>Sync play count</span></label></div>' +
|
|
'<div class="checkboxContainer"><label class="emby-checkbox-label">' +
|
|
'<input type="checkbox" is="emby-checkbox" name="IsDisabled"' + (g.IsDisabled ? ' checked' : '') + ' />' +
|
|
'<span>Disabled</span></label></div>' +
|
|
'<button is="emby-button" type="submit" class="raised button-submit emby-button"><span>Save</span></button> ' +
|
|
'<button is="emby-button" type="button" class="raised btnCancelEdit emby-button"><span>Cancel</span></button>' +
|
|
'</form>' +
|
|
'</div>';
|
|
}).join('');
|
|
|
|
function groupById(id) {
|
|
return groups.filter(function (g) { return g.SharedUserId === id; })[0];
|
|
}
|
|
|
|
container.querySelectorAll('.ratingCapSlider').forEach(function (slider) {
|
|
var group = groupById(slider.getAttribute('data-id'));
|
|
var stops = capStops(group);
|
|
var text = container.querySelector('.ratingCapText[data-id="' + group.SharedUserId + '"]');
|
|
slider.getBubbleText = function (value) { return stops[Number(value)].label; };
|
|
// Describe while dragging; only save on release.
|
|
slider.addEventListener('input', function () {
|
|
var i = Number(slider.value);
|
|
text.innerHTML = describeCap(group, stops[i], i);
|
|
});
|
|
slider.addEventListener('change', function () {
|
|
var i = Number(slider.value);
|
|
updateGroup(group, {
|
|
InheritParentalRating: i === 0,
|
|
ParentalRatingCap: stops[i].value
|
|
});
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll('.btnEditGroup').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var form = container.querySelector('.editGroupForm[data-id="' + btn.getAttribute('data-id') + '"]');
|
|
form.style.display = form.style.display === 'none' ? '' : 'none';
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll('.btnCancelEdit').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
btn.closest('.editGroupForm').style.display = 'none';
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll('.editGroupForm').forEach(function (form) {
|
|
form.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
updateGroup(groupById(form.getAttribute('data-id')), {
|
|
SyncUnwatched: form.querySelector('[name=SyncUnwatched]').checked,
|
|
SyncPlayCount: form.querySelector('[name=SyncPlayCount]').checked,
|
|
IsDisabled: form.querySelector('[name=IsDisabled]').checked
|
|
});
|
|
return false;
|
|
});
|
|
});
|
|
|
|
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([
|
|
loadRatingLevels().then(loadGroups),
|
|
loadEligibleUsers(),
|
|
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
|
|
page.querySelector('#NameSeparator').value = config.NameSeparator || '+';
|
|
page.querySelector('#EnableDynamicGroups').checked = config.EnableDynamicGroups;
|
|
})
|
|
]).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
|
|
})
|
|
}).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;
|
|
ApiClient.updatePluginConfiguration(pluginUniqueId, config).then(function (result) {
|
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
|
});
|
|
});
|
|
|
|
return false;
|
|
});
|
|
})();
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|