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>
386 lines
19 KiB
Markdown
386 lines
19 KiB
Markdown
<h1 align="center">Watched Together</h1>
|
|
|
|
<p align="center">
|
|
A Jellyfin plugin that lets several people share one viewing account,
|
|
while everyone's watched list stays their own.
|
|
</p>
|
|
|
|
---
|
|
|
|
## The problem
|
|
|
|
You have one television and one Jellyfin login on it. Two, three, four people use it.
|
|
|
|
Whoever's account is signed in on that TV accumulates everything: *Continue Watching* fills with
|
|
someone else's half-finished documentaries, *Next Up* suggests episode 4 of a series you never
|
|
started, and the person whose account it is can no longer tell what they have actually seen.
|
|
|
|
The usual workarounds are all bad:
|
|
|
|
- **Everyone shares one account permanently.** Nobody's watched list means anything any more.
|
|
- **Everyone logs out and back in.** Nobody does this, especially not on a TV remote.
|
|
- **Everyone gets their own profile on the TV.** Same problem, switching is friction, so people stop.
|
|
|
|
## What this plugin does
|
|
|
|
It creates a **shared account**, a real Jellyfin user that several people log into together; and gives it three special behaviours:
|
|
|
|
**1. The account creates itself when you log in.**
|
|
At the login screen, type `alice+bob` as the username and *your own* password. If no such account
|
|
exists yet, the plugin checks that `alice` and `bob` are both real users and that the password you
|
|
typed is one of theirs — then creates the shared account and signs you straight into it. No
|
|
dashboard visit, no admin, no setup step. Next time, it is just there.
|
|
|
|
**2. Any member's own password unlocks it.**
|
|
Alice types her password, Bob types his, and both get into the same shared account. Nobody has to
|
|
remember a new credential, and there is no shared password written on a sticky note.
|
|
|
|
**3. Whatever gets watched there is mirrored back to each member's own account.**
|
|
Finish an episode on the shared account and it is marked watched for Alice *and* Bob, on their
|
|
individual accounts. Their personal *Continue Watching* and *Next Up* stay correct, and when they
|
|
watch alone on their phone, the series picks up where the group left off.
|
|
|
|
The sync is **one-way**: shared account → members. What Alice watches privately is her business and
|
|
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. 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"
|
|
with any one member's password
|
|
│
|
|
▼ (creates the account if it does not exist yet)
|
|
┌──────────────────┐
|
|
alice's password │ │ played ──► alice's account
|
|
bob's password ──►│ shared account │ played ──► bob's account
|
|
carol's password │ "alice+bob+carol"│ played ──► carol's account
|
|
└──────────────────┘
|
|
any one unlocks it watched state flows outward only
|
|
```
|
|
|
|
### This is not SyncPlay
|
|
|
|
Jellyfin already has **SyncPlay**, which keeps playback *synchronized in time* across devices so
|
|
people in different places press play together.
|
|
|
|
Watched Together solves a different problem: people watching *the same screen* who want their
|
|
*individual watched lists* to stay accurate. The two are complementary and can be used together.
|
|
|
|
---
|
|
|
|
## How it works
|
|
|
|
### Creating a group by logging in
|
|
|
|
When you submit a username Jellyfin does not recognise, it offers the login to every enabled
|
|
authentication plugin before giving up. That is the hook this plugin uses.
|
|
|
|
On an unrecognised name, it:
|
|
|
|
1. Splits the name on the separator (`+` by default) — `alice+bob+carol` → three parts.
|
|
2. Requires **every part** to be an existing, enabled user that is not itself a shared account.
|
|
3. Requires the submitted password to match **one of those members'** stored hashes. Members are
|
|
checked in the order you typed them and the check stops at the first match, so putting your own
|
|
name first is marginally quicker.
|
|
4. Looks for an existing group with exactly those members. If one exists, you are logged into it.
|
|
5. Otherwise creates the shared account, and returns its name so Jellyfin completes the login.
|
|
|
|
Step 3 is what stops this being an open door: knowing two usernames is not enough to bring an
|
|
account into being. If any check fails, the plugin declines and the login fails exactly as an
|
|
ordinary typo would.
|
|
|
|
#### Order does not matter
|
|
|
|
`john+jane` and `jane+john` are the same group. Member names are sorted alphabetically to build the
|
|
account name, and the lookup in step 4 compares members as a set, so both spellings resolve to one
|
|
account rather than creating a second one for the same two people. The account itself is named with
|
|
the sorted spelling — `jane+john` — whichever order you happened to type.
|
|
|
|
#### The name collision, and why it is harmless
|
|
|
|
`+` is a legal Jellyfin username character:
|
|
|
|
```
|
|
^(?!\s)[\w \-'._@+]+(?<!\s)$
|
|
```
|
|
|
|
So `alice+bob` is ambiguous in principle — it could mean the group [`alice`, `bob`], or a single
|
|
real user literally named `alice+bob`.
|
|
|
|
In practice the ambiguity resolves itself: **Jellyfin only consults this plugin when no local user
|
|
matches the typed name.** A real account named `alice+bob` is found first and logs in normally,
|
|
never reaching the splitting logic. The group interpretation is only ever tried for a name that
|
|
belongs to nobody.
|
|
|
|
If you would rather avoid the situation entirely, change the separator to `_` or `-` in settings,
|
|
or switch auto-creation off and provision groups from the dashboard instead.
|
|
|
|
### Membership is stored as user IDs, not re-parsed from the name
|
|
|
|
Once a group exists, its membership lives in plugin configuration as a **list of user IDs**, keyed
|
|
by the shared account's ID. That list is authoritative and **nothing at runtime parses the username
|
|
again** — so you can freely rename a shared account to `Movie Night` and everything keeps working.
|
|
The name is only ever read at the moment of creation.
|
|
|
|
### Authentication
|
|
|
|
The shared account's `AuthenticationProviderId` points at this plugin, so Jellyfin routes only these
|
|
accounts to it. On login the plugin walks the member list and checks the submitted password against
|
|
each member's **live stored hash**, using Jellyfin's own `ICryptoProvider`.
|
|
|
|
Two consequences worth knowing:
|
|
|
|
- **No duplicated credentials.** There is no second copy of anyone's password anywhere. When a
|
|
member changes their password, the change takes effect immediately.
|
|
- **No lockout side effects.** The plugin deliberately does *not* re-enter Jellyfin's normal
|
|
`AuthenticateUser` flow. Doing so would trip every member's failed-attempt counter each time a
|
|
*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:
|
|
|
|
- `TogglePlayed` and `Import` are explicit - someone set the flag - so whatever it says is mirrored,
|
|
unwatched included (subject to *Sync unwatched*).
|
|
- `PlaybackFinished`, `PlaybackProgress` and `UpdateUserData` only ever mirror *watched*. Jellyfin
|
|
raises `PlaybackFinished` on every stop, not just on completion, so a stop halfway through leaves
|
|
`Played` false without anyone having marked anything unwatched; propagating that would wipe what a
|
|
member watched on their own. Acting on progress too means the tick lands the moment the completion
|
|
threshold is crossed, and still lands for clients that never report a stop.
|
|
- Everything else (`PlaybackStart`, `UpdateUserRating`) is ignored.
|
|
|
|
A member is written the way Jellyfin's own *mark played* writes - `Played`, `LastPlayedDate` and a
|
|
cleared resume position - not just the flag. *Next Up* is computed from `LastPlayedDate` on the
|
|
member's own row, so a bare tick would leave their Next Up stuck on the wrong episode. Members whose
|
|
row already matches are skipped, which keeps the progress ticks after the threshold write-free.
|
|
|
|
No feedback loop is possible: writing to a member raises the event again with *that member's* ID,
|
|
which is not a shared account, so the handler stops immediately.
|
|
|
|
---
|
|
|
|
## Installation
|
|
|
|
### From the plugin repository
|
|
|
|
Add this repository URL in **Dashboard → Plugins → Repositories**:
|
|
|
|
```
|
|
https://gitea.tourolle.paris/dtourolle/WatchedTogether/raw/branch/master/manifest.json
|
|
```
|
|
|
|
Then install **Watched Together** from the catalogue and restart Jellyfin.
|
|
|
|
### Manual
|
|
|
|
Each release ships two `.zip` files: one for Jellyfin **10.11** and one for Jellyfin **12** (the
|
|
version's last segment says which: `x.y.z.11` or `x.y.z.12`). Download the one matching your
|
|
server, extract it into a `WatchedTogether` folder inside your Jellyfin `plugins` directory, and
|
|
restart the server. The plugin repository picks the right one for you automatically.
|
|
|
|
---
|
|
|
|
## Setting up a group
|
|
|
|
### The quick way: just log in
|
|
|
|
On the shared device, at the Jellyfin login screen:
|
|
|
|
- **Username:** `alice+bob` (the members' usernames, joined with `+`, in any order)
|
|
- **Password:** your own
|
|
|
|
That is the whole setup. The account is created on first use and reused from then on. Add a third
|
|
person later by logging in once as `alice+bob+carol`.
|
|
|
|
### The dashboard way
|
|
|
|
If you would rather provision groups explicitly — or you have turned auto-creation off:
|
|
|
|
1. Go to **Dashboard → Plugins → Watched Together**.
|
|
2. Under **Create a group**, select **two or more** members.
|
|
3. Optionally give the account a name. Left blank, the member names are sorted alphabetically and
|
|
joined with `+`.
|
|
4. Click **Create group**.
|
|
|
|
Either way, a new user appears in your user list and can be renamed like any other.
|
|
|
|
### Per-group options
|
|
|
|
| Option | Default | Meaning |
|
|
| --- | --- | --- |
|
|
| 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
|
|
|
|
| Setting | Default | Meaning |
|
|
| --- | --- | --- |
|
|
| Create groups automatically at login | on | Enables the `alice+bob` login flow described above. Turn off to require dashboard provisioning. |
|
|
| Name separator | `+` | The character joining member names, and the one split at login. Use `_` or `-` if you prefer. |
|
|
|
|
There is no library-access setting: a shared account always receives exactly the intersection of its
|
|
members' access. See the security notes below.
|
|
|
|
---
|
|
|
|
## Security notes
|
|
|
|
How this plugin bounds what a shared account can reach.
|
|
|
|
- **Library access is an intersection, never a union.** A shared account is granted only the
|
|
libraries that *every* member can already reach. If Alice is blocked from a library, no group
|
|
containing Alice can see it — even if everyone else can. Joining a group can therefore never grant
|
|
anyone access they did not already have, which is what makes auto-creation safe to leave on.
|
|
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. 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
|
|
is rejected at creation time.
|
|
- **Brute-force protection differs.** Because authentication bypasses Jellyfin's standard login
|
|
path (see above), the shared account does not inherit Jellyfin's built-in lockout counter.
|
|
- **Nothing sensitive is logged.** Submitted passwords and stored hashes are never written to logs.
|
|
|
|
---
|
|
|
|
## Compatibility
|
|
|
|
| Jellyfin | Framework | Built against | Package version |
|
|
| --- | --- | --- | --- |
|
|
| **10.11.x** | .NET 9 | `Jellyfin.Controller` 10.11.5 | `x.y.z.11` |
|
|
| **12.x** | .NET 10 | `Jellyfin.Controller` 12.0.0 | `x.y.z.12` |
|
|
|
|
One source tree, one build per server generation. Jellyfin 12 turned `IUserManager.Users` and
|
|
`UsersIds` into methods, made `ChangePassword` take a user id, dropped `HasPassword` from
|
|
`IAuthenticationProvider`, and stopped caching users in memory (every lookup is a detached copy).
|
|
Those differences live behind a `JELLYFIN_12` compile constant in
|
|
[Compat/UserManagerCompat.cs](Jellyfin.Plugin.WatchedTogether/Compat/UserManagerCompat.cs); the
|
|
rest of the plugin is identical on both.
|
|
|
|
Jellyfin installs the highest manifest version whose `targetAbi` it satisfies, which is why the two
|
|
packages of a release carry different version numbers: a 10.11 server only sees the `.11` entry,
|
|
while a 12 server sees both and takes the `.12` one.
|
|
|
|
Auto-creation depends on `UserManager.AuthenticateUser` offering unmatched usernames to every
|
|
enabled provider and re-querying the database afterwards ("the authentication provider might have
|
|
created it"). That behaviour is present in both 10.11.5 and 12.0; if a future release changes it,
|
|
auto-creation stops working and dashboard provisioning continues to.
|
|
|
|
Jellyfin's plugin API changes across minor versions, `IServerEntryPoint` gave way to
|
|
`IHostedService` around 10.9, and entity types moved namespaces in 10.11. Expect to rebuild against
|
|
the matching SDK when upgrading the server.
|
|
|
|
---
|
|
|
|
## Building
|
|
|
|
The solution multi-targets `net9.0` (Jellyfin 10.11) and `net10.0` (Jellyfin 12), so it needs the
|
|
.NET 10 SDK, which builds both. The test host rolls the `net9.0` run forward onto the .NET 10
|
|
runtime, so a single runtime is enough. If your machine does not have it, build in a container:
|
|
|
|
```bash
|
|
docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:10.0 \
|
|
dotnet test Jellyfin.Plugin.WatchedTogether.sln -c Release
|
|
```
|
|
|
|
Or natively, with the .NET 10 SDK installed:
|
|
|
|
```bash
|
|
dotnet build Jellyfin.Plugin.WatchedTogether.sln -c Release
|
|
dotnet test Jellyfin.Plugin.WatchedTogether.sln -c Release
|
|
```
|
|
|
|
Tests run once per framework; the `net10.0` run has one test fewer because `HasPassword` no longer
|
|
exists on Jellyfin 12's provider contract.
|
|
|
|
To produce installable plugin zips, one per Jellyfin generation:
|
|
|
|
```bash
|
|
scripts/package.sh 10.11 0.0.5.11 # -> artifacts/watched-together_0.0.5.11.zip
|
|
scripts/package.sh 12 0.0.5.12 # -> artifacts/watched-together_0.0.5.12.zip
|
|
```
|
|
|
|
The script wraps `jprm plugin build`, stamping `build.yaml` with the right framework and
|
|
`targetAbi` for the chosen generation and restoring it afterwards.
|
|
|
|
### CI
|
|
|
|
Gitea Actions workflows live in [.gitea/workflows/](.gitea/workflows/):
|
|
|
|
| Workflow | Trigger | Does |
|
|
| --- | --- | --- |
|
|
| `test.yaml` | push / PR | Debug build and test run, uploads `.trx` results |
|
|
| `build.yaml` | push / PR to `master` | Release build, tests, and a date-versioned plugin zip per Jellyfin generation |
|
|
| `release.yaml` | tag `vX.Y.Z` | Builds `X.Y.Z.11` and `X.Y.Z.12`, creates a Gitea release with both, and adds both to `manifest.json` |
|
|
|
|
Release tags are three-part (`v0.0.5`); the fourth segment is reserved for the Jellyfin generation.
|
|
|
|
All three run in the builder image defined by [Dockerfile.builder](Dockerfile.builder):
|
|
|
|
```bash
|
|
docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/watchedtogether-builder:latest .
|
|
docker push gitea.tourolle.paris/dtourolle/watchedtogether-builder:latest
|
|
```
|
|
|
|
---
|
|
|
|
## License
|
|
|
|
GPL-3.0. See [LICENSE](LICENSE).
|