watchparty/README.md
Nicolas 3ae1fce806
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Fix device takeover leaving both devices on the session with no working controls
Two gaps around an already-correct takeover mechanism, not the
mechanism itself. First: Player.ts/MusicBar.ts both wired onClose to a
no-op, so the moment a takeover force-disconnected the old device its
video/hls.js instance (or the persistent music bar) just kept running
forever on a dead connection - both devices looked fully active, since
nothing ever told the old one to stop or navigate away. Second: the
obvious fix (call the same leave path Quitter uses) ran into a worse
bug - POST /leave removes a member by userId alone with no staleness
guard, so the old devices own leave call would have ended the NEW
devices membership too. Fixed both: onClose now tears down local
playback and leaves whenever this routes own explicit teardown has not
already run, and the leave route (plus the client, which now sends its
own deviceId) gained the same staleness guard the socket handlers
already had, via a new PartyService.isStaleDevice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:23:22 +02:00

412 lines
24 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. Everyone also shares the exact same stream from
Emby — one transcode per party, not one per person — so the host's
language/quality pick applies for the whole party too.
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 are a host-only, party-wide pick too, like the
language/quality pick above — the host picks one (or "Aucun") and
everyone's player follows. Any text-based track just renders
natively in each viewer's own browser, in sync (no reload, no extra
server load — only *which* one is synced). A Blu-ray/DVD-style image
subtitle (PGS/VobSub/DVB) is different — picking one has this app's
own ffmpeg burn it into the video, shared live by everyone in the
party, which does restart the stream for everyone since the video
itself changes.
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 for the self-hosted ffmpeg burn-in pipeline (see
docs/architecture.md): no ffmpeg binary is reachable here, 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 has never run in this environment. Treat the first real
attempt at a Blu-ray-style subtitle as the thing to double-check here.
**Resolved**: three Emby-API assumptions that used to be listed here as
unconfirmed (the ffmpeg direct-stream URL, the Playlists endpoint, the
Emby-playstate sync endpoint) were all checked directly against Emby's
own real OpenAPI spec. Two matched exactly. The third - playstate sync
- took three rounds: two request-shape bugs against Emby's
`PlayingItems/Progress` check-in endpoint (a missing `MediaSourceId`,
then a missing `PlaySessionId`), then a further report ("still don't
see this in my Emby profile") that questioned the whole approach - a
bare `Progress` report with no matching "Start" call may just never
reach the resume-position/watched-history data real clients read,
regardless of request shape. Switched entirely to a plain
`POST /Users/{userId}/Items/{itemId}/UserData` instead - confirmed
directly against the real server that this is exactly the data model
"Continue Watching" and the watched checkmark read, updated with no
playback session required at all - and added marking an item Played
(the other half of the same report) when the party moves past it.
Not yet confirmed end to end against a real server (no safe way to
fire a real, state-mutating test call without one) - see
docs/architecture.md's "Sync watch-party progress back to Emby"
section for the full story. The one-shared-stream-per-party work is a
different kind of unverified: its own Emby API calls were already live,
confirmed code, just newly shared instead of per-viewer - what's untested is behavior
under real multi-viewer load (whether a dead Emby session actually
surfaces as an HTTP failure the way the cache-invalidation logic
assumes, how a real transcode's seek handling feels once several
viewers depend on the exact same one).
Casting to a Chromecast (see docs/architecture.md) is the newest,
least-verified piece, for video and music alike: the security-critical
parts were checked against the real running server directly (the
party-stream/burn routes reject a request with no auth exactly as
before, and now also reject an invalid `castToken`, both a plain 401)
and the Cast SDK itself was confirmed to load and initialize in a real
browser, but there's neither a real Chromecast nor Emby login
credentials in this environment to take it any further - the actual
on-TV playback, the cast button's own live behavior, and (for music) a
re-cast actually surviving a real track change are all unconfirmed.
## 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 (all three are now
host-only, party-wide choices — see docs/architecture.md's "One
shared Emby stream per party" and "Text subtitles join burn-in as
a party-wide, host-only pick")
- [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), either from the start or
from any chosen item ("Lire à partir d'ici") — see
docs/architecture.md
- [x] Sync watch-party progress back to Emby's own PlaystateService, per
member's own account, including marking an item watched once the
party moves past it — see docs/architecture.md
- [x] One shared Emby stream per party instead of one per viewer — see
docs/architecture.md
- [x] Text subtitle choice joins burn-in as a host-only, party-wide pick
(was still each viewer's own local choice until now); fixed the
audio picker briefly showing an empty option on first load — see
docs/architecture.md
- [x] Fixed catalog browsing feeling slow: image thumbnails were never
cacheable (Emby's own Cache-Control/ETag were silently dropped) —
see docs/architecture.md
- [x] Cast a party's video *or* listen-party music to a Chromecast
(Default Media Receiver), with a play/pause/seek remote on the
sender's own screen — music reuses the same `PlaybackDriver`/
`CastTokenService` the video case introduced, and re-casts
in place on every track change instead of dropping the session
the way an item change does for video — see docs/architecture.md
- [x] Fixed the listen-party queue's broken layout (it was reusing an
album page's row grid), its missing album art (a track almost
never has its own image — now falls back to its album's), and,
reported later, its fixed 480px width and a header that scrolled
away with the queue — see docs/architecture.md
- [x] Fixed a slow, freeze-prone catalog: a library grid's query fetched
an unused `Overview` field for every item, its artist list had no
cap at all, and up to 300 tiles were inserted in one synchronous
pass — see docs/architecture.md
- [x] Reworked the video player's bottom bar (episode/request actions/
fullscreen, one bar instead of three floating pieces) and added a
real fullscreen toggle — fullscreen now targets the whole player,
not just the `<video>`, so the request buttons stay visible; the
bar now also auto-hides down to just its seek line after a
stretch of no activity, so it stops masking subtitles — see
docs/architecture.md
- [x] Retired the browser's native `<video controls>` (it was rendering
as its own always-on-top bar, duplicating the cast icon and
overlapping the new bottom bar in fullscreen) in favor of fully
custom play/pause, a seek bar, and mute — see docs/architecture.md
- [x] Sticky search bar on the library/artist/album pages, and letting
anyone seek within the current music track (same no-host-check
model pause/resume already had) — see docs/architecture.md
- [x] Skip intro / jump to next episode at end credits, using Emby's own
native marker detection (`EnableMarkerDetection` + its "Detect
Episode Intros" task) rather than building fingerprinting
ourselves — see docs/architecture.md
- [x] Report each party member's own playback to Emby's real Sessions
API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin
dashboard sees watch-party streams the same way it sees a native
client's — one session per member, under their own account — see
docs/architecture.md
- [x] Queue a whole album, or an artist's entire discography, in one
action — resolved server-side (one atomic operation, not one
request per track) the same way launching a playlist already
expands into everything it contains — see docs/architecture.md
- [x] Picture-in-picture for the video player (standard
`requestPictureInPicture`, confirmed present on Android Chrome) —
asked for directly for mobile, where there's no second window to
put this app in while switching apps — see docs/architecture.md
- [x] Installable as a PWA (manifest + icons, standalone display,
no service worker — this app has nothing worth caching offline)
— see docs/architecture.md
- [x] Mobile navigation redesigned as a bottom sheet (Accueil /
Bibliothèques / Listes de lecture) instead of a horizontally
scrolling sidebar — see docs/architecture.md
- [x] Music UI reworked to look like a music app instead of reusing
movie-poster tiles — circular artist tiles, square album tiles,
a real hero header, and a mobile "now playing" full-screen player
— see docs/architecture.md
- [x] Mobile menu's bottom sheet is now swipe-to-close (a real drag
handle, not just a decorative grabber) instead of fighting the
browser's own pull-to-refresh gesture — see docs/architecture.md
- [x] Every blocking `alert()` (host handoff, every "couldn't start/join
a party" error) replaced with a light, auto-dismissing toast — see
docs/architecture.md
- [x] Library/artist grid tiles shrink fluidly on a narrow phone instead
of a fixed 160px floor collapsing to one column per row — see
docs/architecture.md
- [x] Library page title now stays pinned alongside the search bar while
scrolling, instead of just the search bar — see docs/architecture.md
- [x] Library alphabet index: sticky per-letter section headings plus a
right-edge A-Z rail to jump straight to one, drag-to-scrub like
iOS's own Contacts — see docs/architecture.md
- [x] Fixed the alphabet index's headers landing in the wrong order —
it was bucketing by display name instead of Emby's own SortName,
so an article-stripped title ("The ...") threw a whole run off —
see docs/architecture.md
- [x] Fixed the music bar's queue popup — a missing `position` broke
the desktop layout when opened and left it unclosable on mobile
(its own close button was reachable now) — see docs/architecture.md
- [x] Fixed library tile buttons rendering on top of the sticky header
while scrolling — an equal z-index tie that a scrolling tile won
— see docs/architecture.md
- [x] Fixed the music bar's desktop layout for real — the progress bar
was missing a grid-column span, so it (and not just the queue
popup above) was still squeezed and wrapping controls onto a
second row — see docs/architecture.md
- [x] Fixed an album's track rows collapsing to 3 lines on mobile —
there was always room for one, the breakpoint just assumed
otherwise — see docs/architecture.md
- [x] A mobile banner nudging an install of the PWA — the real
Chrome/Android install prompt, manual instructions on iOS Safari
(no such prompt exists there) — see docs/architecture.md
- [x] Linked whatever's playing (video or music) to the OS's own media
controls (Windows' SMTC, macOS' Now Playing, ...) — title/artwork,
play/pause/seek/next — see docs/architecture.md
- [x] Fixed switching a party between devices leaving both "on the
session" with no working controls — the old device never reacted
to being kicked, and the leave route had no way to tell a stale
device apart from the current one — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session)
- [ ] 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).