Files
WatchedTogether/README.md
T
dtourolle 7be07d16a2
🏗️ Build Plugin / build (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled
Implement Watched Together shared viewing accounts
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.
2026-07-29 00:00:13 +02:00

290 lines
12 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.
```
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.
4. Only then 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.
#### 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 `+`)
- **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 joined with `+`.
4. Decide whether the account should see all libraries (see the security note below).
5. 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. |
| Auto-created accounts can access all libraries | on | Whether accounts made at the login screen start with full library access. Turn off to grant access deliberately. |
| Name separator | `+` | The character joining member names, and the one split at login. Use `_` or `-` if you prefer. |
---
## Security notes
Please read this before granting a shared account broad library access.
- **Access is a union, and it is deliberate.** Any member's password opens the shared account, and
that account sees whatever libraries *you* granted *it*, independent of each member's own
restrictions. If a member is normally blocked from a library but the shared account is not, that
member's password now reaches it. Set the shared account's library access accordingly.
- **Auto-creation grants library access without an admin in the loop.** With both
*Create groups automatically at login* and *Auto-created accounts can access all libraries* on,
any user who knows a colleague's username can pair it with their own and reach a full-library
account. That is a real privilege escalation if your libraries are not uniformly visible. It is
still gated on a valid member password — nobody gets in without one — but if per-user library
restrictions matter to you, turn off *Auto-created accounts can access all libraries* (or
auto-creation entirely) and provision groups from the dashboard.
- **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).