Three separate reports handled together. Cast was video-only by design (docs/architecture.md flagged it as video-parties-only for v1) - asked for directly, and turned out to need almost nothing new server-side: CastTokenService and the 4 streaming routes' sessionForStream fallback were already keyed on nothing more specific than partyId. cast.ts's localDriver only needed its parameter widened from HTMLVideoElement to HTMLMediaElement - both already share the same currentTime/paused/duration/play()/pause() surface. MusicBar.ts gained the same activeDriver indirection Player.ts already has, with one real difference from the video case: a listen party's queue advances far more often than a video party changes items, so dropping the cast on every track (the video path's own choice there) would have made this worse than not having it - a track change while casting instead calls startCast() again, reusing the already-connected session (a cache-busting query param added since the content URL itself never changes between tracks - the server alone decides what's current). Falls back to local playback if a re-cast throws, same recovery instinct loadTrack's own hls.js try/catch already has. The queue popup: reported separately, a fixed 480px width and a header that scrolled away with the list - both leftover CSS from before the row shape was reworked, never revisited since. Width cap dropped; overflow/max-height moved off the outer .musicBarQueue (which wraps the header too) down onto #queueList alone. The catalog: still too slow, the front freezing while it loads. CatalogService.getLibraryItems was asking Emby for Overview on every one of up to 300 items and never using it (views/library's tiles only ever show a poster/name/year) - dropped. getArtists had no Limit at all, unlike every sibling query. Separately, tiles.ts's shared render path built one HTML string for a whole batch and inserted it via a single innerHTML write - fine for a 16-item carousel, a real hitch for a 300-tile grid. renderTilesInChunks inserts the same tileHtml output 40 at a time across requestAnimationFrame callbacks instead; wireTileButtons switched to one delegated listener so a tile from a later chunk is still clickable. Verified: full typecheck/test/build on both projects (122/122 server tests). The chunked-render mechanism was verified directly - a synthetic 300-item list against a real DOM confirms every item lands and a button in the last chunk still fires through the delegated listener. Not verified: no real Chromecast or Emby login reachable from this sandbox, so the actual on-TV re-cast round trip and the before/after catalog latency against a real large library are both unconfirmed - same standing gap this project already flags for its other casting work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
341 lines
19 KiB
Markdown
341 lines
19 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
|
|
- [ ] 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).
|