Full rewrite, requested directly. Two independent decisions since TypeScript can't compile itself away at runtime - "no build step" and "TypeScript" are mutually exclusive, not a small addition to the existing setup: - Server: plain tsc (no bundling need - Node resolves its own requires). server/dist mirrors server/src 1:1. - Client: esbuild (does need bundling) - picked over a heavier tool (Vite, webpack) since this app's whole client is a couple thousand lines; its built-in CSS-from-JS bundling and a `text` loader for importing an HTML template as a string cover everything needed via a ~30-line hand-written build script (client/build.mjs), not a config file for something built for much bigger apps. Both projects compile independently (no shared-types package - considered and rejected as more structural commitment than this app's size justifies; server/src/types.ts and client/src/types.ts have their own overlapping DTO copies instead). server/'s `npm run dev` uses tsx for a compile-free dev loop; `npm run build` (tsc) is what server/Dockerfile's build stage actually runs. client/'s `npm run watch` is esbuild's own watch mode. Server: one singleton service class per feature, generalizing the PartyManager/MusicQueueService pattern that already existed, rather than route handlers with logic inline: - AuthService (from embyAuth.js), CatalogService (NEW - previously ~15 route handlers' worth of Emby queries/DTO shaping inlined directly in index.js), PartyService (renamed from PartyManager), MusicQueueService (ported as-is, already service-shaped), SocketGateway (from socketHandler.js), EmbyClient (from embyProxy.js - low-level plumbing, not called a "service" since it has no state/feature of its own). - index.ts is now thin wiring only: every route reads the session, calls one service method, returns the result. - Real error-handling pass, not just a port: Express 4 doesn't catch a rejected Promise from an async handler on its own - a new asyncRoute() wrapper + a single error-handling middleware replace what used to be zero handling for a request that outright throws. CatalogService methods throw a CatalogFetchError (own HTTP status - getRawItem now preserves Emby's real 404 instead of a blanket 502) rather than touching res directly. Client: one folder per view (src/views/<name>/<Name>.ts + .html + .css) instead of one .js file with the markup inline as a template literal. A view's static shell moved to its .html (esbuild's text loader); per-item dynamic content (tile grids, episode/track lists) stays as template -literal-returning functions in the .ts, same as before. Genuinely shared CSS (tiles, the presence avatar, the series/album detail-header pattern, track-list rows) lives under src/styles/, imported by whichever views need it; src/styles/base.css holds the global primitives, imported once by app.ts. Deliberately not pushed to zero duplication/renaming shared classes across views for tidiness alone - not worth the risk in a change already this large. Player.ts keeps its pre-existing internal shape (the /party/:id dispatcher + the video renderer in one file, importing renderMusicPlayer from the sibling folder) - that pattern predates this rewrite and only needed types, not restructuring. Two real things the port itself caught, not just carried over: - A type bug: porting SocketGateway's "members" broadcast, reusing PartyService's PublicMember interface would have type-checked fine but was the wrong (larger) shape - the socket broadcast never included isPaused/positionTicks, only the REST response did. Caught by comparing against the original JS line by line, fixed with a dedicated SocketMemberDto type. - A real Express+TypeScript friction point: noUncheckedIndexedAccess (enabled in both tsconfigs - genuinely useful elsewhere, e.g. PartyService.advanceQueue's array indexing) makes every req.params.xxx read a type error, since Express's index-signature typing doesn't know a matched route's required :name segment is always present. Resolved once with a param(req, name) helper rather than 15 routes' worth of scattered assertions. Verified: both projects type-check cleanly in strict mode (tsc --noEmit - not just "esbuild didn't complain", which it wouldn't even with type errors present, since it transpiles without checking). Server: every unit/integration test from the JS version (party lifecycle logging, the music queue's timer/vote/pause behavior incl. real setTimeout auto-advance, the socket integration through SocketGateway+PartyService+ MusicQueueService together, the library-type filter, the playback -override forcing) re-run against the compiled server/dist output - identical results; the compiled server was actually started and confirmed listening cleanly. Client: since esbuild bundles everything into one app.js (no more individually-importable view modules), testing moved to driving the real compiled bundle through its actual boot -> login -> router -> view chain (mocked fetch/Socket.IO/hls.js, real clicks/hash navigation) - arguably more realistic than the old per-module approach. Confirmed this way: login, the sidebar, home's carousels, a video party (presence, host controls, leave), the full music hierarchy, a music party (presence, vote-skip, pause toggle, the inline browse-and-add panel), and the series page - all through client/dist's actual output, no thrown errors anywhere. Not verified: an actual `docker build` of the new multi-stage Dockerfile - this dev environment has no Docker at all. The build stage runs the exact npm run build commands already confirmed working locally, just inside node:20-alpine - the main remaining risk is something environment -specific, not the build commands themselves, but a real build is still the first thing to try after this lands. docs/architecture.md has the full writeup (new "TypeScript and a service-oriented architecture" section, plus a corrected top-level "Components" overview that had gone stale even before this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10 KiB
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
- Sign in with your Emby account (just username/password — the server address is fixed server-side, see Deploying below).
- A sidebar lists your Emby libraries (movies, TV, music, music videos — 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.
- 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.
- 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 present shows up as a small avatar overlay on the video (their initials, on the player itself). If you're not the host, clicking your own avatar asks the host to pause — everyone sees the request, including the host, without needing chat.
- 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 has its own inline "Ajouter des morceaux" browser, so building the queue doesn't mean leaving the page.
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 compiled server passes the same
unit/integration tests the pre-rewrite JS did (run against the compiled
output, not the source), and the compiled+bundled client was driven
through every page and both players end to end (mocked network/Socket.IO)
in a real browser. 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.
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/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
src/views/player/Player.* the /party/:id route's dispatcher + the video player
src/views/musicPlayer/MusicPlayer.* the music half - now-playing, queue, inline browse+add
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.
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
- 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) + "request pause" in the player
- 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
- 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.