Files
dtourolle 5c8430f207
🏗️ Build Plugin / build (push) Successful in 38s
🧪 Test Plugin / test (push) Successful in 34s
Treat member order as insignificant when resolving a group
"jane+john" and "john+jane" name the same group, but they did not behave
that way. Jellyfin only routes a login here when no account matches the
typed name, so logging in with the reversed spelling of an existing group
found nothing and quietly created a second shared account for the same
two people - each with its own watched state.

Group identity is now order-independent:

- Member names are sorted alphabetically when building an account name,
  so a given set of members always produces the same name.
- Before creating anything, the login path looks for an existing group
  whose members are exactly the named set, compared as a set rather than
  a sequence, and logs into that account if it finds one.
- Stored member lists are kept in the same canonical order on create and
  update, so a group's stored order does not depend on the order an
  admin happened to select members in.

Passing no name through to provisioning lets it generate the canonical
name, rather than preserving whatever order was typed.

Members are also now checked in the order they were typed, stopping at
the first match, so whoever puts their own name first is verified first.
Verification is a deliberately slow hash comparison, so the ordering is
worth having; it is only a preference, and any member's password still
unlocks the group.
2026-07-31 09:35:14 +02:00

305 lines
13 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. Sharing an account is therefore never a way to reach a library you
were not already allowed into.
```
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.
### Watched-state sync
The plugin subscribes to `UserDataSaved` and filters tightly: only `PlaybackFinished`,
`TogglePlayed` and `Import` are acted on, so the constant stream of progress updates during playback
is ignored.
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
Download the release `.zip`, extract it into a `WatchedTogether` folder inside your Jellyfin
`plugins` directory, and restart the server.
---
## 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. |
### 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".
- **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.
- **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
| | |
| --- | --- |
| Target ABI | Jellyfin **10.11.x** |
| Framework | .NET 9 |
Verified against the 10.11.5 SDK: `IAuthenticationProvider` + `IRequiresResolvedUser`,
`ICryptoProvider.Verify`, `IUserDataManager.UserDataSaved`, and a 255-character username limit.
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 10.11.5; 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 plugin targets .NET 9. If your machine does not have that runtime, build in a container:
```bash
docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 \
dotnet test Jellyfin.Plugin.WatchedTogether.sln -c Release
```
Or natively, with the .NET 9 SDK installed:
```bash
dotnet build Jellyfin.Plugin.WatchedTogether.sln -c Release
dotnet test Jellyfin.Plugin.WatchedTogether.sln -c Release
```
To produce an installable plugin zip:
```bash
jprm plugin build .
```
### 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 |
| `release.yaml` | tag `v*.*.*` | Builds, creates a Gitea release, and updates `manifest.json` |
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).