Inherit parental restrictions on shared accounts, with a chosen rating cap

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>
This commit is contained in:
2026-09-19 12:30:50 +02:00
co-authored by Claude Opus 5
parent 0c5fc9487c
commit 27cd2a3d37
24 changed files with 1950 additions and 69 deletions
+51 -3
View File
@@ -44,8 +44,13 @@ The sync is **one-way**: shared account → members. What Alice watches privatel
never leaks into the shared account or onto Bob.
Library access is the **intersection** of the members', never the union: the group sees only what
everyone in it could already see. Sharing an account is therefore never a way to reach a library you
were not already allowed into.
everyone in it could already see. Parental restrictions work the same way: the shared account
inherits the *strictest* member's rating cap, unrated-item block and tag rules. Sharing an account
is therefore never a way to reach something you were not already allowed to see.
A parent can deliberately **raise** a group's rating cap to watch something above a child's rating
together. When they do, the child's own password stops unlocking the shared account, so raising
the cap never becomes a way around it.
```
login as "alice+bob+carol"
@@ -139,6 +144,35 @@ Two consequences worth knowing:
*different* member's password happened to be the one that matched, eventually locking out
members who did nothing wrong.
A matching password is not the whole story. A member may only unlock a shared account that is **at
least as restricted as they are**: after the password matches, both users' *live* policies are
compared field by field (rating cap, unrated block, blocked tags, allowed tags) and the login is
refused if the account is looser on any of them. With an inherited cap this always passes. With a
chosen cap, the passwords of members stricter than it simply stop working on the group — logged at
Information level, since a child trying the family account is expected, not an incident.
### Content restrictions
`RestrictionService` mirrors the library-access code for the parental fields on a user:
| Field | Combination |
| --- | --- |
| Parental rating cap (score, sub-score) | Lowest wins. Any cap beats none; at an equal score, any sub-cap beats none. |
| Blocked unrated kinds | Union. |
| Blocked tags | Union. |
| Allowed tags (whitelist) | Only members *with* a whitelist constrain; their lists are intersected. An empty list in Jellyfin means "no whitelist", so when two whitelists have nothing in common the account is written a single tag no item carries (`watched-together:nothing`) — it must see nothing, not everything. |
A member that cannot be resolved contributes "fully restricted", on the same principle as
library access. Access schedules and channel restrictions are **not** inherited yet.
The result is written to the shared account at creation, on every membership change, and at
server startup — overwriting whatever was set on the account in the user editor. The one thing
an admin can choose is the **rating cap**, anywhere between the strictest member's and the loosest
member's; unrated and tag rules stay strictest-wins regardless. A chosen cap is kept inside that
range whenever it is applied: at or below the strictest member it is simply inheritance, and past
the loosest member nobody could unlock the account, so it is pulled back (and logged). The shared
account is never an administrator.
### Watched-state sync
The plugin subscribes to `UserDataSaved` and decides per save reason what it means:
@@ -214,6 +248,10 @@ Either way, a new user appears in your user list and can be renamed like any oth
| Sync unwatched | on | Marking something *unwatched* on the shared account also marks it unwatched for every member. Turn this off to make sync additive: things only ever become watched. |
| Sync play count | off | Raise a member's play count to at least 1 when an item becomes watched. Play counts are never decreased. |
| Disabled | off | Suspends a group: it stops accepting logins and stops syncing, without deleting anything. |
| Parental rating cap | strictest member | A slider from the strictest member's rating to the loosest member's (or "No cap"). At the left end the cap is inherited and every member can unlock the account. Move it right to let the group watch above a member's rating: members stricter than the chosen cap can no longer unlock the account with their password. The dashboard says who is in and who is out at each position. Not shown when no member has a cap. |
Unrated-item blocks and tag rules are always the strictest member's; the dashboard shows what is
in effect under each group.
### Plugin settings
@@ -238,9 +276,19 @@ How this plugin bounds what a shared account can reach.
Members with nothing in common produce an account that sees nothing.
- **Blocked folders stay blocked.** An explicitly blocked library is subtracted even from a member
who otherwise has "access to all libraries".
- **Parental restrictions are inherited, strictest wins.** Rating cap, unrated-item block, blocked
and allowed tags are combined so the shared account hides at least everything any member cannot
see. Access schedules and channel restrictions are not inherited yet.
- **Raising the cap cannot be used to get around it.** A member can only unlock a shared account
that is at least as restricted as they are, checked against live policies at every login. A
child's password stops opening a group the parent raised above the child's rating; the parent's
still does. And the cap can never go past the loosest member's — there would be nobody left who
could unlock it.
- **A shared account is never an administrator.**
- **The intersection is recomputed, not frozen.** It is recalculated whenever a group's membership
changes, and re-applied to every group at server startup, so narrowing a member's own access
narrows the groups they belong to.
narrows the groups they belong to. Between restarts, the unlock rule covers the gap for
restrictions: a member whose cap was lowered is refused until the group is recomputed.
- **Disabled members are excluded.** A disabled Jellyfin user can no longer unlock the shared
account, and no longer receives watched state.
- **Shared accounts cannot be nested.** A shared account may not be a member of another group; this