Emby Server plugin: watch party feature (session sync via Emby's remote-control commands) + companion web control panel
Find a file
Nicolas f78b2f1408
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 20s
One shared Emby stream per party, not one per viewer
Asked for directly: exactly one stream should ever be open between this
app and Emby per party, no matter how many members it has. Before this,
every viewer's own <video>/<audio> independently negotiated its own
PlaybackInfo and got its own PlaySessionId - a party of 5 meant Emby
transcoding the same title 5 separate times.

A bigger, higher-regression-risk change than most of this session's
other work: it replaces the core playback path every existing party
already relies on (CatalogService.proxyStream and its route are gone
entirely) rather than adding something alongside it - there's no other
consumer of the per-viewer path, and the ask was explicit that there
should only ever be one mechanism.

- server/src/services/CatalogService.ts: proxyStream's negotiate-then-
  build-a-target logic extracted into negotiateStreamTarget(session,
  itemId, isAudio, audioStreamIndex, maxStreamingBitrate) - identical
  logic, returns the target instead of proxying it directly so it can
  be cached. proxyStream/proxyRawStreamAsset removed.
- server/src/services/PartyStreamService.ts (new): one negotiated
  target per party.id, reused by every member; renegotiates when the
  desired item/audio/quality changes. Recovers from a dead upstream
  session with no new plumbing - EmbyClient.proxyResponse already sets
  res.statusCode before writing the body, so a failed proxied response
  is detected right after it resolves and the cache entry is dropped,
  so the next request (a manual retry, or the next segment fetch)
  renegotiates fresh instead of repeating the same dead target forever.
- server/src/emby/EmbyClient.ts: rewritePlaylist/proxyResponse's
  trailing itemId param genericized to routeKey - it's only ever read
  back to build the raw-asset URL, so it's party.id for the new stream
  routes and an inert real itemId for proxyImage/proxySubtitle (which
  never reach that branch anyway).
- server/src/index.ts: GET /api/catalog/party-stream/:partyId/
  master.m3u8 and .../raw replace /api/catalog/stream/:itemId/...
  entirely, membership-checked like the burn-in routes; a new idle-reap
  interval for PartyStreamService, same shape as TranscodeService's own.
- Forced consequence of "one stream", not a free design choice: audio
  track/quality become a host-only, party-wide choice for video -
  Party.streamAudioIndex/streamQuality, setStreamOptions/
  streamOptionsChanged, byte-for-byte the same shape
  setBurnInSubtitle/burnInChanged already established for burn-in
  subtitles. server/src/realtime/SocketGateway.ts gains the host-only,
  video-only handler; server/src/services/PartyService.ts resets
  streamAudioIndex (not streamQuality - a bandwidth preference isn't
  item-specific) on advanceQueue, same reasoning as burnIn* already
  gets; MusicQueueService.playNext ends the party's cached session on
  a track change.
- client/src/api/embyApi.ts: streamUrl/audioStreamUrl replaced by one
  partyStreamUrl(partyId) - no query params needed any more, the party
  itself is the source of truth for what it's streaming.
- client/src/api/party.ts: setStreamOptions/onStreamOptionsChanged.
- client/src/views/player/Player.ts: audio/quality <select>s disabled
  for a follower (applyStreamOptionsGating, mirroring
  renderSubtitleOptions' own gating), restore the party's current
  choice on load/broadcast, and a host's change calls setStreamOptions
  instead of reloading locally - every member (host included) only
  ever reacts to the resulting broadcast, same discipline the rest of
  this player already uses. Dead reloadWithCurrentOptions removed.
- client/src/musicBar/MusicBar.ts: loadTrack points at
  partyStreamUrl(activePartyId) - no picker to gate, MusicBar never had
  one.
- server/test/catalogService.test.ts: proxyStream's old cases moved to
  exercise negotiateStreamTarget instead, plus new quality-override and
  no-source-fails cases. server/test/partyStreamService.test.ts (new):
  cache hit/miss/param-change/item-change, the failure-invalidation
  path, endSession, idle reap. server/test/socketGateway.test.ts:
  setStreamOptions host-only gating + broadcast, mirroring the existing
  setBurnInSubtitle tests.
- docs/architecture.md, README.md: design, the forced audio/quality
  trade-off spelled out, and what's still unverified (a different kind
  than this session's other Emby-API guesses - the calls themselves
  were already live code, what's untested is real multi-viewer load).

Verified: full typecheck+test+build on both projects (85/85 server
tests, 12 new). A real-browser check (mocked-fetch + real bundle, this
session's standing harness) confirmed a follower's audio/quality
selects render disabled and a host's render enabled, from the same
party data with only youAreHost flipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 20:05:34 +02:00
.forgejo/workflows Add a real server test suite and gate CI's Docker publish on it 2026-09-14 09:57:47 +02:00
.github/workflows Scaffold Emby Watch Party plugin 2026-09-13 14:02:43 +02:00
client One shared Emby stream per party, not one per viewer 2026-09-14 20:05:34 +02:00
docs One shared Emby stream per party, not one per viewer 2026-09-14 20:05:34 +02:00
server One shared Emby stream per party, not one per viewer 2026-09-14 20:05:34 +02:00
src/WatchParty Fix the real crash: partyPage.html was missing data-controller 2026-09-13 20:17:14 +02:00
.dockerignore Rewrite server + client in TypeScript, service-oriented server architecture 2026-09-14 09:38:14 +02:00
.gitignore Rewrite server + client in TypeScript, service-oriented server architecture 2026-09-14 09:38:14 +02:00
docker-compose.yml Pivot to a standalone app - Emby plugin hit a hard wall 2026-09-13 20:48:55 +02:00
LICENSE Scaffold Emby Watch Party plugin 2026-09-13 14:02:43 +02:00
README.md One shared Emby stream per party, not one per viewer 2026-09-14 20:05:34 +02:00

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 for the full design, including why this replaced an earlier Emby-plugin attempt (still in 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 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 (picking one 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 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. 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. 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).

Project layout

Both server/ and client/ are TypeScript now (see docs/architecture.md 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).

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):

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:

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

  • Design + scaffold the standalone app
  • Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
  • TV show navigation (Series → Seasons → Episodes) + episode auto-queue
  • Player language/subtitle/quality pickers (language/quality are now host-only, party-wide choices — see docs/architecture.md's "One shared Emby stream per party")
  • 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
  • Live "watch parties en cours" list (host, elapsed time, member count), live host handoff, no join codes
  • Presence overlay (avatars) + a request toolbar (pause/resume) in the player — see docs/architecture.md
  • 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
  • Server-side logging of the party lifecycle (create/join/leave/destroy, host handoff, play/pause, pause requests) — see docs/architecture.md
  • Music: dedicated player, Artist/Album/Track browsing, participative queue (free add, vote-to-skip, no host) — see docs/architecture.md
  • Full TypeScript rewrite (client + server), component-style client views (.ts/.html/.css each), server-side singleton services per feature — see docs/architecture.md
  • Automated server test suite (node:test) + CI job gating the Docker publish on typecheck+test+build passing — see docs/architecture.md
  • 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
  • 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
  • Browse Emby playlists and launch one as a pre-queued party (video queue or the participative music queue) — see docs/architecture.md
  • Sync watch-party progress back to Emby's own PlaystateService, per member's own account — see docs/architecture.md
  • One shared Emby stream per party instead of one per viewer — 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.