watchparty/README.md
Nicolas 11f003d269
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 16s
Sync watch-party progress back to Emby (PlaystateService)
This app has been read/proxy-only toward Emby so far. Queued right
after the playlist feature, asked for directly: while someone's in a
party, push their progress to Emby's own PlaystateService, so their own
Emby library (resume position, last-played state) stays in sync with
the watch/listen party, under their own account - not just the host's,
and not just this app's own memory of it.

Scoped to periodic progress reporting only for v1 - no explicit
start/stop calls (Emby's own "now playing" list won't show this app's
streams, and "mark fully watched" isn't guaranteed the way a clean Stop
near 100% would trigger it). Steady Progress calls alone already cover
the actual ask; wiring clean start/stop into every transition (item
change, leave, party destroy, a device takeover) is real complexity for
a secondary payoff - left as a documented follow-up.

- server/src/types.ts: PartyMember gains embyToken (null until known,
  same shape as this session's own deviceId addition) - never sent to
  the client, toPublicState's member mapping doesn't grow it.
- server/src/emby/EmbyClient.ts: fetch() now optionally takes
  method/body (JSON-encoded once, here) - every existing GET-only call
  site unaffected, both are optional.
- server/src/services/PlaystateService.ts (new): reportProgress(token,
  userId, itemId, positionTicks, isPaused) - POSTs to
  /Users/{userId}/PlayingItems/{itemId}/Progress. Catches and logs its
  own failures (new Logger.playstate) rather than throwing - this runs
  on a timer with nothing to catch a rejection, and one bad token or a
  transient Emby hiccup must never touch real playback.
- server/src/services/PartyService.ts: new setMemberEmbyToken (records
  a member's own token) and getCurrentPlaybackState (video: the host's
  own tracked position - there's no per-follower tracking today, a
  reasonable approximation given the player's own drift correction;
  music: the one shared, server-authoritative position every member
  already sees the same way). The one real piece of logic here is pure
  and unit-tested on its own, same shape as this session's
  absoluteToRelativeIndex/splitPlaylistByMediaType.
- server/src/realtime/SocketGateway.ts: attach()'s getSession callback
  widened to expose embyToken - it was already being passed the full
  EmbySession (AuthService.getSession), just typed down to {userId,
  userName} before anything downstream could read the rest. The "join"
  handler now also records member.embyToken alongside deviceId.
- server/src/index.ts: setMemberEmbyToken called after each of the 4
  REST create/join call sites, so a member has a valid token from the
  moment they're a member, not only once their socket first connects.
  New ~20s setInterval (same shape as the existing removeIdleParties/
  TranscodeService.reapIdleSessions timers): for every active party,
  getCurrentPlaybackState once, then reportProgress for every connected
  member with a known token.
- server/test/embyClient.test.ts, partyService.test.ts,
  playstateService.test.ts (new): the new body-encoding path (and that
  a plain GET stays untouched), getCurrentPlaybackState's video/music/
  empty branches, setMemberEmbyToken, and that reportProgress's request
  shape is right and a thrown failure never propagates.
- docs/architecture.md, README.md: design + the deliberate v1 scope and
  the follower-position approximation spelled out.

No client changes - this is a pure server-side background process with
nothing for the browser to render or react to.

Verified: full typecheck+test+build on both projects (71/71 server
tests, 9 new). No Emby server is reachable from this dev environment,
so the actual endpoint/body shape is unverified end-to-end - flagged
explicitly in the docs, the third such Emby-API assumption this session
(after the ffmpeg direct-stream URL and the Playlists endpoint, both
fine once actually tried).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 19:21:16 +02:00

262 lines
14 KiB
Markdown

# Watch Party for Emby
A standalone web app for watching the same title, in sync, with other users
of the same Emby server. It reads your Emby catalog and streams your Emby
media, but it's its own app with its own player — not an Emby plugin.
See [docs/architecture.md](docs/architecture.md) for the full design,
including why this replaced an earlier Emby-plugin attempt (still in
[src/WatchParty/](src/WatchParty/) for reference — see below).
## How it works
1. Sign in with your Emby account (just username/password — the server
address is fixed server-side, see Deploying below).
2. A sidebar lists your Emby libraries (movies, TV, music, music videos,
playlists if you have any — anything else Emby might have, like
photos or a boxset collection, isn't shown); the home page shows every ongoing
watch party live (host, how long it's been running, member count) plus a
"recently added" row per library, Emby-style. No join codes — if a party
is running, it's already on the list, updating in real time.
3. Pick a movie/episode tile and hit "Lancer une watch party" (or drill into
a Series' seasons/episodes first) — you become the host — or hit
"Rejoindre" on any party already listed on the home page. Already
connected to that same party from another device? You're asked to
confirm before it gets disconnected — reconnecting from the same
device (a reload, a second tab) never asks.
4. The host's play/pause/seek drives everyone else's player automatically;
starting from an episode queues the rest of the series so the host can
just hit "Épisode suivant" instead of restarting a party each time. If
the host leaves, the party hands off to another member automatically
(their player unlocks live, no reload needed) — only closing once
everyone's gone.
5. Everyone present shows up as a small avatar overlay on the video (their
initials, on the player itself). If you're not the host, a small
toolbar lets you ask the host to pause or resume — everyone sees the
request, including the host, without needing chat.
6. Subtitles render natively in the browser for any text-based track. A
Blu-ray/DVD-style image subtitle (PGS/VobSub/DVB) is different — the
host can pick one to have it burned into the video by this app's own
ffmpeg, shared live by everyone in the party (audio/quality selection
is fixed while that's active, and it restarts the stream for
everyone, so it's a host-only choice, like play/pause already is).
7. Music works differently, on purpose: pick a track (browsing Artist →
Album → Track) to start a listening party. There's no host for
playback — anyone can add more tracks to the shared queue at any time,
anyone can pause/resume, and skipping the current track needs a
majority vote from whoever's currently listening. The player itself is
a persistent bar docked at the bottom of the screen, not a page — the
sidebar and the rest of the app stay fully usable while it plays, so
adding more tracks is just browsing to an album and clicking a track
(its button reads "Ajouter à la file" instead of "Écouter ensemble"
once a party's already going). Starting or joining a video watch party
always leaves the listen party first — the two don't run at once.
8. A playlist works the same idea as a series: open it to see what's
inside, then "Lancer cette playlist" starts one party pre-loaded with
everything in it — a video queue if it's movies/episodes, or the
participative music queue if it's tracks.
9. While you're in a party, your own progress is quietly pushed back to
your own Emby account (resume position, last-played state) — so
opening Emby's own app afterward reflects what you actually watched
or listened to, not just this app's own memory of it.
Everything — catalog, images, video, audio, sync — flows through this
app's own backend, which proxies whatever it needs from Emby server-side.
The browser never talks to Emby directly and never holds an Emby access
token; it only ever has a session cookie for this app.
## Status
Login, and the rendering/navigation logic for every page (sidebar, home,
library, series, artist/album, both players), have been exercised end to
end (login against the real Emby server with intentionally-wrong
credentials; the pages against mocked API responses; the music queue's
timer/vote/pause logic against the real server modules directly - no Emby
involved there at all). See
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for
exactly what that does and doesn't confirm — the real Emby response shapes
this now depends on (library `CollectionType`, seasons/episodes, artists/
albums/tracks, `PlaybackInfo`'s media streams) and actual HLS playback
(video or audio) are still unverified.
The TypeScript rewrite itself is separately verified: both projects
type-check in strict mode, the server has a real automated test suite
(`server/test/*.test.ts`, Node's built-in test runner, see Running
locally below) covering the party lifecycle, the music queue's
timer/vote/pause behavior, the socket protocol, and the catalog/streaming
overrides, and the compiled+bundled client was driven through every page
and both players end to end (mocked network/Socket.IO) in a real browser.
CI (Forgejo Actions) runs that same typecheck+test+build sequence on
every push and gates the Docker image publish on it passing — see
[docs/architecture.md](docs/architecture.md#continuous-integration).
**Not verified**: an actual `docker build` of the new multi-stage
Dockerfile - this dev environment has no Docker - so treat the first real
build as the thing to double-check, even though it mirrors exactly the
`npm run build` commands already confirmed working locally for both
projects. Same caveat, doubled, for the self-hosted ffmpeg burn-in
pipeline (see docs/architecture.md): no Emby server *or* ffmpeg binary
is reachable here either, so while its pure logic (stream-index
conversion, session lifecycle) is unit-tested with `child_process.spawn`
mocked, the actual command against a real PGS file - and the exact
shape of Emby's direct-stream URL it depends on - has never run for
real. Treat the first real attempt at a Blu-ray-style subtitle as the
thing to double-check here too. Same again for the Emby-playstate sync
(the exact `PlaystateService` endpoint/body shape) and the Playlists
endpoint - three Emby-API assumptions total now waiting on a real
server to actually confirm, all otherwise built and unit-tested the
same way.
## Project layout
Both `server/` and `client/` are TypeScript now (see
[docs/architecture.md](docs/architecture.md#typescript-and-a-service-oriented-architecture)
for the why and the migration itself) - each its own project with its own
`package.json`/`tsconfig.json`/build step; `server/src` and `client/src`
are the only source of truth, `server/dist`/`client/dist` are generated
(gitignored) and never hand-edited.
```
server/ Node.js + Express + Socket.IO backend: proxies ALL Emby
access (auth, catalog, images, HLS streaming) and
coordinates party/room state + realtime sync. The
browser only ever talks to this. TypeScript, compiled by
tsc to server/dist.
src/index.ts Express app + route wiring only - thin,
delegates to the services below
src/config.ts env-derived config (EMBY_SERVER_URL, PORT)
src/types.ts shared interfaces (Party, ItemDto, the
Socket.IO event maps, ...)
src/Logger.ts party/stream lifecycle logging
src/emby/EmbyClient.ts low-level Emby HTTP + response proxying (incl. HLS)
src/services/AuthService.ts server-side login + session store
src/services/CatalogService.ts all Emby browsing/streaming + DTO shaping
src/services/PartyService.ts in-memory party lifecycle (video queue, host handoff)
src/services/MusicQueueService.ts participative music queue - see docs/architecture.md
src/realtime/SocketGateway.ts Socket.IO protocol (see comments in the file)
client/ Frontend: TypeScript, esbuild-bundled to client/dist
(one app.js + one app.css - see client/build.mjs).
Talks only to server/ - never to Emby directly. Each
view is its own folder colocating its .ts/.html/.css.
src/app.ts entry point - registers routes, boots the app
src/router.ts tiny hash router (unchanged in spirit - see docs)
src/types.ts shared interfaces (client-side copies of the
server's DTO shapes - see docs/architecture.md)
src/api/embyApi.ts calls our own /api/catalog/* + auth routes
src/api/party.ts our own backend's party REST + Socket.IO client
src/tiles.ts + tiles.css poster-tile rendering shared by home/library/artist
src/styles/ base.css (global reset/vars/primitives) +
small shared component styles (presence,
detail header, track list)
src/shell/Shell.* persistent sidebar + #mainContent the router swaps
src/musicBar/MusicBar.* persistent bottom-bar listen party - NOT a route,
mounted once by Shell.ts so it survives navigation
(see docs/architecture.md's "Music bar" section)
src/views/login/Login.* sign-in form
src/views/home/Home.* live parties carousel + per-library "recently added"
src/views/library/Library.* one library's tile grid (or artist grid, for music)
src/views/series/Series.* Series detail + season tabs + episode list
src/views/artist/Artist.* one artist's albums
src/views/album/Album.* one album's tracks - each track starts or queues
onto the active listen party (see musicBar/ above)
src/views/player/Player.* the /party/:id route's dispatcher (video takeover,
or hands off to musicBar/ + bounces back for music)
src/WatchParty/ The earlier Emby-plugin attempt (C#, unrelated to the
TypeScript above). Builds fine and installs, but Emby
has no supported extension point for a player button,
and even menu integration required reverse-engineering
undocumented internals - see docs/architecture.md for
the full story. Kept for reference; not the active
direction.
docs/architecture.md full design doc for the standalone app
```
## Running locally
Both projects need `npm install` once; the server needs the client
already built at least once (it serves `client/dist` as static files).
```bash
cd client && npm install && npm run build && cd ..
cd server && npm install && npm run build
EMBY_SERVER_URL=https://your-emby-server npm start
```
Then open `http://localhost:3000`.
For active development, `client`'s `npm run watch` rebuilds on save, and
`server`'s `npm run dev` runs the server straight from TypeScript via
`tsx` (no separate compile step needed while iterating) - run both in
separate terminals.
Server tests (`server/test/*.test.ts`, Node's built-in test runner, no
extra dependencies or compile step):
```bash
cd server && npm test
```
`server`'s `npm run typecheck` also covers `server/test/`, not just `src/`
(see `tsconfig.test.json`).
## Deploying
Build the Docker image from the **repo root** (it needs both `server/` and
`client/`) - the Dockerfile compiles both TypeScript projects itself in a
build stage, so nothing needs building beforehand:
```bash
docker build -f server/Dockerfile -t watchparty .
docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
```
Then put it behind your existing reverse proxy like everything else
(`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g.
`watchparty.kyuno.fr` → `http://<container>:3000`.
## Roadmap
- [x] Design + scaffold the standalone app
- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
- [x] TV show navigation (Series → Seasons → Episodes) + episode auto-queue
- [x] Player language/subtitle/quality pickers
- [x] Socket.IO instead of raw WebSockets; fixed two real memory leaks
(dead-connection cleanup server-side, view teardown on navigation
client-side) — see docs/architecture.md
- [x] Live "watch parties en cours" list (host, elapsed time, member count),
live host handoff, no join codes
- [x] Presence overlay (avatars) + a request toolbar (pause/resume) in the
player — see docs/architecture.md
- [x] Device detection: reconnecting from the same device is silent, a
genuinely different device asks to confirm before disconnecting the
other one — see docs/architecture.md
- [x] Server-side logging of the party lifecycle (create/join/leave/destroy,
host handoff, play/pause, pause requests) — see docs/architecture.md
- [x] Music: dedicated player, Artist/Album/Track browsing, participative
queue (free add, vote-to-skip, no host) — see docs/architecture.md
- [x] Full TypeScript rewrite (client + server), component-style client
views (.ts/.html/.css each), server-side singleton services per
feature — see docs/architecture.md
- [x] Automated server test suite (`node:test`) + CI job gating the Docker
publish on typecheck+test+build passing — see docs/architecture.md
- [x] Music player as a persistent bottom bar instead of a page — browse
and queue tracks while listening; starting a video party always
leaves the listen party first — see docs/architecture.md
- [x] Self-hosted ffmpeg burn-in for PGS/image subtitles (Emby's own
transcode can't render these as text) — one shared stream per
party, host-driven — see docs/architecture.md
- [x] Browse Emby playlists and launch one as a pre-queued party (video
queue or the participative music queue) — see docs/architecture.md
- [x] Sync watch-party progress back to Emby's own PlaystateService, per
member's own account — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Tune transcoding target / support direct play when possible
No chat, no OIDC — deliberately out of scope (Emby-only auth was the
explicit choice; see docs/architecture.md).
## License
MIT — see [LICENSE](LICENSE).