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.
290 lines
12 KiB
Markdown
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).
|