Rewrite server + client in TypeScript, service-oriented server architecture
All checks were successful
Publier l'image Docker / publish (push) Successful in 18s

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>
This commit is contained in:
Nicolas 2026-09-14 09:38:14 +02:00
parent efaa7f55f0
commit 4a31ee741e
76 changed files with 5567 additions and 3286 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
# Never actually COPYed into the image (server/Dockerfile copies specific
# paths, not the whole tree) - excluded anyway so `docker build` doesn't
# waste time uploading them as part of the build context.
**/node_modules/
server/dist/
client/dist/
.git/
.claude/

8
.gitignore vendored
View file

@ -10,11 +10,17 @@ obj/
*.swp
.claude/
## Node (server/)
## Node (server/, client/)
node_modules/
.env
.env.local
## TypeScript/esbuild build output - server/Dockerfile builds these itself
## (multi-stage), so they're never committed - see docs/architecture.md.
server/dist/
client/dist/
*.tsbuildinfo
## OS cruft
.DS_Store
Thumbs.db

107
README.md
View file

@ -58,53 +58,103 @@ 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](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 backend: proxies ALL Emby access (auth, catalog,
images, HLS streaming) and coordinates party/room state +
realtime sync. The browser only ever talks to this.
src/index.js Express app + REST endpoints
src/embyAuth.js server-side login against Emby + session cookies
src/embyProxy.js generic Emby request/response proxying (incl. HLS)
src/socketHandler.js Socket.IO protocol (see comments in the file)
src/partyManager.js in-memory room state
src/musicQueueService.js participative music queue - see docs/architecture.md
src/logger.js party lifecycle logging (docker logs/journalctl-friendly)
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/ Static frontend (plain JS, ES modules, no build step).
Talks only to server/ - never to Emby directly.
js/shell.js persistent sidebar + #mainContent the router swaps
js/tiles.js poster-tile rendering shared by home.js and library.js
js/embyApi.js calls our own /api/* routes (auth, catalog, playback options)
js/party.js our own backend's party REST + Socket.IO client
js/views/ one file per screen: login, home, library, series, player,
artist, album, musicPlayer
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. 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.
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 server
npm install
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/`):
`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 .
@ -131,6 +181,9 @@ Then put it behind your existing reverse proxy like everything else
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
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Tune transcoding target / support direct play when possible

47
client/build.mjs Normal file
View file

@ -0,0 +1,47 @@
// Client build: TypeScript (+ per-component .html/.css) -> client/dist,
// via esbuild. Kept as a small hand-written script rather than a config
// file for a heavier tool (Vite, webpack) - this app's whole client is a
// couple thousand lines across a dozen files, esbuild's own bundling
// (including its built-in "collect CSS imported from JS into a sibling
// .css file" behavior) is all it needs, consistent with this project's
// general bias against dependencies it doesn't need.
//
// Usage: node build.mjs [--watch]
// npm run build -> one-off build (what server/Dockerfile's build stage runs)
// npm run watch -> rebuilds on save, for local dev (see README's
// Running locally section)
import * as esbuild from 'esbuild';
import { copyFileSync, mkdirSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const here = path.dirname(fileURLToPath(import.meta.url));
const outdir = path.join(here, 'dist');
mkdirSync(outdir, { recursive: true });
copyFileSync(path.join(here, 'src', 'index.html'), path.join(outdir, 'index.html'));
const buildOptions = {
entryPoints: [path.join(here, 'src', 'app.ts')],
outfile: path.join(outdir, 'app.js'),
bundle: true,
format: 'esm',
target: 'es2020',
sourcemap: true,
// Each component imports its own template as a raw string (`import html
// from './Foo.html'`) - see global.d.ts. CSS imports need no loader
// entry: esbuild bundles any CSS reached from a JS/TS entry point into a
// sibling app.css automatically.
loader: { '.html': 'text' }
};
const watch = process.argv.includes('--watch');
if (watch) {
const ctx = await esbuild.context(buildOptions);
await ctx.watch();
console.log('[watchparty-client] watching for changes...');
} else {
await esbuild.build(buildOptions);
console.log('[watchparty-client] built to client/dist');
}

View file

@ -1,812 +0,0 @@
/* Colors pulled live from this Emby server's own active theme (dark), via
its CSS custom properties (--theme-primary-color-*, --background-*, etc.)
- see docs/architecture.md for how these were read directly off the
running server rather than guessed. */
:root {
color-scheme: dark;
--bg: #151216; /* hsl(285, 10%, 7.84%) - Emby's dark theme background */
--bg-header: #221d24; /* same hue, +5% lightness - Emby's header/footer background */
--panel: #262626; /* Emby's card/button/input background (achromatic, ~15-17% lightness) */
--text: #ffffff;
--muted: rgba(255, 255, 255, .6);
--accent: #52b54b; /* Emby's theme-primary-color (hsl(116, 41.7%, 50.2%)) */
--accent-bright: #6ccf65; /* Emby's --theme-accent-text-color-darkbg */
--danger: #ff6b6b;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
#app { min-height: 100%; }
/* Login is a small centered form - everything else lives inside the app
shell (sidebar + .mainContent), see below. */
.wrap {
max-width: 420px;
margin: 0 auto;
padding: 32px 20px 80px;
}
h1 { font-size: 22px; margin: 0; }
h2 { font-size: 16px; margin: 0 0 12px; }
.panel {
background: var(--bg-header);
border-radius: 12px;
padding: 20px;
}
.hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
.error { color: var(--danger); font-size: 13px; min-height: 18px; }
label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
input {
display: block;
width: 100%;
margin-top: 4px;
padding: 10px;
border-radius: 8px;
border: 1px solid #3a3a3a;
background: var(--panel);
color: var(--text);
font-size: 14px;
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
padding: 12px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button:hover { background: var(--accent-bright); }
button[disabled] { opacity: .5; cursor: default; }
/* Class-only (not tag-qualified) so an <a class="linklike"> - e.g. the
"Voir sur Emby" link on the series detail page - gets the same treatment
as the <button> ones without needing its own duplicate rule; there's no
global `a` reset in this file, so an unclassed anchor would otherwise
fall back to the browser's default blue/underline against this dark
theme. */
.linklike {
background: none;
color: var(--muted);
font-size: 12px;
font-weight: 400;
text-decoration: underline;
padding: 4px 0;
}
.linklike:hover { background: none; color: var(--accent-bright); }
.code { color: var(--accent-bright); letter-spacing: .1em; }
/* ---- App shell: sidebar + routed main content ---- */
.appShell {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 220px;
flex-shrink: 0;
background: var(--bg-header);
padding: 20px 12px;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebarBrand {
font-size: 18px;
font-weight: 700;
padding: 0 12px 20px;
}
.sidebarNav {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.navSectionLabel {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .05em;
padding: 16px 12px 6px;
}
.navItem {
display: block;
padding: 10px 12px;
border-radius: 8px;
color: var(--text);
text-decoration: none;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.navItem:hover { background: rgba(255, 255, 255, .06); }
.navItem.active { background: var(--accent); color: white; font-weight: 600; }
.sidebarLogout { margin-top: 12px; padding: 8px 12px; }
.mainContent {
flex: 1;
min-width: 0;
padding: 24px clamp(16px, 3vw, 48px) 80px;
}
/* Sidebar becomes a horizontal scrollable topbar on narrow screens - same
"scroll instead of wrap" treatment as .tabs below. */
@media (max-width: 720px) {
.appShell { flex-direction: column; }
.sidebar {
width: 100%;
height: auto;
flex-direction: row;
align-items: center;
padding: 10px 12px;
overflow-x: auto;
overflow-y: hidden;
}
.sidebarBrand { padding: 0 12px 0 0; }
.sidebarNav { flex-direction: row; flex: none; }
.navSectionLabel { display: none; }
.sidebarLogout { margin: 0 0 0 12px; }
}
.pageHeader {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.joinInline { display: flex; gap: 8px; align-items: center; }
.joinInline input { width: 140px; margin: 0; }
.joinInline button { padding: 10px 14px; }
.section { margin-bottom: 32px; }
/* ---- Horizontal carousels (home page) ---- */
.carousel {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 10px;
}
.carousel .tile { flex: 0 0 160px; }
.carousel .partyCard { flex: 0 0 280px; }
.partyCard {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-header);
border-radius: 10px;
padding: 10px;
}
.partyCard img {
width: 48px;
height: 72px;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.partyCard img.noImage { visibility: hidden; }
.partyCardInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.partyCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.partyCard button { flex-shrink: 0; padding: 8px 14px; font-size: 13px; }
/* Tabs: one per Emby library (library.js) or per season (series.js). */
.tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
margin-bottom: 20px;
border-bottom: 1px solid rgba(255,255,255,.1);
}
.tab {
background: none;
color: var(--muted);
font-weight: 600;
padding: 8px 4px;
border-radius: 0;
white-space: nowrap;
border-bottom: 2px solid transparent;
}
.tab:hover { background: none; color: var(--text); }
.tab.active { color: var(--accent-bright); border-bottom-color: var(--accent-bright); }
/* ---- Tile grid (library.js) + tiles (shared with the carousels) ---- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 20px 16px;
}
.tile {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
/* Without this, a long title (or an image's intrinsic size before it
loads) can force this flex/grid item wider than its declared size -
the classic "min-width: auto" flexbox/grid pitfall. Every tile must
stay exactly the track/flex-basis size regardless of content. */
min-width: 0;
}
.tile img {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
display: block;
}
.tile img.noImage { visibility: hidden; }
.tileLink {
position: absolute;
inset: 0 0 26px 0;
z-index: 1;
}
.tileLabel {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Hover-reveal overlay with the two actions, on devices that actually have
hover (mouse/trackpad). Touch devices get an always-visible bar instead -
see the (hover: none) block below, since :hover is unreachable there. */
.tileOverlay {
position: absolute;
inset: 0 0 26px 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 8px;
padding: 10px;
background: rgba(0, 0, 0, .55);
border-radius: 8px;
opacity: 0;
transition: opacity .15s ease;
pointer-events: none;
}
.tile:hover .tileOverlay,
.tile:focus-within .tileOverlay {
opacity: 1;
pointer-events: auto;
}
/* Base look is the same solid, high-contrast treatment as every other
button in the app (see the global `button {}` rule above) - but that
rule is tag-scoped, so it never applied to "Voir les épisodes", the one
.tileBtn that's an <a>, not a <button> (a plain hash link, so the
browser's native navigation handles it with no JS needed). Left
unstyled beyond padding/text-align, it rendered as a bare, small,
default-blue link sitting on the overlay - much less visible than
"Lancer une watch party" (a real <button>, styled by that global rule)
or "Voir sur Emby" (an <a>, but with the explicit .tileBtnLink look
below) right next to it. Styling .tileBtn itself, tag-agnostic, fixes
that - and is the semantically right call besides: for a Series tile,
"Voir les épisodes" IS the primary action (a Series is never itself
directly playable - see WATCH_PARTY_TYPES in server/src/index.js - so
the two never appear on the same tile), not a secondary one. */
.tileBtn {
padding: 8px 10px;
font-size: 12px;
font-weight: 600;
text-align: center;
text-decoration: none;
border-radius: 8px;
background: var(--accent);
color: white;
}
.tileBtn:hover { background: var(--accent-bright); }
/* "Voir sur Emby" only: the one secondary/neutral action among the tile
buttons - overrides just the two properties that make it read as
secondary, on top of .tileBtn's shared sizing/weight/radius above. */
.tileBtnLink {
background: var(--panel);
color: var(--text);
}
.tileBtnLink:hover { background: #333; }
@media (hover: none) {
.tileOverlay {
opacity: 1;
pointer-events: auto;
inset: auto 0 26px 0;
flex-direction: row;
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
padding: 6px;
}
.tileBtn { font-size: 11px; padding: 6px 4px; }
}
/* ---- Series page: detail header + seasons tabs + episode list ---- */
.seriesHeader {
display: flex;
gap: 20px;
margin-bottom: 24px;
}
.seriesPoster {
width: 160px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.seriesPoster.noImage { visibility: hidden; }
.seriesInfo {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.seriesYear { color: var(--muted); font-weight: 400; }
.seriesOverview {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
margin: 0;
max-width: 640px;
}
@media (max-width: 600px) {
.seriesHeader { flex-direction: column; }
.seriesPoster { width: 120px; }
}
.episodeList {
display: flex;
flex-direction: column;
gap: 12px;
}
.episodeRow {
display: flex;
gap: 14px;
align-items: center;
background: var(--bg-header);
border-radius: 10px;
padding: 10px;
}
.episodeRow img {
width: 120px;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.episodeRow img.noImage { visibility: hidden; }
.episodeInfo { flex: 1; min-width: 0; }
.episodeInfo strong { display: block; margin-bottom: 4px; }
.episodeInfo p {
margin: 0;
color: var(--muted);
font-size: 13px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.episodeRow button { flex-shrink: 0; }
@media (max-width: 600px) {
.episodeRow { flex-wrap: wrap; }
.episodeInfo { order: 3; flex-basis: 100%; }
}
/* ---- Music: album page, track lists (shared with the music player's own
queue/browse panel), now-playing header ---- */
.albumHeader {
display: flex;
gap: 20px;
margin-bottom: 24px;
}
.albumCover {
width: 160px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.albumCover.noImage { visibility: hidden; }
.trackList {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.trackRow {
display: grid;
grid-template-columns: 28px 1fr auto auto;
align-items: center;
gap: 12px;
background: var(--bg-header);
border-radius: 8px;
padding: 8px 12px;
}
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; }
@media (max-width: 600px) {
.albumHeader { flex-direction: column; }
.albumCover { width: 120px; }
.trackRow { grid-template-columns: 24px 1fr; }
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
}
/* Music player's "now playing" header - see musicPlayer.js. Not inside a
.playerWrap like the video player: there's no video image to declutter
around, so this renders as a normal page (sidebar stays up) rather than
theater-mode full-viewport. */
.nowPlaying {
display: flex;
gap: 16px;
align-items: center;
background: var(--bg-header);
border-radius: 10px;
padding: 14px;
margin-bottom: 20px;
}
.nowPlayingImage {
width: 96px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.nowPlayingImage.noImage { visibility: hidden; }
.nowPlayingInfo { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.nowPlayingInfo strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nowPlayingControls {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.nowPlayingControls .iconBtn { font-size: 20px; }
/* The presence row reused from the video player (same .avatar component -
see player.js) but in normal page flow here, not absolutely positioned
over a video frame. */
.musicPresence { margin-bottom: 16px; }
.browsePanel {
background: var(--bg-header);
border-radius: 10px;
padding: 14px;
margin-top: 8px;
}
.browseList {
display: flex;
flex-direction: column;
gap: 2px;
}
.browseItem {
display: block;
width: 100%;
text-align: left;
padding: 8px 4px;
}
/* ---- Player ---- */
.playerWrap {
/* Fixed + inset so it fills the actual viewport regardless of
.mainContent's own padding (which used to add to 100vh and push the
whole page taller than the screen, causing an outer scrollbar). Also
covers the sidebar while playing, like most video players' theater
mode - "Quitter" is how you get back to it. */
position: fixed;
inset: 0;
z-index: 20;
background: #000;
}
.playerWrap video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Two stacked rows, both part of the same top gradient overlay: the action
bar (title/members/next/settings/leave) and, below it, the presence
avatars - see .presenceOverlay. Stacking them in normal flow inside one
column means the avatar row always ends up right after the action bar
regardless of how tall that bar is (it wraps to two lines on narrow
screens), instead of guessing a fixed pixel offset for a second
absolutely-positioned block. */
.topOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px;
background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent);
z-index: 5;
}
.actionBar {
display: flex;
align-items: center;
gap: 16px;
color: white;
font-size: 13px;
flex-wrap: wrap;
}
.actionBar .itemTitle { font-weight: 600; }
.actionBar .members { color: var(--muted); flex: 1; }
/* Settings: a small gear icon in the action bar, not a permanently-visible
block of dropdowns - opens a compact popup on click. */
.settingsWrap { position: relative; }
.iconBtn {
background: none;
padding: 4px 8px;
font-size: 16px;
line-height: 1;
border-radius: 6px;
}
.iconBtn:hover { background: rgba(255, 255, 255, .12); }
.settingsPanel {
position: absolute;
top: 100%;
right: 0;
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-header);
padding: 12px;
border-radius: 10px;
font-size: 12px;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
z-index: 5;
}
.settingsPanel label {
color: var(--muted);
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.settingsPanel select {
background: var(--panel);
color: var(--text);
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 4px 6px;
font-size: 12px;
}
.loadingNotice {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--muted);
font-size: 14px;
margin: 0;
}
.playerError {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 360px;
text-align: center;
background: rgba(0, 0, 0, .75);
padding: 20px;
border-radius: 10px;
}
.playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; }
.followerNotice {
/* margin:0 overrides the <p>'s default UA margin - for an absolutely
positioned box, "bottom" places the MARGIN edge, so the default ~1em
bottom margin was quietly pushing the pill 13px higher than its
declared bottom:24px ever suggested. Harmless on its own, but it made
.pauseRequestBanner's bottom offset above it read as barely any gap
at all once that one was measured against this element's real position. */
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
margin: 0;
background: rgba(0,0,0,.6);
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
color: var(--muted);
}
/* Presence overlay: one circle per party member. Lives inside .topOverlay,
right below the action bar - see renderMembers() in player.js. */
.presenceOverlay {
display: flex;
gap: 8px;
}
.avatar {
position: relative; /* anchors .avatarPauseBadge */
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: white;
opacity: .75; /* "overlay en semi transparence" - background color comes from inline style, see colorForName() in player.js */
border: 2px solid rgba(255, 255, 255, .3);
user-select: none;
}
/* Only the viewer's own avatar gets this, and only while not host - see
renderMembers(). The host has direct controls, so their own circle is
presence-only. */
.avatarClickable {
cursor: pointer;
transition: opacity .15s, transform .15s;
}
.avatarClickable:hover {
opacity: 1;
transform: scale(1.08);
}
/* Shown to EVERY member (including the host and the requester themselves)
on the matching person's circle while their "pauseRequested" is active -
see onPauseRequested() in player.js. Orange border + a small pause-icon
badge, not just a generic pulse, so it reads unambiguously even to
someone glancing at the video rather than the banner text. */
.avatarPauseRequested {
opacity: 1;
border-color: #ff9800;
}
.avatarPauseBadge {
position: absolute;
bottom: -4px;
right: -4px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ff9800;
border: 1px solid rgba(0, 0, 0, .5);
display: none;
align-items: center;
justify-content: center;
}
.avatarPauseRequested .avatarPauseBadge { display: flex; }
.pauseRequestBanner {
/* Bottom-anchored, clear of the presence row now living up in
.topOverlay - stacked above .followerNotice (bottom: 24px) rather than
on top of it, since a non-host sees both at once. */
position: absolute;
bottom: 72px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, .75);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
z-index: 6;
}
.hide { display: none; }

View file

@ -1,41 +0,0 @@
import { route, start, navigate } from './router.js';
import { auth, restoreSession } from './embyApi.js';
import { renderShell } from './shell.js';
import { renderLogin } from './views/login.js';
import { renderHome } from './views/home.js';
import { renderLibrary } from './views/library.js';
import { renderSeries } from './views/series.js';
import { renderArtist } from './views/artist.js';
import { renderAlbum } from './views/album.js';
import { renderPartyPlayer } from './views/player.js';
route('/', () => navigate('/home'));
route('/login', renderLogin);
route('/home', renderHome);
route('/library/:id', renderLibrary);
route('/series/:id', renderSeries);
route('/artist/:id', renderArtist);
route('/album/:id', renderAlbum);
route('/party/:id', renderPartyPlayer);
/**
* Renders either the bare login screen or the full shell (sidebar + routed
* content), depending on whether our session cookie is still valid. Exported
* so login.js can call it again right after a successful sign-in - there's
* no shell/router running yet at that point, just the login form.
*/
export async function boot() {
const appEl = document.getElementById('app');
const loggedIn = auth.isLoggedIn || await restoreSession();
if (!loggedIn) {
appEl.innerHTML = '';
renderLogin(appEl);
return;
}
const mainContent = await renderShell(appEl);
start(mainContent);
}
boot();

View file

@ -1,118 +0,0 @@
// Talks ONLY to our own backend now - never directly to Emby. The backend
// holds the real Emby session (server/src/embyAuth.js); this module just
// wraps fetch calls to our own /api/* routes. See docs/architecture.md for
// why this changed from the first version (CORS, plus not wanting the
// browser to hold an Emby token at all).
async function apiFetch(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export const auth = {
userName: null,
get isLoggedIn() { return !!this.userName; }
};
/** Call once on app start to find out if our session cookie is still valid. */
export async function restoreSession() {
try {
const data = await apiFetch('/api/auth/me');
auth.userName = data.userName;
return true;
} catch (err) {
auth.userName = null;
return false;
}
}
export async function login(username, password) {
const data = await apiFetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
auth.userName = data.userName;
return data;
}
export async function logout() {
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
auth.userName = null;
}
export async function searchItems(query) {
if (!query || query.trim().length < 2) return [];
const data = await apiFetch(`/api/catalog/search?q=${encodeURIComponent(query)}`);
return data.items || [];
}
export async function getItem(itemId) {
return apiFetch(`/api/catalog/item/${itemId}`);
}
export async function getLibraries() {
const data = await apiFetch('/api/catalog/libraries');
return data.libraries || [];
}
export async function getLibraryItems(libraryId) {
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/items`);
return data.items || [];
}
export async function getRecentItems(libraryId) {
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/recent`);
return data.items || [];
}
export async function getSeriesSeasons(seriesId) {
const data = await apiFetch(`/api/catalog/series/${seriesId}/seasons`);
return data.seasons || [];
}
export async function getSeriesEpisodes(seriesId, seasonId) {
const data = await apiFetch(`/api/catalog/series/${seriesId}/episodes?seasonId=${encodeURIComponent(seasonId)}`);
return data.episodes || [];
}
export async function getArtists(libraryId) {
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/artists`);
return data.artists || [];
}
export async function getArtistAlbums(artistId) {
const data = await apiFetch(`/api/catalog/artists/${artistId}/albums`);
return data.albums || [];
}
export async function getAlbumTracks(albumId) {
const data = await apiFetch(`/api/catalog/albums/${albumId}/tracks`);
return data.tracks || [];
}
export async function getPlaybackOptions(itemId) {
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
}
/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate/mediaType. */
export function streamUrl(itemId, options = {}) {
const qs = new URLSearchParams(
Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null))
).toString();
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
}
/** Same stream, tagged for a music track - see server/src/index.js's
* /master.m3u8 route for why mediaType is the one thing that matters here
* (its hand-built-fallback path is genuinely different for audio). */
export function audioStreamUrl(itemId) {
return streamUrl(itemId, { mediaType: 'audio' });
}

View file

@ -1,124 +0,0 @@
// Party REST + realtime - same origin as everything else, auth is the
// session cookie (see embyApi.js), so no headers/tokens to pass around here.
// Realtime is Socket.IO (window.io, loaded via <script> in index.html) -
// gives us automatic reconnection and, on the server side, heartbeat-based
// dead-connection detection for free (see server/src/socketHandler.js).
//
// No join codes: joining happens straight from the home page's live list
// (connectLobby below) by party id - see docs/architecture.md.
async function apiFetch(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export function createParty(itemId) {
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
}
export function joinParty(partyId) {
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
}
export function leaveParty(partyId) {
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
}
export function getParty(partyId) {
return apiFetch(`/api/parties/${partyId}`);
}
/**
* Music only: adds a track to a party's shared queue. REST, not a socket
* event, deliberately - unlike voteSkip/setMusicPaused below, this needs to
* work from the album-browsing page (album.js), which has no live
* connection to the party it might be adding into, only the player view
* (musicPlayer.js) does. `track` is whatever toItemDto shape the catalog
* endpoints already returned (itemId/itemName/artistName/albumName/
* itemImageUrl/durationTicks) - see server/src/musicQueueService.js.
*/
export function addTrackToQueue(partyId, track) {
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
}
/**
* Live "watch parties en cours" feed for the home page. `onParties` gets
* called with the full current list on subscribe, and again every time any
* party anywhere changes (created, someone joined/left, host handed off).
* Caller MUST call .disconnect() when navigating away (see home.js's router
* cleanup) - same leak-prevention pattern as connectParty below.
*/
export function connectLobby(onParties) {
const socket = window.io();
socket.on('connect', () => socket.emit('subscribeLobby'));
socket.on('parties', onParties);
return {
disconnect() {
socket.disconnect();
}
};
}
/**
* Opens the realtime connection for a party. Returns an object the caller
* MUST eventually call .leave() on (e.g. from a router cleanup function -
* see router.js/player.js) - otherwise the socket, and whatever it's
* keeping alive via closures, never gets released.
*/
export function connectParty(partyId, {
onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onQueueUpdated, onError, onClose
} = {}) {
const socket = window.io();
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
// also covers rejoining after a transient network drop, not just the
// first connection.
socket.on('connect', () => {
socket.emit('join', { partyId });
});
if (onMembers) socket.on('members', onMembers);
if (onPlaystate) socket.on('playstate', onPlaystate);
if (onItemChanged) socket.on('itemChanged', onItemChanged);
if (onHostChanged) socket.on('hostChanged', onHostChanged);
if (onPauseRequested) socket.on('pauseRequested', onPauseRequested);
if (onQueueUpdated) socket.on('queueUpdated', onQueueUpdated); // music parties only - see musicPlayer.js
if (onError) socket.on('errorMessage', (msg) => onError(msg.message));
if (onClose) socket.on('disconnect', onClose);
return {
sendPlaystate(isPaused, positionTicks) {
socket.emit('playstate', { isPaused, positionTicks });
},
/** Host-only: advance the party's queue (e.g. to the next episode). */
next() {
socket.emit('next');
},
/** Asks the host (and tells everyone else) "please pause". Video only -
* a music party has no host to ask, see requestSkip/setMusicPaused. */
requestPause() {
socket.emit('requestPause');
},
/** Music only: vote to skip the current track - see musicQueueService.js
* on the server for the majority threshold. */
voteSkip() {
socket.emit('voteSkip');
},
/** Music only: no host check server-side, unlike sendPlaystate above. */
setMusicPaused(isPaused) {
socket.emit('musicPlaystate', { isPaused });
},
leave() {
socket.emit('leave');
socket.disconnect();
}
};
}

View file

@ -1,76 +0,0 @@
// Tiny hash router - no dependency, matches the project's "no build step"
// bias. Routes are registered with route(pattern, handler); handler gets
// (root, params) where params come from ":name" segments in the pattern.
//
// A handler may return a cleanup function. If it does, the router calls it
// right before rendering whatever comes next - this is what stops a view
// like the player (setInterval heartbeat, a realtime connection, an hls.js
// instance) from leaking when the user navigates away some way other than
// its own "Quitter" button (a sidebar link, browser back, ...). Before this
// existed, `currentRoot.innerHTML = ''` only ever destroyed DOM nodes -
// nothing told the outgoing view's JS to stop running.
const routes = [];
let currentRoot = null;
let listenerAttached = false;
let currentCleanup = null;
export function route(pattern, handler) {
const keys = [];
const regexSource = pattern
.split('/')
.map((segment) => {
if (segment.startsWith(':')) {
keys.push(segment.slice(1));
return '([^/]+)';
}
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
})
.join('/');
routes.push({ regex: new RegExp('^' + regexSource + '$'), keys, handler });
}
export function navigate(path) {
location.hash = '#' + path;
}
function render() {
if (!currentRoot) return;
if (typeof currentCleanup === 'function') {
try {
currentCleanup();
} catch (err) {
console.error('[watchparty] view cleanup failed', err);
}
}
currentCleanup = null;
const [path, query] = (location.hash.slice(1) || '/').split('?');
const params = Object.fromEntries(new URLSearchParams(query || ''));
for (const r of routes) {
const match = path.match(r.regex);
if (match) {
r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1]); });
currentRoot.innerHTML = '';
currentCleanup = r.handler(currentRoot, params);
return;
}
}
currentRoot.innerHTML = '<main class="wrap"><p>Page introuvable.</p></main>';
}
/**
* (Re)targets the router at `root` and renders the current route into it.
* Safe to call more than once (e.g. after a logout/login cycle re-renders
* the app shell) - the hashchange listener is only ever attached once, so
* repeated calls don't pile up duplicate listeners.
*/
export function start(root) {
currentRoot = root;
if (!listenerAttached) {
window.addEventListener('hashchange', render);
listenerAttached = true;
}
render();
}

View file

@ -1,61 +0,0 @@
// The persistent app chrome (sidebar with collections + main content area),
// rendered once per login. The router (router.js) only ever swaps what's
// inside #mainContent - see app.js's boot().
import { auth, logout, getLibraries } from './embyApi.js';
export async function renderShell(appEl) {
appEl.innerHTML = `
<div class="appShell">
<aside class="sidebar">
<div class="sidebarBrand">Watch Party</div>
<nav class="sidebarNav" id="sidebarNav">
<a href="#/home" class="navItem">Accueil</a>
<div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus">Chargement...</p>
</nav>
<button class="linklike sidebarLogout" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
</aside>
<main id="mainContent" class="mainContent"></main>
</div>`;
appEl.querySelector('#logout').addEventListener('click', async () => {
await logout();
// Simplest reliable way back to a bare login screen: a real reload,
// rather than hand-tearing-down every shell/router listener in place.
location.hash = '';
location.reload();
});
const nav = appEl.querySelector('#sidebarNav');
const status = appEl.querySelector('#libStatus');
try {
const libraries = await getLibraries();
status.remove();
libraries.forEach((lib) => {
const a = document.createElement('a');
a.href = `#/library/${lib.id}`;
a.className = 'navItem';
a.textContent = lib.name;
nav.appendChild(a);
});
} catch (err) {
status.textContent = 'Bibliothèques indisponibles.';
}
const highlight = () => {
nav.querySelectorAll('.navItem').forEach((a) => {
const isHome = a.getAttribute('href') === '#/home';
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
});
};
highlight();
window.addEventListener('hashchange', highlight);
return appEl.querySelector('#mainContent');
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

View file

@ -1,68 +0,0 @@
import { getItem, getAlbumTracks } from '../embyApi.js';
import { createParty } from '../party.js';
import { navigate } from '../router.js';
import { escapeHtml, formatDuration } from '../tiles.js';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
/** Route `/album/:id` - the last level of the music hierarchy: one album's
* tracks, each startable as its own listen party (mirrors series.js's
* episode list, but flat - an album has no further sub-level the way a
* series' seasons do). */
export async function renderAlbum(root, params) {
const albumId = params.id;
root.innerHTML = '<p class="hint">Chargement...</p>';
let album;
let tracks;
try {
[album, tracks] = await Promise.all([getItem(albumId), getAlbumTracks(albumId)]);
} catch (err) {
root.innerHTML = '<p class="error">Impossible de charger cet album.</p>';
return;
}
root.innerHTML = `
<button class="linklike" id="back">&larr; Retour</button>
<div class="albumHeader">
<img class="albumCover" src="${album.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<h1>${escapeHtml(album.name)}${album.year ? ` <span class="seriesYear">(${album.year})</span>` : ''}</h1>
${album.artistName ? `<p class="hint">${escapeHtml(album.artistName)}</p>` : ''}
<a class="linklike" href="${album.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
<div id="trackList" class="trackList"></div>`;
root.querySelector('#back').addEventListener('click', () => history.back());
const listEl = root.querySelector('#trackList');
if (!tracks.length) {
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
return;
}
listEl.innerHTML = tracks.map((t) => `
<div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
<button class="startPartyBtn" data-item-id="${t.id}">Écouter ensemble</button>
</div>
`).join('');
listEl.querySelectorAll('.startPartyBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
const party = await createParty(btn.dataset.itemId);
navigate(`/party/${party.partyId}`);
} catch (err) {
alert(err.message);
btn.disabled = false;
btn.textContent = 'Écouter ensemble';
}
});
});
}

View file

@ -1,35 +0,0 @@
import { getItem, getArtistAlbums } from '../embyApi.js';
import { tileHtml, escapeHtml } from '../tiles.js';
/** Route `/artist/:id` - one artist's albums, reached from a music
* library's top-level grid (see library.js) via a MusicArtist tile's
* "Voir les albums" link (tiles.js's DRILL_DOWN table). Structurally the
* same "detail header + grid" shape as series.js, just one level up the
* music hierarchy (Artist -> Album, not Series -> Season/Episode) and
* with no per-item action beyond drilling further into an album (an
* artist has nothing itself to start a party from). */
export async function renderArtist(root, params) {
const artistId = params.id;
root.innerHTML = '<p class="hint">Chargement...</p>';
let artist;
let albums;
try {
[artist, albums] = await Promise.all([getItem(artistId), getArtistAlbums(artistId)]);
} catch (err) {
root.innerHTML = '<p class="error">Impossible de charger cet artiste.</p>';
return;
}
root.innerHTML = `
<button class="linklike" id="back">&larr; Retour</button>
<div class="pageHeader"><h1>${escapeHtml(artist.name)}</h1></div>
<div id="grid" class="grid"></div>`;
root.querySelector('#back').addEventListener('click', () => history.back());
const grid = root.querySelector('#grid');
grid.innerHTML = albums.length
? albums.map(tileHtml).join('')
: '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
}

View file

@ -1,33 +0,0 @@
import { login } from '../embyApi.js';
import { boot } from '../app.js';
export function renderLogin(root) {
root.innerHTML = `
<main class="wrap">
<h1>Watch Party</h1>
<form id="loginForm" class="panel">
<p class="hint">Utilise ton compte Emby habituel.</p>
<label>Nom d'utilisateur
<input id="username" autocomplete="username" required autofocus />
</label>
<label>Mot de passe
<input id="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">Se connecter</button>
<p id="error" class="error"></p>
</form>
</main>`;
const form = root.querySelector('#loginForm');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = form.querySelector('#error');
errorEl.textContent = '';
try {
await login(form.querySelector('#username').value, form.querySelector('#password').value);
await boot(); // no shell/router running yet at the login screen - (re)boot into it now
} catch (err) {
errorEl.textContent = "Connexion impossible - vérifie ton nom d'utilisateur et ton mot de passe.";
}
});
}

View file

@ -1,100 +0,0 @@
import { getItem, getSeriesSeasons, getSeriesEpisodes } from '../embyApi.js';
import { createParty } from '../party.js';
import { navigate } from '../router.js';
import { escapeHtml } from '../tiles.js';
export async function renderSeries(root, params) {
const seriesId = params.id;
root.innerHTML = '<p class="hint">Chargement...</p>';
let series;
let seasons;
try {
[series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]);
} catch (err) {
root.innerHTML = '<p class="error">Impossible de charger cette série.</p>';
return;
}
root.innerHTML = `
<button class="linklike" id="back">&larr; Retour</button>
<div class="seriesHeader">
<img class="seriesPoster" src="${series.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<h1>${escapeHtml(series.name)}${series.year ? ` <span class="seriesYear">(${series.year})</span>` : ''}</h1>
${series.overview ? `<p class="seriesOverview">${escapeHtml(series.overview)}</p>` : ''}
<a class="linklike" href="${series.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
<nav id="seasonTabs" class="tabs"></nav>
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>`;
root.querySelector('#back').addEventListener('click', () => history.back());
if (!seasons.length) {
root.querySelector('#episodeList').innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
return;
}
const tabsEl = root.querySelector('#seasonTabs');
tabsEl.innerHTML = seasons
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
.join('');
tabsEl.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
if (tab.classList.contains('active')) return;
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
loadEpisodes(root, seriesId, tab.dataset.seasonId);
});
});
loadEpisodes(root, seriesId, seasons[0].id);
}
async function loadEpisodes(root, seriesId, seasonId) {
const listEl = root.querySelector('#episodeList');
listEl.innerHTML = '<p class="hint">Chargement...</p>';
let episodes;
try {
episodes = await getSeriesEpisodes(seriesId, seasonId);
} catch (err) {
listEl.innerHTML = '<p class="error">Impossible de charger les épisodes.</p>';
return;
}
if (!episodes.length) {
listEl.innerHTML = '<p class="hint">Aucun épisode dans cette saison.</p>';
return;
}
// Starting a party from any episode here queues the rest of the series
// from that point on server-side (see server/src/index.js) - the host can
// then use "Épisode suivant" in the player instead of restarting per episode.
listEl.innerHTML = episodes.map((ep) => `
<div class="episodeRow">
<img src="${ep.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo">
<strong>${ep.year ? ep.year + ' · ' : ''}${escapeHtml(ep.name)}</strong>
<p class="hint">${escapeHtml(ep.overview || '')}</p>
</div>
<button class="startEpisodeBtn" data-item-id="${ep.id}">Lancer une watch party</button>
</div>
`).join('');
listEl.querySelectorAll('.startEpisodeBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
const party = await createParty(btn.dataset.itemId);
navigate(`/party/${party.partyId}`);
} catch (err) {
alert(err.message);
btn.disabled = false;
btn.textContent = 'Lancer une watch party';
}
});
});
}

478
client/package-lock.json generated Normal file
View file

@ -0,0 +1,478 @@
{
"name": "watchparty-client",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watchparty-client",
"version": "0.1.0",
"devDependencies": {
"esbuild": "^0.23.0",
"typescript": "^5.5.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
"integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
"integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
"integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
"integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
"integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
"integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
"integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
"integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
"integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
"integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
"integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
"integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
"integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
"integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
"integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
"integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
"integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
"integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
"integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
"integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
"integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
"integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
"integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
"integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.23.1",
"@esbuild/android-arm": "0.23.1",
"@esbuild/android-arm64": "0.23.1",
"@esbuild/android-x64": "0.23.1",
"@esbuild/darwin-arm64": "0.23.1",
"@esbuild/darwin-x64": "0.23.1",
"@esbuild/freebsd-arm64": "0.23.1",
"@esbuild/freebsd-x64": "0.23.1",
"@esbuild/linux-arm": "0.23.1",
"@esbuild/linux-arm64": "0.23.1",
"@esbuild/linux-ia32": "0.23.1",
"@esbuild/linux-loong64": "0.23.1",
"@esbuild/linux-mips64el": "0.23.1",
"@esbuild/linux-ppc64": "0.23.1",
"@esbuild/linux-riscv64": "0.23.1",
"@esbuild/linux-s390x": "0.23.1",
"@esbuild/linux-x64": "0.23.1",
"@esbuild/netbsd-x64": "0.23.1",
"@esbuild/openbsd-arm64": "0.23.1",
"@esbuild/openbsd-x64": "0.23.1",
"@esbuild/sunos-x64": "0.23.1",
"@esbuild/win32-arm64": "0.23.1",
"@esbuild/win32-ia32": "0.23.1",
"@esbuild/win32-x64": "0.23.1"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

14
client/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "watchparty-client",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "node build.mjs",
"watch": "node build.mjs --watch",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"esbuild": "^0.23.0",
"typescript": "^5.5.3"
}
}

123
client/src/api/embyApi.ts Normal file
View file

@ -0,0 +1,123 @@
// Talks ONLY to our own backend now - never directly to Emby. The backend
// holds the real Emby session (server/src/services/AuthService.ts); this
// module just wraps fetch calls to our own /api/* routes. See
// docs/architecture.md for why this changed from the first version (CORS,
// plus not wanting the browser to hold an Emby token at all).
import type { ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
class AuthState {
userName: string | null = null;
get isLoggedIn(): boolean {
return !!this.userName;
}
}
export const auth = new AuthState();
/** Call once on app start to find out if our session cookie is still valid. */
export async function restoreSession(): Promise<boolean> {
try {
const data = await apiFetch<{ userName: string }>('/api/auth/me');
auth.userName = data.userName;
return true;
} catch {
auth.userName = null;
return false;
}
}
export async function login(username: string, password: string): Promise<{ userName: string }> {
const data = await apiFetch<{ userName: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
auth.userName = data.userName;
return data;
}
export async function logout(): Promise<void> {
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
auth.userName = null;
}
export async function searchItems(query: string): Promise<ItemDto[]> {
if (!query || query.trim().length < 2) return [];
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/search?q=${encodeURIComponent(query)}`);
return data.items || [];
}
export function getItem(itemId: string): Promise<ItemDto> {
return apiFetch(`/api/catalog/item/${itemId}`);
}
export async function getLibraries(): Promise<LibraryDto[]> {
const data = await apiFetch<{ libraries: LibraryDto[] }>('/api/catalog/libraries');
return data.libraries || [];
}
export async function getLibraryItems(libraryId: string): Promise<ItemDto[]> {
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/libraries/${libraryId}/items`);
return data.items || [];
}
export async function getRecentItems(libraryId: string): Promise<ItemDto[]> {
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/libraries/${libraryId}/recent`);
return data.items || [];
}
export async function getSeriesSeasons(seriesId: string): Promise<SeasonDto[]> {
const data = await apiFetch<{ seasons: SeasonDto[] }>(`/api/catalog/series/${seriesId}/seasons`);
return data.seasons || [];
}
export async function getSeriesEpisodes(seriesId: string, seasonId: string): Promise<ItemDto[]> {
const data = await apiFetch<{ episodes: ItemDto[] }>(`/api/catalog/series/${seriesId}/episodes?seasonId=${encodeURIComponent(seasonId)}`);
return data.episodes || [];
}
export async function getArtists(libraryId: string): Promise<ItemDto[]> {
const data = await apiFetch<{ artists: ItemDto[] }>(`/api/catalog/libraries/${libraryId}/artists`);
return data.artists || [];
}
export async function getArtistAlbums(artistId: string): Promise<ItemDto[]> {
const data = await apiFetch<{ albums: ItemDto[] }>(`/api/catalog/artists/${artistId}/albums`);
return data.albums || [];
}
export async function getAlbumTracks(albumId: string): Promise<ItemDto[]> {
const data = await apiFetch<{ tracks: ItemDto[] }>(`/api/catalog/albums/${albumId}/tracks`);
return data.tracks || [];
}
export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto> {
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
}
/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate/mediaType. */
export function streamUrl(itemId: string, options: Record<string, string | number | null | undefined> = {}): string {
const entries = Object.entries(options).filter(([, v]) => v !== '' && v != null) as [string, string][];
const qs = new URLSearchParams(entries).toString();
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
}
/** Same stream, tagged for a music track - see server/src/services/
* CatalogService.ts's proxyStream for why mediaType is the one thing
* that matters here (its hand-built-fallback path is genuinely different
* for audio). */
export function audioStreamUrl(itemId: string): string {
return streamUrl(itemId, { mediaType: 'audio' });
}

145
client/src/api/party.ts Normal file
View file

@ -0,0 +1,145 @@
// Party REST + realtime - same origin as everything else, auth is the
// session cookie (see embyApi.ts), so no headers/tokens to pass around here.
// Realtime is Socket.IO (the `io` global, loaded via <script> in
// index.html - see global.d.ts) - gives us automatic reconnection and, on
// the server side, heartbeat-based dead-connection detection for free (see
// server/src/realtime/SocketGateway.ts).
//
// No join codes: joining happens straight from the home page's live list
// (connectLobby below) by party id - see docs/architecture.md.
import type { ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, SocketMember } from '../types';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
export function createParty(itemId: string): Promise<PartyPublicState> {
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
}
export function joinParty(partyId: string): Promise<PartyPublicState> {
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
}
export function leaveParty(partyId: string): Promise<{ ok: true }> {
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
}
export function getParty(partyId: string): Promise<PartyPublicState> {
return apiFetch(`/api/parties/${partyId}`);
}
/**
* Music only: adds a track to a party's shared queue. REST, not a socket
* event, deliberately - unlike voteSkip/setMusicPaused below, this needs
* to work from the album-browsing page and the music player's own inline
* browser, neither of which is guaranteed to be the page with the live
* connection to the party being added into. `track` is whatever ItemDto
* shape the catalog endpoints already returned - see
* server/src/services/MusicQueueService.ts.
*/
export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promise<PartyPublicState> {
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
}
/**
* Live "watch parties en cours" feed for the home page. `onParties` gets
* called with the full current list on subscribe, and again every time any
* party anywhere changes (created, someone joined/left, host handed off).
* Caller MUST call .disconnect() when navigating away (see home's router
* cleanup) - same leak-prevention pattern as connectParty below.
*/
export function connectLobby(onParties: (parties: LobbyPartyDto[]) => void): { disconnect(): void } {
const socket = io();
socket.on('connect', () => socket.emit('subscribeLobby'));
socket.on('parties', (parties) => onParties(parties as LobbyPartyDto[]));
return {
disconnect() {
socket.disconnect();
}
};
}
export interface PartyConnectionCallbacks {
onMembers?: (members: SocketMember[]) => void;
onPlaystate?: (msg: { isPaused: boolean; positionTicks: number }) => void;
onItemChanged?: (msg: ItemChangedMsg) => void;
onHostChanged?: () => void;
onPauseRequested?: (msg: PauseRequestedMsg) => void;
onQueueUpdated?: (state: MusicPublicState) => void;
onError?: (message: string) => void;
onClose?: () => void;
}
export interface PartyConnection {
sendPlaystate(isPaused: boolean, positionTicks: number): void;
/** Host-only: advance the party's queue (e.g. to the next episode). */
next(): void;
/** Asks the host (and tells everyone else) "please pause". Video only -
* a music party has no host to ask, see voteSkip/setMusicPaused. */
requestPause(): void;
/** Music only: vote to skip the current track - see MusicQueueService
* on the server for the majority threshold. */
voteSkip(): void;
/** Music only: no host check server-side, unlike sendPlaystate above. */
setMusicPaused(isPaused: boolean): void;
leave(): void;
}
/**
* Opens the realtime connection for a party. Returns an object the caller
* MUST eventually call .leave() on (e.g. from a router cleanup function -
* see router.ts/player) - otherwise the socket, and whatever it's keeping
* alive via closures, never gets released.
*/
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection {
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onQueueUpdated, onError, onClose } = callbacks;
const socket = io();
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
// also covers rejoining after a transient network drop, not just the
// first connection.
socket.on('connect', () => {
socket.emit('join', { partyId });
});
if (onMembers) socket.on('members', (m) => onMembers(m as SocketMember[]));
if (onPlaystate) socket.on('playstate', (m) => onPlaystate(m as { isPaused: boolean; positionTicks: number }));
if (onItemChanged) socket.on('itemChanged', (m) => onItemChanged(m as ItemChangedMsg));
if (onHostChanged) socket.on('hostChanged', () => onHostChanged());
if (onPauseRequested) socket.on('pauseRequested', (m) => onPauseRequested(m as PauseRequestedMsg));
if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see MusicPlayer
if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
if (onClose) socket.on('disconnect', () => onClose());
return {
sendPlaystate(isPaused, positionTicks) {
socket.emit('playstate', { isPaused, positionTicks });
},
next() {
socket.emit('next');
},
requestPause() {
socket.emit('requestPause');
},
voteSkip() {
socket.emit('voteSkip');
},
setMusicPaused(isPaused) {
socket.emit('musicPlaystate', { isPaused });
},
leave() {
socket.emit('leave');
socket.disconnect();
}
};
}

43
client/src/app.ts Normal file
View file

@ -0,0 +1,43 @@
import './styles/base.css';
import { route, start, navigate } from './router';
import { auth, restoreSession } from './api/embyApi';
import { renderShell } from './shell/Shell';
import { renderLogin } from './views/login/Login';
import { renderHome } from './views/home/Home';
import { renderLibrary } from './views/library/Library';
import { renderSeries } from './views/series/Series';
import { renderArtist } from './views/artist/Artist';
import { renderAlbum } from './views/album/Album';
import { renderPartyPlayer } from './views/player/Player';
route('/', () => navigate('/home'));
route('/login', renderLogin);
route('/home', renderHome);
route('/library/:id', renderLibrary);
route('/series/:id', renderSeries);
route('/artist/:id', renderArtist);
route('/album/:id', renderAlbum);
route('/party/:id', renderPartyPlayer);
/**
* Renders either the bare login screen or the full shell (sidebar +
* routed content), depending on whether our session cookie is still
* valid. Exported so Login.ts can call it again right after a successful
* sign-in - there's no shell/router running yet at that point, just the
* login form.
*/
export async function boot(): Promise<void> {
const appEl = document.getElementById('app') as HTMLElement;
const loggedIn = auth.isLoggedIn || await restoreSession();
if (!loggedIn) {
appEl.innerHTML = '';
renderLogin(appEl);
return;
}
const mainContent = await renderShell(appEl);
start(mainContent);
}
boot();

59
client/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,59 @@
/**
* Ambient declarations for things esbuild/the browser provide that aren't
* npm packages - see client/build.mjs's `.html`-as-text loader, and
* client/index.html's CDN <script> tags for hls.js/socket.io (both used
* as plain globals, not imported - keeping the "pin an exact CDN version,
* no bundler-managed copy" choice this app made from the start).
*/
/** Each component's own .html template, imported as a raw string - see
* any view's own .ts file for the pattern (`import html from './Foo.html'`). */
declare module '*.html' {
const content: string;
export default content;
}
/** Imported purely for esbuild's CSS bundling side effect (see any view's
* own `import './Foo.css'`) - there's nothing to type, the import itself
* is the whole point. */
declare module '*.css';
// --- socket.io-client (CDN global, see client/index.html) -------------------
// Minimal shape covering only what this app actually calls - not the full
// socket.io-client API surface.
interface AppClientSocket {
on(event: string, handler: (...args: unknown[]) => void): void;
emit(event: string, payload?: unknown): void;
disconnect(): void;
}
declare function io(): AppClientSocket;
// --- hls.js (CDN global, see client/index.html) -----------------------------
// Same idea - only the surface client/src/views/player and musicPlayer
// actually use.
interface HlsErrorData {
fatal: boolean;
[key: string]: unknown;
}
interface HlsInstance {
on(event: string, handler: (event: string, data: HlsErrorData) => void): void;
loadSource(url: string): void;
attachMedia(media: HTMLMediaElement): void;
destroy(): void;
}
interface HlsStatic {
new (): HlsInstance;
isSupported(): boolean;
Events: { MANIFEST_PARSED: string; ERROR: string };
}
declare const Hls: HlsStatic | undefined;
interface Window {
Hls?: HlsStatic;
}

View file

@ -4,12 +4,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Watch Party</title>
<link rel="stylesheet" href="css/app.css" />
<link rel="stylesheet" href="app.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="js/app.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>

105
client/src/router.ts Normal file
View file

@ -0,0 +1,105 @@
// Tiny hash router - no dependency, matches the project's "no build step
// for the client's actual routing logic" bias even now that there IS a
// build step for TypeScript itself. Routes are registered with
// route(pattern, handler); handler gets (root, params) where params come
// from ":name" segments in the pattern.
//
// A handler may return a cleanup function. If it does, the router calls it
// right before rendering whatever comes next - this is what stops a view
// like the player (setInterval heartbeat, a realtime connection, an hls.js
// instance) from leaking when the user navigates away some way other than
// its own "Quitter" button (a sidebar link, browser back, ...). Before this
// existed, `currentRoot.innerHTML = ''` only ever destroyed DOM nodes -
// nothing told the outgoing view's JS to stop running.
export type RouteParams = Record<string, string>;
export type CleanupFn = () => void;
/**
* A handler can be `async` (most views just fetch and render, with
* nothing to clean up - the resulting `Promise<void>` is simply not a
* function, so render() below leaves `currentCleanup` at null) or
* synchronous-returning-a-CleanupFn (a view that opens a socket/interval/
* hls.js instance and needs to release it - see views/player/Player.ts).
* What's deliberately NOT allowed here is `Promise<CleanupFn>`: a route
* that needs cleanup but fetches first has to return a real function
* *synchronously* regardless (see Player.ts's own dispatcher for exactly
* that pattern, and why - the type system won't stop a handler from doing
* `async () => { await x; return cleanup; }` instead, but it also won't
* paper over the mistake by pretending that shape is supported here.
*/
export type RouteHandler = (root: HTMLElement, params: RouteParams) => CleanupFn | void | Promise<void>;
interface CompiledRoute {
regex: RegExp;
keys: string[];
handler: RouteHandler;
}
const routes: CompiledRoute[] = [];
let currentRoot: HTMLElement | null = null;
let listenerAttached = false;
let currentCleanup: CleanupFn | null = null;
export function route(pattern: string, handler: RouteHandler): void {
const keys: string[] = [];
const regexSource = pattern
.split('/')
.map((segment) => {
if (segment.startsWith(':')) {
keys.push(segment.slice(1));
return '([^/]+)';
}
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
})
.join('/');
routes.push({ regex: new RegExp('^' + regexSource + '$'), keys, handler });
}
export function navigate(path: string): void {
location.hash = '#' + path;
}
function render(): void {
if (!currentRoot) return;
if (typeof currentCleanup === 'function') {
try {
currentCleanup();
} catch (err) {
console.error('[watchparty] view cleanup failed', err);
}
}
currentCleanup = null;
const [path, query] = (location.hash.slice(1) || '/').split('?');
const params: RouteParams = Object.fromEntries(new URLSearchParams(query || ''));
for (const r of routes) {
const match = (path || '').match(r.regex);
if (match) {
r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1] as string); });
currentRoot.innerHTML = '';
const result = r.handler(currentRoot, params);
// A Promise (an async handler with nothing to clean up) is
// correctly *not* a function - see RouteHandler's own comment for
// why that's the deliberately-supported "no cleanup" shape.
currentCleanup = typeof result === 'function' ? result : null;
return;
}
}
currentRoot.innerHTML = '<main class="wrap"><p>Page introuvable.</p></main>';
}
/**
* (Re)targets the router at `root` and renders the current route into it.
* Safe to call more than once (e.g. after a logout/login cycle re-renders
* the app shell) - the hashchange listener is only ever attached once, so
* repeated calls don't pile up duplicate listeners.
*/
export function start(root: HTMLElement): void {
currentRoot = root;
if (!listenerAttached) {
window.addEventListener('hashchange', render);
listenerAttached = true;
}
render();
}

View file

@ -0,0 +1,81 @@
/* ---- App shell: sidebar + routed main content - see Shell.ts. ---- */
.appShell {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 220px;
flex-shrink: 0;
background: var(--bg-header);
padding: 20px 12px;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebarBrand {
font-size: 18px;
font-weight: 700;
padding: 0 12px 20px;
}
.sidebarNav {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.navSectionLabel {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .05em;
padding: 16px 12px 6px;
}
.navItem {
display: block;
padding: 10px 12px;
border-radius: 8px;
color: var(--text);
text-decoration: none;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.navItem:hover { background: rgba(255, 255, 255, .06); }
.navItem.active { background: var(--accent); color: white; font-weight: 600; }
.sidebarLogout { margin-top: 12px; padding: 8px 12px; }
.mainContent {
flex: 1;
min-width: 0;
padding: 24px clamp(16px, 3vw, 48px) 80px;
}
/* Sidebar becomes a horizontal scrollable topbar on narrow screens. */
@media (max-width: 720px) {
.appShell { flex-direction: column; }
.sidebar {
width: 100%;
height: auto;
flex-direction: row;
align-items: center;
padding: 10px 12px;
overflow-x: auto;
overflow-y: hidden;
}
.sidebarBrand { padding: 0 12px 0 0; }
.sidebarNav { flex-direction: row; flex: none; }
.navSectionLabel { display: none; }
.sidebarLogout { margin: 0 0 0 12px; }
}

View file

@ -0,0 +1,12 @@
<div class="appShell">
<aside class="sidebar">
<div class="sidebarBrand">Watch Party</div>
<nav class="sidebarNav" id="sidebarNav">
<a href="#/home" class="navItem">Accueil</a>
<div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus">Chargement...</p>
</nav>
<button class="linklike sidebarLogout" id="logout"></button>
</aside>
<main id="mainContent" class="mainContent"></main>
</div>

46
client/src/shell/Shell.ts Normal file
View file

@ -0,0 +1,46 @@
// The persistent app chrome (sidebar with collections + main content
// area), rendered once per login. The router (router.ts) only ever swaps
// what's inside #mainContent - see app.ts's boot().
import './Shell.css';
import html from './Shell.html';
import { auth, getLibraries, logout } from '../api/embyApi';
export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
appEl.innerHTML = html;
appEl.querySelector('#logout')!.textContent = `Se déconnecter (${auth.userName || ''})`;
appEl.querySelector('#logout')!.addEventListener('click', async () => {
await logout();
// Simplest reliable way back to a bare login screen: a real reload,
// rather than hand-tearing-down every shell/router listener in place.
location.hash = '';
location.reload();
});
const nav = appEl.querySelector('#sidebarNav')!;
const status = appEl.querySelector('#libStatus')!;
try {
const libraries = await getLibraries();
status.remove();
libraries.forEach((lib) => {
const a = document.createElement('a');
a.href = `#/library/${lib.id}`;
a.className = 'navItem';
a.textContent = lib.name;
nav.appendChild(a);
});
} catch {
status.textContent = 'Bibliothèques indisponibles.';
}
const highlight = () => {
nav.querySelectorAll('.navItem').forEach((a) => {
const isHome = a.getAttribute('href') === '#/home';
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
});
};
highlight();
window.addEventListener('hashchange', highlight);
return appEl.querySelector('#mainContent') as HTMLElement;
}

122
client/src/styles/base.css Normal file
View file

@ -0,0 +1,122 @@
/* Colors pulled live from this Emby server's own active theme (dark), via
its CSS custom properties (--theme-primary-color-*, --background-*, etc.)
- see docs/architecture.md for how these were read directly off the
running server rather than guessed. Loaded once, globally, by app.ts -
every other component/*.css only ever reads these variables, never
redefines them. */
:root {
color-scheme: dark;
--bg: #151216; /* hsl(285, 10%, 7.84%) - Emby's dark theme background */
--bg-header: #221d24; /* same hue, +5% lightness - Emby's header/footer background */
--panel: #262626; /* Emby's card/button/input background (achromatic, ~15-17% lightness) */
--text: #ffffff;
--muted: rgba(255, 255, 255, .6);
--accent: #52b54b; /* Emby's theme-primary-color (hsl(116, 41.7%, 50.2%)) */
--accent-bright: #6ccf65; /* Emby's --theme-accent-text-color-darkbg */
--danger: #ff6b6b;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
#app { min-height: 100%; }
/* Login is a small centered form - everything else lives inside the app
shell (sidebar + .mainContent) - see shell/Shell.css. */
.wrap {
max-width: 420px;
margin: 0 auto;
padding: 32px 20px 80px;
}
h1 { font-size: 22px; margin: 0; }
h2 { font-size: 16px; margin: 0 0 12px; }
.panel {
background: var(--bg-header);
border-radius: 12px;
padding: 20px;
}
.hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
.error { color: var(--danger); font-size: 13px; min-height: 18px; }
label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
input {
display: block;
width: 100%;
margin-top: 4px;
padding: 10px;
border-radius: 8px;
border: 1px solid #3a3a3a;
background: var(--panel);
color: var(--text);
font-size: 14px;
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
padding: 12px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button:hover { background: var(--accent-bright); }
button[disabled] { opacity: .5; cursor: default; }
/* Class-only (not tag-qualified) so an <a class="linklike"> - e.g. the
"Voir sur Emby" link on the series/album detail pages - gets the same
treatment as the <button> ones without needing its own duplicate rule;
there's no global `a` reset in this file, so an unclassed anchor would
otherwise fall back to the browser's default blue/underline against
this dark theme. */
.linklike {
background: none;
color: var(--muted);
font-size: 12px;
font-weight: 400;
text-decoration: underline;
padding: 4px 0;
}
.linklike:hover { background: none; color: var(--accent-bright); }
.code { color: var(--accent-bright); letter-spacing: .1em; }
/* Generic "title + optional action on the right" header, reused by
home/library/artist/the music player - see each view's own .ts. */
.pageHeader {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.hide { display: none; }

View file

@ -0,0 +1,21 @@
/* Shared "poster/cover + title + overview + Emby link" info block, used by
both views/series (a series' overview) and views/album (an album's
artist name) - see each's own Series.css/Album.css for the
poster/cover image sizing itself, which differs (2:3 vs 1:1). */
.seriesInfo {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.seriesYear { color: var(--muted); font-weight: 400; }
.seriesOverview {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
margin: 0;
max-width: 640px;
}

View file

@ -0,0 +1,25 @@
/* Shared base for the "who's here" circle, used by both the video player
(Player.css adds the click-to-request-pause states on top) and the
music player (which just shows presence, no per-avatar interaction -
see MusicPlayer.css's .musicPresence for its positioning tweak). */
.presenceOverlay {
display: flex;
gap: 8px;
}
.avatar {
position: relative;
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: white;
opacity: .75; /* "overlay en semi transparence" - background color comes from an inline style, see colorForName() in tiles.ts */
border: 2px solid rgba(255, 255, 255, .3);
user-select: none;
}

View file

@ -0,0 +1,29 @@
/* Shared track-list rows, used by both views/album (an album's tracks)
and views/musicPlayer (the current queue, and its inline browse panel's
own track lists). */
.trackList {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.trackRow {
display: grid;
grid-template-columns: 28px 1fr auto auto;
align-items: center;
gap: 12px;
background: var(--bg-header);
border-radius: 8px;
padding: 8px 12px;
}
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; }
@media (max-width: 600px) {
.trackRow { grid-template-columns: 24px 1fr; }
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
}

118
client/src/tiles.css Normal file
View file

@ -0,0 +1,118 @@
/* ---- Tile grid (library/artist pages) + tiles (shared with the home
carousels) - see tiles.ts. ---- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 20px 16px;
}
.tile {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
/* Without this, a long title (or an image's intrinsic size before it
loads) can force this flex/grid item wider than its declared size -
the classic "min-width: auto" flexbox/grid pitfall. Every tile must
stay exactly the track/flex-basis size regardless of content. */
min-width: 0;
}
.tile img {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
display: block;
}
.tile img.noImage { visibility: hidden; }
.tileLink {
position: absolute;
inset: 0 0 26px 0;
z-index: 1;
}
.tileLabel {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Hover-reveal overlay with the two actions, on devices that actually have
hover (mouse/trackpad). Touch devices get an always-visible bar instead -
see the (hover: none) block below, since :hover is unreachable there. */
.tileOverlay {
position: absolute;
inset: 0 0 26px 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 8px;
padding: 10px;
background: rgba(0, 0, 0, .55);
border-radius: 8px;
opacity: 0;
transition: opacity .15s ease;
pointer-events: none;
}
.tile:hover .tileOverlay,
.tile:focus-within .tileOverlay {
opacity: 1;
pointer-events: auto;
}
/* Base look is the same solid, high-contrast treatment as every other
button in the app (see base.css's global `button {}` rule) - but that
rule is tag-scoped, so it never applied to "Voir les épisodes", the one
.tileBtn that's an <a>, not a <button> (a plain hash link, so the
browser's native navigation handles it with no JS needed). Left
unstyled beyond padding/text-align, it would render as a bare, small,
default-blue link sitting on the overlay - much less visible than
"Lancer une watch party" (a real <button>) or "Voir sur Emby" (an <a>,
but with the explicit .tileBtnLink look below) right next to it.
Styling .tileBtn itself, tag-agnostic, fixes that - and is the
semantically right call besides: for a Series tile, "Voir les épisodes"
IS the primary action (a Series is never itself directly playable, so
the two never appear on the same tile), not a secondary one. */
.tileBtn {
padding: 8px 10px;
font-size: 12px;
font-weight: 600;
text-align: center;
text-decoration: none;
border-radius: 8px;
background: var(--accent);
color: white;
}
.tileBtn:hover { background: var(--accent-bright); }
/* "Voir sur Emby" only: the one secondary/neutral action among the tile
buttons - overrides just the two properties that make it read as
secondary, on top of .tileBtn's shared sizing/weight/radius above. */
.tileBtnLink {
background: var(--panel);
color: var(--text);
}
.tileBtnLink:hover { background: #333; }
@media (hover: none) {
.tileOverlay {
opacity: 1;
pointer-events: auto;
inset: auto 0 26px 0;
flex-direction: row;
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
padding: 6px;
}
.tileBtn { font-size: 11px; padding: 6px 4px; }
}

View file

@ -1,7 +1,10 @@
// Shared poster-tile rendering - used by the home carousels and the library
// grid, so the two stay visually/behaviorally identical instead of drifting.
// Shared poster-tile rendering - used by the home carousels, the library
// grid, and the artist page, so they all stay visually/behaviorally
// identical instead of drifting.
import './tiles.css';
import type { ItemDto } from './types';
export function escapeHtml(str) {
export function escapeHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
@ -10,12 +13,12 @@ export function escapeHtml(str) {
/** "m:ss" (or "h:mm:ss" past an hour) - shared by the album track list and
* the music player, so a track's listed duration and its playing position
* always read the same way. */
export function formatDuration(totalSeconds) {
export function formatDuration(totalSeconds: number): string {
const s = Math.max(0, Math.round(totalSeconds || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}
@ -25,28 +28,28 @@ export function formatDuration(totalSeconds) {
// listener list, so the same person reads as the same color in either.
const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
export function colorForName(name) {
export function colorForName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
return AVATAR_COLORS[hash % AVATAR_COLORS.length] as string;
}
export function initialsFor(name) {
export function initialsFor(name: string): string {
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
}
// Drill-down targets: a tile for a type with no direct playback (Series,
// MusicArtist, MusicAlbum) links into its own browsing page instead of
// getting a "start a party" button - canWatchParty is already false for
// all three server-side (see WATCH_PARTY_TYPES in server/src/index.js).
const DRILL_DOWN = {
// all three server-side (see WATCH_PARTY_TYPES in CatalogService).
const DRILL_DOWN: Record<string, { route: string; label: string }> = {
Series: { route: 'series', label: 'Voir les épisodes' },
MusicArtist: { route: 'artist', label: 'Voir les albums' },
MusicAlbum: { route: 'album', label: 'Voir les morceaux' }
};
export function tileHtml(item) {
const drill = DRILL_DOWN[item.type];
export function tileHtml(item: ItemDto): string {
const drill = item.type ? DRILL_DOWN[item.type] : undefined;
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
return `
@ -55,7 +58,7 @@ export function tileHtml(item) {
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="tileOverlay">
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">${partyLabel}</button>` : ''}
${drillHref ? `<a class="tileBtn" href="${drillHref}">${drill.label}</a>` : ''}
${drillHref ? `<a class="tileBtn" href="${drillHref}">${drill!.label}</a>` : ''}
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
@ -64,20 +67,20 @@ export function tileHtml(item) {
/**
* Wires up every "Lancer une watch party" button inside `container`.
* `onStart(itemId)` does the actual work (create the party + navigate) - kept
* as a caller-supplied callback so this module doesn't need to depend on
* party.js/router.js itself.
* `onStart(itemId)` does the actual work (create the party + navigate) -
* kept as a caller-supplied callback so this module doesn't need to
* depend on api/party.ts/router.ts itself.
*/
export function wireTileButtons(container, onStart) {
container.querySelectorAll('.startPartyBtn').forEach((btn) => {
export function wireTileButtons(container: ParentNode, onStart: (itemId: string) => Promise<void>): void {
container.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
await onStart(btn.dataset.itemId);
await onStart(btn.dataset.itemId as string);
} catch (err) {
alert(err.message);
alert((err as Error).message);
btn.disabled = false;
btn.textContent = 'Lancer une watch party';
}

140
client/src/types.ts Normal file
View file

@ -0,0 +1,140 @@
/**
* Shared client-side types - overlapping copies of server/src/types.ts's
* DTO shapes rather than a real shared package (the two projects compile
* independently - tsc for the server, esbuild for the client - and a
* shared package would be a bigger structural commitment than this app's
* size justifies). See docs/architecture.md.
*/
export interface ItemDto {
id: string;
name: string;
overview?: string;
year?: number;
type?: string;
canWatchParty: boolean;
imageUrl: string;
embyUrl: string;
durationTicks: number | null;
indexNumber: number | null;
artistName: string | null;
albumName: string | null;
}
export interface LibraryDto {
id: string;
name: string;
collectionType: string;
}
export interface SeasonDto {
id: string;
name: string;
indexNumber?: number;
}
export interface PlaybackTrackOption {
index: number;
label: string;
}
export interface PlaybackOptionsDto {
audioTracks: PlaybackTrackOption[];
subtitleTracks: PlaybackTrackOption[];
}
export type MediaType = 'video' | 'music';
export interface PublicMember {
userId: string;
userName: string;
isHost: boolean;
isPaused: boolean;
positionTicks: number;
connected: boolean;
}
export interface MusicTrack {
itemId: string;
itemName: string;
artistName: string | null;
albumName: string | null;
itemImageUrl: string;
durationSeconds: number | null;
addedByUserId: string;
addedByUserName: string;
}
/** What this app's own catalog DTO (ItemDto) looks like once reshaped into
* a track add request - see tiles/album/musicPlayer's addTrackToQueue calls. */
export interface MusicTrackInput {
itemId: string;
itemName: string;
artistName?: string | null;
albumName?: string | null;
itemImageUrl: string;
durationTicks?: number | null;
}
export interface MusicCurrentPublic extends MusicTrack {
isPaused: boolean;
elapsedSeconds: number;
}
export interface MusicPublicState {
queue: MusicTrack[];
current: MusicCurrentPublic | null;
skipVotes: number;
}
export interface PartyPublicState {
partyId: string;
mediaType: MediaType;
itemId: string;
itemName: string;
itemImageUrl: string;
createdUtc: number;
hasNext: boolean;
queueLength: number;
queueIndex: number;
hostUserName: string | null;
memberCount: number;
youAreHost: boolean;
members: PublicMember[];
music: MusicPublicState | null;
}
/** The socket "members" broadcast's shape - smaller than
* PartyPublicState.members above (no isPaused/positionTicks) - see
* server/src/types.ts's SocketMemberDto for why. */
export interface SocketMember {
userId: string;
userName: string;
isHost: boolean;
connected: boolean;
}
export interface LobbyPartyDto {
partyId: string;
mediaType: MediaType;
itemId: string;
itemName: string;
itemImageUrl: string;
hostUserName: string | null;
memberCount: number;
createdUtc: number;
}
export interface ItemChangedMsg {
itemId: string;
itemName: string;
itemImageUrl: string;
queueIndex: number;
queueLength: number;
hasNext: boolean;
}
export interface PauseRequestedMsg {
userId: string;
userName: string;
}

View file

@ -0,0 +1,21 @@
.albumHeader {
display: flex;
gap: 20px;
margin-bottom: 24px;
}
.albumCover {
width: 160px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.albumCover.noImage { visibility: hidden; }
@media (max-width: 600px) {
.albumHeader { flex-direction: column; }
.albumCover { width: 120px; }
}

View file

@ -0,0 +1,10 @@
<button class="linklike" id="back">&larr; Retour</button>
<div class="albumHeader">
<img class="albumCover" id="albumCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<h1 id="albumTitle"></h1>
<p class="hint hide" id="albumArtist"></p>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
<div id="trackList" class="trackList"></div>

View file

@ -0,0 +1,72 @@
import './Album.css';
import '../../styles/trackList.css';
import html from './Album.html';
import { getAlbumTracks, getItem } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { escapeHtml, formatDuration } from '../../tiles';
import type { ItemDto } from '../../types';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
/** Route `/album/:id` - the last level of the music hierarchy: one album's
* tracks, each startable as its own listen party (mirrors views/series's
* episode list, but flat - an album has no further sub-level the way a
* series' seasons do). */
export async function renderAlbum(root: HTMLElement, params: RouteParams): Promise<void> {
const albumId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>';
let album: ItemDto;
let tracks: ItemDto[];
try {
[album, tracks] = await Promise.all([getItem(albumId), getAlbumTracks(albumId)]);
} catch {
root.innerHTML = '<p class="error">Impossible de charger cet album.</p>';
return;
}
root.innerHTML = html;
root.querySelector('#back')!.addEventListener('click', () => history.back());
(root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl;
root.querySelector('#albumTitle')!.innerHTML = album.year
? `${escapeHtml(album.name)} <span class="seriesYear">(${album.year})</span>`
: escapeHtml(album.name);
if (album.artistName) {
const artistEl = root.querySelector('#albumArtist')!;
artistEl.textContent = album.artistName;
artistEl.classList.remove('hide');
}
(root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl;
const listEl = root.querySelector('#trackList')!;
if (!tracks.length) {
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
return;
}
listEl.innerHTML = tracks.map((t) => `
<div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
<button class="startPartyBtn" data-item-id="${t.id}">Écouter ensemble</button>
</div>
`).join('');
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
const party = await createParty(btn.dataset.itemId as string);
navigate(`/party/${party.partyId}`);
} catch (err) {
alert((err as Error).message);
btn.disabled = false;
btn.textContent = 'Écouter ensemble';
}
});
});
}

View file

@ -0,0 +1,3 @@
<button class="linklike" id="back">&larr; Retour</button>
<div class="pageHeader"><h1></h1></div>
<div id="grid" class="grid"></div>

View file

@ -0,0 +1,34 @@
import html from './Artist.html';
import { getArtistAlbums, getItem } from '../../api/embyApi';
import type { RouteParams } from '../../router';
import { tileHtml } from '../../tiles';
/** Route `/artist/:id` - one artist's albums, reached from a music
* library's top-level grid (see views/library) via a MusicArtist tile's
* "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the
* same "detail header + grid" shape as views/series, just one level up
* the music hierarchy (Artist -> Album, not Series -> Season/Episode) and
* with no per-item action beyond drilling further into an album (an
* artist has nothing itself to start a party from). */
export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> {
const artistId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>';
let artist;
let albums;
try {
[artist, albums] = await Promise.all([getItem(artistId), getArtistAlbums(artistId)]);
} catch {
root.innerHTML = '<p class="error">Impossible de charger cet artiste.</p>';
return;
}
root.innerHTML = html;
root.querySelector('h1')!.textContent = artist.name;
root.querySelector('#back')!.addEventListener('click', () => history.back());
const grid = root.querySelector('#grid')!;
grid.innerHTML = albums.length
? albums.map(tileHtml).join('')
: '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
}

View file

@ -0,0 +1,44 @@
.section { margin-bottom: 32px; }
/* ---- Horizontal carousels ---- */
.carousel {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 10px;
}
.carousel .tile { flex: 0 0 160px; }
.carousel .partyCard { flex: 0 0 280px; }
.partyCard {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-header);
border-radius: 10px;
padding: 10px;
}
.partyCard img {
width: 48px;
height: 72px;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.partyCard img.noImage { visibility: hidden; }
.partyCardInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.partyCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.partyCard button { flex-shrink: 0; padding: 8px 14px; font-size: 13px; }

View file

@ -0,0 +1,10 @@
<div class="pageHeader">
<h1>Accueil</h1>
</div>
<section class="section" id="partiesSection" hidden>
<h2>Watch parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div>
</section>
<div id="recentSections"></div>

View file

@ -1,42 +1,36 @@
import { getLibraries, getRecentItems } from '../embyApi.js';
import { connectLobby, joinParty, createParty } from '../party.js';
import { navigate } from '../router.js';
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
import './Home.css';
import html from './Home.html';
import { getLibraries, getRecentItems } from '../../api/embyApi';
import { connectLobby, createParty, joinParty } from '../../api/party';
import { navigate } from '../../router';
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
import type { LobbyPartyDto } from '../../types';
export function renderHome(root) {
root.innerHTML = `
<div class="pageHeader">
<h1>Accueil</h1>
</div>
<section class="section" id="partiesSection" hidden>
<h2>Watch parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div>
</section>
<div id="recentSections"></div>`;
export function renderHome(root: HTMLElement): () => void {
root.innerHTML = html;
// Live, not a one-shot fetch: the list (and each card's host/member count)
// updates the moment anything changes anywhere - see
// server/src/socketHandler.js's "lobby" room. The elapsed-time label is
// the one piece that ticks purely client-side (from createdUtc) - no
// point re-pushing from the server once a second just for a clock.
// server/src/realtime/SocketGateway.ts's "lobby" room. The elapsed-time
// label is the one piece that ticks purely client-side (from
// createdUtc) - no point re-pushing from the server once a second just
// for a clock.
const lobby = connectLobby((parties) => renderParties(root, parties));
const elapsedTimer = setInterval(() => updateElapsedLabels(root), 1000);
const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
loadRecentSections(root);
// Called by router.js before rendering whatever comes next - stops the
// lobby socket and the ticking clock from running after you've navigated
// away (see docs/architecture.md's memory-leak writeup).
// Called by router.ts before rendering whatever comes next - stops the
// lobby socket and the ticking clock from running after you've
// navigated away (see docs/architecture.md's memory-leak writeup).
return function cleanup() {
lobby.disconnect();
clearInterval(elapsedTimer);
};
}
function renderParties(root, parties) {
const section = root.querySelector('#partiesSection');
function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
const section = root.querySelector('#partiesSection') as HTMLElement | null;
const list = root.querySelector('#partiesCarousel');
if (!section || !list) return; // navigated away mid-flight
@ -61,17 +55,17 @@ function renderParties(root, parties) {
</div>
`).join('');
list.querySelectorAll('.joinPartyBtn').forEach((btn) => {
list.querySelectorAll<HTMLButtonElement>('.joinPartyBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
// Actually register as a member before navigating - the player's
// realtime "join" is rejected server-side otherwise (you'd load the
// video but never get added to the members list or sync).
await joinParty(btn.dataset.partyId);
// realtime "join" is rejected server-side otherwise (you'd load
// the video but never get added to the members list or sync).
await joinParty(btn.dataset.partyId as string);
navigate(`/party/${btn.dataset.partyId}`);
} catch (err) {
alert(err.message);
alert((err as Error).message);
btn.disabled = false;
}
});
@ -80,30 +74,30 @@ function renderParties(root, parties) {
updateElapsedLabels(root);
}
function updateElapsedLabels(root) {
function updateElapsedLabels(root: HTMLElement): void {
const now = Date.now();
root.querySelectorAll('.elapsed').forEach((el) => {
root.querySelectorAll<HTMLElement>('.elapsed').forEach((el) => {
const created = Number(el.dataset.created);
if (!created) return;
el.textContent = formatElapsed(now - created);
});
}
function formatElapsed(ms) {
function formatElapsed(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
async function loadRecentSections(root) {
const container = root.querySelector('#recentSections');
async function loadRecentSections(root: HTMLElement): Promise<void> {
const container = root.querySelector('#recentSections') as HTMLElement;
let libraries;
try {
libraries = await getLibraries();
} catch (err) {
} catch {
container.innerHTML = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
return;
}
@ -120,7 +114,7 @@ async function loadRecentSections(root) {
section.remove();
return;
}
const carousel = section.querySelector('.carousel');
const carousel = section.querySelector('.carousel')!;
carousel.innerHTML = items.map(tileHtml).join('');
wireTileButtons(carousel, async (itemId) => {
const party = await createParty(itemId);

View file

@ -0,0 +1,2 @@
<div class="pageHeader"><h1></h1></div>
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>

View file

@ -1,7 +1,9 @@
import { getLibraries, getLibraryItems, getArtists } from '../embyApi.js';
import { createParty } from '../party.js';
import { navigate } from '../router.js';
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
import html from './Library.html';
import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { tileHtml, wireTileButtons } from '../../tiles';
/**
* Generic "one library" page, reached from the sidebar. What it renders
@ -9,19 +11,19 @@ import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
* - movies/tvshows/musicvideos (or anything else playable): a tile grid,
* same as the home page's carousels (Series/MusicArtist/MusicAlbum
* tiles drill further in instead of starting a party directly - see
* tiles.js's DRILL_DOWN table).
* tiles.ts's DRILL_DOWN table).
* - music: a tile grid too, just of artists (Emby's dedicated /Artists
* endpoint, not a generic library listing - see
* GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one
* level further in (artist.js, then album.js).
* level further in (views/artist, then views/album).
*/
export async function renderLibrary(root, params) {
export async function renderLibrary(root: HTMLElement, params: RouteParams): Promise<void> {
root.innerHTML = '<p class="hint">Chargement...</p>';
let libraries;
try {
libraries = await getLibraries();
} catch (err) {
} catch {
root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
return;
}
@ -32,17 +34,16 @@ export async function renderLibrary(root, params) {
return;
}
root.innerHTML = `
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>`;
root.innerHTML = html;
root.querySelector('h1')!.textContent = library.name;
const grid = root.querySelector('#grid');
const grid = root.querySelector('#grid') as HTMLElement;
let items;
try {
items = library.collectionType === 'music'
? await getArtists(library.id)
: await getLibraryItems(library.id);
} catch (err) {
} catch {
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
return;
}

View file

@ -0,0 +1,14 @@
<main class="wrap">
<h1>Watch Party</h1>
<form id="loginForm" class="panel">
<p class="hint">Utilise ton compte Emby habituel.</p>
<label>Nom d'utilisateur
<input id="username" autocomplete="username" required autofocus />
</label>
<label>Mot de passe
<input id="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">Se connecter</button>
<p id="error" class="error"></p>
</form>
</main>

View file

@ -0,0 +1,22 @@
import html from './Login.html';
import { login } from '../../api/embyApi';
import { boot } from '../../app';
export function renderLogin(root: HTMLElement): void {
root.innerHTML = html;
const form = root.querySelector('#loginForm') as HTMLFormElement;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = form.querySelector('#error') as HTMLElement;
errorEl.textContent = '';
const username = (form.querySelector('#username') as HTMLInputElement).value;
const password = (form.querySelector('#password') as HTMLInputElement).value;
try {
await login(username, password);
await boot(); // no shell/router running yet at the login screen - (re)boot into it now
} catch {
errorEl.textContent = "Connexion impossible - vérifie ton nom d'utilisateur et ton mot de passe.";
}
});
}

View file

@ -0,0 +1,61 @@
/* Music player's "now playing" header. Not inside a .playerWrap like the
video player: there's no video image to declutter around, so this
renders as a normal page (sidebar stays up) rather than theater-mode
full-viewport - see Player.css for the contrast. */
.nowPlaying {
display: flex;
gap: 16px;
align-items: center;
background: var(--bg-header);
border-radius: 10px;
padding: 14px;
margin-bottom: 20px;
}
.nowPlayingImage {
width: 96px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.nowPlayingImage.noImage { visibility: hidden; }
.nowPlayingInfo { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.nowPlayingInfo strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nowPlayingControls {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.nowPlayingControls .iconBtn { font-size: 20px; }
/* The presence row (styles/presence.css's .avatar) in normal page flow
here, not absolutely positioned over a video frame like the video
player's. */
.musicPresence { margin-bottom: 16px; }
.browsePanel {
background: var(--bg-header);
border-radius: 10px;
padding: 14px;
margin-top: 8px;
}
.browseList {
display: flex;
flex-direction: column;
gap: 2px;
}
.browseItem {
display: block;
width: 100%;
text-align: left;
padding: 8px 4px;
}

View file

@ -0,0 +1,28 @@
<div class="pageHeader">
<h1>Écoute partagée</h1>
<button class="linklike" id="leaveBtn">Quitter</button>
</div>
<div id="presenceOverlay" class="presenceOverlay musicPresence"></div>
<div class="nowPlaying" id="nowPlaying">
<img id="trackImage" class="nowPlayingImage" alt="" onerror="this.classList.add('noImage')" />
<div class="nowPlayingInfo">
<strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span>
<div class="nowPlayingControls">
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button>
<span id="trackPosition" class="hint">0:00 / 0:00</span>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
</div>
</div>
</div>
<audio id="audio"></audio>
<div class="pageHeader">
<h2>File d'attente</h2>
<button class="linklike" id="toggleBrowseBtn">Ajouter des morceaux</button>
</div>
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
<div id="browsePanel" class="browsePanel hide"></div>

View file

@ -1,10 +1,17 @@
import { getLibraries, getArtists, getArtistAlbums, getAlbumTracks, audioStreamUrl } from '../embyApi.js';
import { leaveParty, connectParty, addTrackToQueue } from '../party.js';
import { navigate } from '../router.js';
import { escapeHtml, formatDuration, colorForName, initialsFor } from '../tiles.js';
import '../../styles/presence.css';
import '../../styles/trackList.css';
import './MusicPlayer.css';
import html from './MusicPlayer.html';
import { audioStreamUrl, getAlbumTracks, getArtistAlbums, getArtists, getLibraries } from '../../api/embyApi';
import { addTrackToQueue, connectParty, leaveParty } from '../../api/party';
import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router';
import type { CleanupFn } from '../../router';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
import type { MusicCurrentPublic, MusicPublicState, MusicTrackInput, PartyPublicState, SocketMember } from '../../types';
/**
* Music half of the party route (see player.js's dispatcher). Deliberately
* Music half of the party route (see Player.ts's dispatcher). Deliberately
* NOT full-viewport/theater-mode like the video player: there's no image
* to declutter around, so the sidebar stays up and this renders as a
* normal page - which also means "browse and add a track" can happen
@ -14,61 +21,33 @@ import { escapeHtml, formatDuration, colorForName, initialsFor } from '../tiles.
* add a track to the shared queue at any time; anyone can pause/resume;
* skipping the current track needs a strict majority vote of currently
* connected members. No host-only controls at all - see
* server/src/musicQueueService.js for the full reasoning and the
* server/src/services/MusicQueueService.ts for the full reasoning and the
* server-authoritative auto-advance timer that drives this without any
* single client having to be "in charge".
*/
export function renderMusicPlayer(root, partyId, party) {
root.innerHTML = `
<div class="pageHeader">
<h1>Écoute partagée</h1>
<button class="linklike" id="leaveBtn">Quitter</button>
</div>
export function renderMusicPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn {
root.innerHTML = html;
<div id="presenceOverlay" class="presenceOverlay musicPresence"></div>
const audio = root.querySelector('#audio') as HTMLAudioElement;
const presenceOverlay = root.querySelector('#presenceOverlay')!;
const trackImage = root.querySelector('#trackImage') as HTMLImageElement;
const trackName = root.querySelector('#trackName')!;
const trackArtist = root.querySelector('#trackArtist')!;
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
const trackPosition = root.querySelector('#trackPosition')!;
const voteSkipBtn = root.querySelector('#voteSkipBtn') as HTMLButtonElement;
const queueList = root.querySelector('#queueList')!;
const browsePanel = root.querySelector('#browsePanel')!;
<div class="nowPlaying" id="nowPlaying">
<img id="trackImage" class="nowPlayingImage" alt="" onerror="this.classList.add('noImage')" />
<div class="nowPlayingInfo">
<strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span>
<div class="nowPlayingControls">
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button>
<span id="trackPosition" class="hint">0:00 / 0:00</span>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
</div>
</div>
</div>
<audio id="audio"></audio>
<div class="pageHeader">
<h2>File d'attente</h2>
<button class="linklike" id="toggleBrowseBtn">Ajouter des morceaux</button>
</div>
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
<div id="browsePanel" class="browsePanel hide"></div>`;
const audio = root.querySelector('#audio');
const presenceOverlay = root.querySelector('#presenceOverlay');
const trackImage = root.querySelector('#trackImage');
const trackName = root.querySelector('#trackName');
const trackArtist = root.querySelector('#trackArtist');
const playPauseBtn = root.querySelector('#playPauseBtn');
const trackPosition = root.querySelector('#trackPosition');
const voteSkipBtn = root.querySelector('#voteSkipBtn');
const queueList = root.querySelector('#queueList');
const browsePanel = root.querySelector('#browsePanel');
let conn = null;
let hls = null;
let currentItemId = null;
let conn: PartyConnection | null = null;
let hls: HlsInstance | null = null;
let currentItemId: string | null = null;
let currentDurationSeconds = 0;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as player.js
let positionTicker = null;
let latestMembers = [];
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as Player.ts
let positionTicker: number | null = null;
let latestMembers: SocketMember[] = [];
// Same HLS pipeline as the video player (player.js's loadVideo) - Emby's
// Same HLS pipeline as the video player (Player.ts's loadVideo) - Emby's
// PlaybackInfo negotiation isn't video-specific, so its TranscodingUrl
// for a music track just as plausibly comes back HLS-shaped, and most
// browsers (everything but Safari) have no native .m3u8 support at all -
@ -77,7 +56,7 @@ export function renderMusicPlayer(root, partyId, party) {
// is actually parsed, not immediately after setting src - setting
// currentTime before a fresh source has loaded metadata is unreliable
// and gets silently reset by most browsers.
function loadTrack(track, elapsedSeconds, isPaused) {
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean) {
currentItemId = track.itemId;
currentDurationSeconds = track.durationSeconds || 0;
trackImage.src = track.itemImageUrl || '';
@ -101,16 +80,16 @@ export function renderMusicPlayer(root, partyId, party) {
// Caught, not left to propagate: this runs from inside applyMusicState,
// which every "queueUpdated" push calls - an uncaught throw here would
// abort that entire handler (and, the first time it happens, the
// player's whole synchronous init - see player.js's dispatcher), taking
// player's whole synchronous init - see Player.ts's dispatcher), taking
// the queue list and vote button down with a problem that's really
// just "this one track's audio failed to load". A skip vote is still
// this feature's actual recovery path (see musicQueueService.js) - this
// this feature's actual recovery path (see MusicQueueService) - this
// just needs to not crash on the way there.
try {
if (window.Hls && window.Hls.isSupported()) {
hls = new window.Hls();
hls.on(window.Hls.Events.MANIFEST_PARSED, resume);
hls.on(window.Hls.Events.ERROR, (event, data) => {
hls.on(window.Hls.Events.ERROR, (_event, data) => {
if (data.fatal) console.error('[watchparty musicPlayer] HLS error', data);
});
hls.loadSource(src);
@ -126,19 +105,19 @@ export function renderMusicPlayer(root, partyId, party) {
}
}
function applyMusicState(music) {
function applyMusicState(music: MusicPublicState) {
// Queue/vote-button/play-icon updates happen first, unconditionally -
// deliberately BEFORE the track-loading branch below, which touches
// hls.js and could throw (a bad manifest, a network hiccup). Same
// ordering the video player already uses (see onItemChanged in
// player.js, which updates the title/next-button before calling
// Player.ts, which updates the title/next-button before calling
// loadVideo): a media-loading failure should never also take the
// queue list and vote tally down with it, since they're not actually
// related to whether THIS particular track's audio loaded.
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
playPauseBtn.disabled = !music.current;
voteSkipBtn.disabled = !music.current;
// "needed" mirrors the exact math musicQueueService.js uses server-side
// "needed" mirrors the exact math MusicQueueService uses server-side
// (strict majority of currently connected members) - computed here from
// the member list the "members" event already provides, rather than
// MusicQueueService's own broadcast repeating that count on every
@ -191,18 +170,18 @@ export function renderMusicPlayer(root, partyId, party) {
conn.setMusicPaused(!audio.paused);
});
voteSkipBtn.addEventListener('click', () => { if (conn) conn.voteSkip(); });
voteSkipBtn.addEventListener('click', () => conn?.voteSkip());
// Ticks the visible position label between server updates - the same
// "derive it client-side from a snapshot" approach as the home page's
// party-elapsed-time labels, rather than the server pushing this every
// second for every listener.
positionTicker = setInterval(() => {
positionTicker = window.setInterval(() => {
if (!currentItemId) return;
trackPosition.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`;
}, 500);
function renderMembers(members) {
function renderMembers(members: SocketMember[]) {
latestMembers = members;
presenceOverlay.innerHTML = members.map((m) => `
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
@ -214,7 +193,7 @@ export function renderMusicPlayer(root, partyId, party) {
// within this page (see class comment for why not a route navigation). ---
let browsing = false;
root.querySelector('#toggleBrowseBtn').addEventListener('click', () => {
root.querySelector('#toggleBrowseBtn')!.addEventListener('click', () => {
browsing = !browsing;
browsePanel.classList.toggle('hide', !browsing);
if (browsing) loadMusicLibraries();
@ -231,92 +210,95 @@ export function renderMusicPlayer(root, partyId, party) {
// Almost always exactly one - skip straight to its artists rather
// than making the common case click through a one-item list.
if (libraries.length === 1) {
loadArtists(libraries[0].id);
loadArtists(libraries[0]!.id);
} else {
browsePanel.innerHTML = renderBrowseList(libraries.map((l) => ({ id: l.id, name: l.name })), null);
browsePanel.innerHTML = renderBrowseList(libraries.map((l) => ({ id: l.id, name: l.name })), false);
wireBrowseList((id) => loadArtists(id));
}
} catch (err) {
} catch {
browsePanel.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
}
}
async function loadArtists(libraryId) {
async function loadArtists(libraryId: string) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const artists = await getArtists(libraryId);
browsePanel.innerHTML = renderBrowseList(artists.map((a) => ({ id: a.id, name: a.name })), null);
browsePanel.innerHTML = renderBrowseList(artists.map((a) => ({ id: a.id, name: a.name })), false);
wireBrowseList((id) => loadAlbums(id));
} catch (err) {
} catch {
browsePanel.innerHTML = '<p class="error">Impossible de charger les artistes.</p>';
}
}
async function loadAlbums(artistId) {
async function loadAlbums(artistId: string) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const albums = await getArtistAlbums(artistId);
browsePanel.innerHTML = renderBrowseList(albums.map((a) => ({ id: a.id, name: a.year ? `${a.name} (${a.year})` : a.name })), () => loadMusicLibraries());
browsePanel.innerHTML = renderBrowseList(albums.map((a) => ({ id: a.id, name: a.year ? `${a.name} (${a.year})` : a.name })), true);
wireBrowseList((id) => loadTracks(id));
} catch (err) {
} catch {
browsePanel.innerHTML = '<p class="error">Impossible de charger les albums.</p>';
}
}
async function loadTracks(albumId) {
async function loadTracks(albumId: string) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const tracks = await getAlbumTracks(albumId);
browsePanel.innerHTML = `
<button class="linklike browseBack">&larr; Retour</button>
${tracks.map((t) => `
${tracks.map((t) => {
const input: MusicTrackInput = {
itemId: t.id, itemName: t.name, artistName: t.artistName, albumName: t.albumName,
itemImageUrl: t.imageUrl, durationTicks: t.durationTicks
};
return `
<div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / 10000000) : ''}</span>
<button class="addTrackBtn" data-track="${encodeURIComponent(JSON.stringify({
itemId: t.id, itemName: t.name, artistName: t.artistName, albumName: t.albumName,
itemImageUrl: t.imageUrl, durationTicks: t.durationTicks
}))}">Ajouter</button>
</div>`).join('')}`;
browsePanel.querySelector('.browseBack').addEventListener('click', () => loadMusicLibraries());
browsePanel.querySelectorAll('.addTrackBtn').forEach((btn) => {
<button class="addTrackBtn" data-track="${encodeURIComponent(JSON.stringify(input))}">Ajouter</button>
</div>`;
}).join('')}`;
browsePanel.querySelector('.browseBack')!.addEventListener('click', () => loadMusicLibraries());
browsePanel.querySelectorAll<HTMLButtonElement>('.addTrackBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
const originalLabel = btn.textContent;
const originalLabel = btn.textContent as string;
try {
await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track)));
await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track as string)));
btn.textContent = 'Ajouté !';
setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
} catch (err) {
alert(err.message);
alert((err as Error).message);
btn.disabled = false;
}
});
});
} catch (err) {
} catch {
browsePanel.innerHTML = '<p class="error">Impossible de charger les morceaux.</p>';
}
}
function renderBrowseList(items, onBack) {
const back = onBack ? '<button class="linklike browseBack">&larr; Retour</button>' : '';
function renderBrowseList(items: { id: string; name: string }[], withBack: boolean): string {
const back = withBack ? '<button class="linklike browseBack">&larr; Retour</button>' : '';
const body = items.length
? items.map((i) => `<button class="linklike browseItem" data-id="${escapeHtml(i.id)}">${escapeHtml(i.name)}</button>`).join('')
: '<p class="hint">Rien à afficher.</p>';
return `${back}<div class="browseList">${body}</div>`;
}
function wireBrowseList(onPick) {
function wireBrowseList(onPick: (id: string) => void) {
const back = browsePanel.querySelector('.browseBack');
if (back) back.addEventListener('click', () => loadMusicLibraries());
browsePanel.querySelectorAll('.browseItem').forEach((btn) => {
btn.addEventListener('click', () => onPick(btn.dataset.id));
browsePanel.querySelectorAll<HTMLButtonElement>('.browseItem').forEach((btn) => {
btn.addEventListener('click', () => onPick(btn.dataset.id as string));
});
}
// --- Init ---
applyMusicState(party.music);
if (party.music) applyMusicState(party.music);
conn = connectParty(partyId, {
onMembers: renderMembers,
@ -325,14 +307,14 @@ export function renderMusicPlayer(root, partyId, party) {
onClose: () => {}
});
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
return function cleanup() {
if (positionTicker) clearInterval(positionTicker);
if (hls) hls.destroy();
audio.pause();
audio.removeAttribute('src');
if (conn) conn.leave();
conn?.leave();
leaveParty(partyId).catch(() => {});
};
}

View file

@ -0,0 +1,195 @@
.playerWrap {
/* Fixed + inset so it fills the actual viewport regardless of
.mainContent's own padding (which used to add to 100vh and push the
whole page taller than the screen, causing an outer scrollbar). Also
covers the sidebar while playing, like most video players' theater
mode - "Quitter" is how you get back to it. */
position: fixed;
inset: 0;
z-index: 20;
background: #000;
}
.playerWrap video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Two stacked rows, both part of the same top gradient overlay: the action
bar (title/members/next/settings/leave) and, below it, the presence
avatars - see .presenceOverlay in styles/presence.css. Stacking them in
normal flow inside one column means the avatar row always ends up right
after the action bar regardless of how tall that bar is (it wraps to
two lines on narrow screens), instead of guessing a fixed pixel offset
for a second absolutely-positioned block. */
.topOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px;
background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent);
z-index: 5;
}
.actionBar {
display: flex;
align-items: center;
gap: 16px;
color: white;
font-size: 13px;
flex-wrap: wrap;
}
.actionBar .itemTitle { font-weight: 600; }
.actionBar .members { color: var(--muted); flex: 1; }
/* Settings: a small gear icon in the action bar, not a permanently-visible
block of dropdowns - opens a compact popup on click. */
.settingsWrap { position: relative; }
.iconBtn {
background: none;
padding: 4px 8px;
font-size: 16px;
line-height: 1;
border-radius: 6px;
}
.iconBtn:hover { background: rgba(255, 255, 255, .12); }
.settingsPanel {
position: absolute;
top: 100%;
right: 0;
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-header);
padding: 12px;
border-radius: 10px;
font-size: 12px;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
z-index: 5;
}
.settingsPanel label {
color: var(--muted);
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.settingsPanel select {
background: var(--panel);
color: var(--text);
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 4px 6px;
font-size: 12px;
}
.loadingNotice {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--muted);
font-size: 14px;
margin: 0;
}
.playerError {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 360px;
text-align: center;
background: rgba(0, 0, 0, .75);
padding: 20px;
border-radius: 10px;
}
.playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; }
.followerNotice {
/* margin:0 overrides the <p>'s default UA margin - for an absolutely
positioned box, "bottom" places the MARGIN edge, so the default ~1em
bottom margin was quietly pushing the pill 13px higher than its
declared bottom:24px ever suggested. Harmless on its own, but it made
.pauseRequestBanner's bottom offset above it read as barely any gap
at all once that one was measured against this element's real position. */
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
margin: 0;
background: rgba(0,0,0,.6);
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
color: var(--muted);
}
/* Only the viewer's own avatar gets this, and only while not host - see
renderMembers() in Player.ts. The host has direct controls, so their
own circle is presence-only. */
.avatarClickable {
cursor: pointer;
transition: opacity .15s, transform .15s;
}
.avatarClickable:hover {
opacity: 1;
transform: scale(1.08);
}
/* Shown to EVERY member (including the host and the requester themselves)
on the matching person's circle while their "pauseRequested" is active -
see onPauseRequested() in Player.ts. Orange border + a small pause-icon
badge, not just a generic pulse, so it reads unambiguously even to
someone glancing at the video rather than the banner text. */
.avatarPauseRequested {
opacity: 1;
border-color: #ff9800;
}
.avatarPauseBadge {
position: absolute;
bottom: -4px;
right: -4px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ff9800;
border: 1px solid rgba(0, 0, 0, .5);
display: none;
align-items: center;
justify-content: center;
}
.avatarPauseRequested .avatarPauseBadge { display: flex; }
.pauseRequestBanner {
/* Bottom-anchored, clear of the presence row now living up in
.topOverlay - stacked above .followerNotice (bottom: 24px) rather than
on top of it, since a non-host sees both at once. */
position: absolute;
bottom: 72px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, .75);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
z-index: 6;
}

View file

@ -0,0 +1,34 @@
<div class="playerWrap">
<video id="video" playsinline></video>
<p id="loadingNotice" class="loadingNotice">Chargement du flux...</p>
<div id="playerError" class="playerError hide">
<p id="playerErrorText"></p>
<button id="playerErrorRetry" class="linklike">Réessayer</button>
</div>
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></div>
<div class="topOverlay">
<div class="actionBar">
<span id="itemTitle" class="itemTitle"></span>
<span id="members" class="members"></span>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
<div id="settingsWrap" class="settingsWrap hide">
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">&#9881;</button>
<div id="settingsPanel" class="settingsPanel hide">
<label>Langue <select id="audioSelect"></select></label>
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
<label>Qualité <select id="qualitySelect"></select></label>
</div>
</div>
<button id="leaveBtn" class="linklike">Quitter</button>
</div>
<div id="presenceOverlay" class="presenceOverlay"></div>
</div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
</div>

View file

@ -1,12 +1,18 @@
import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js';
import { getParty, leaveParty, connectParty } from '../party.js';
import { navigate } from '../router.js';
import { escapeHtml, colorForName, initialsFor } from '../tiles.js';
import { renderMusicPlayer } from './musicPlayer.js';
import '../../styles/presence.css';
import './Player.css';
import html from './Player.html';
import { auth, getPlaybackOptions, streamUrl } from '../../api/embyApi';
import { getParty, leaveParty, connectParty } from '../../api/party';
import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, initialsFor } from '../../tiles';
import type { ItemChangedMsg, PartyPublicState, PauseRequestedMsg, SocketMember } from '../../types';
import { renderMusicPlayer } from '../musicPlayer/MusicPlayer';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5;
const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in socketHandler.js
const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in SocketGateway
const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up
// "Quality" is really "max transcoding bitrate", the same simplification
@ -35,7 +41,7 @@ const PAUSE_ICON_SVG = `
* before they can render anything at all.
*
* Has to return its cleanup function *synchronously* (route()'s contract -
* see router.js), but which cleanup function that even is depends on a
* see router.ts), but which cleanup function that even is depends on a
* fetch that hasn't resolved yet at the time this returns. Solved with a
* mutable box: an outer cleanup wrapper is handed back immediately, and it
* calls whatever the real cleanup turns out to be once the fetch settles -
@ -43,11 +49,11 @@ const PAUSE_ICON_SVG = `
* when it runs (nothing was ever set up in that case - no socket, no
* timer, nothing to leak).
*/
export function renderPartyPlayer(root, params) {
const partyId = params.id;
export function renderPartyPlayer(root: HTMLElement, params: RouteParams): CleanupFn {
const partyId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>';
let cleanup = () => {};
let cleanup: CleanupFn = () => {};
let cancelled = false;
getParty(partyId).then((party) => {
@ -75,64 +81,30 @@ export function renderPartyPlayer(root, params) {
* Takes the already-fetched party instead of fetching it itself: the
* dispatcher needs to fetch it anyway just to read mediaType, and
* route()'s contract needs its cleanup function back *synchronously* (see
* router.js) - an async renderVideoPlayer awaiting its own fetch would
* return a Promise instead, which router.js's `typeof cleanup ===
* router.ts) - an async renderVideoPlayer awaiting its own fetch would
* return a Promise instead, which router.ts's `typeof cleanup ===
* 'function'` check would silently skip, reintroducing the exact leak
* class this app already spent real effort fixing once.
*/
function renderVideoPlayer(root, partyId, party) {
root.innerHTML = `
<div class="playerWrap">
<video id="video" playsinline></video>
function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn {
root.innerHTML = html;
<p id="loadingNotice" class="loadingNotice">Chargement du flux...</p>
<div id="playerError" class="playerError hide">
<p id="playerErrorText"></p>
<button id="playerErrorRetry" class="linklike">Réessayer</button>
</div>
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></div>
<div class="topOverlay">
<div class="actionBar">
<span id="itemTitle" class="itemTitle"></span>
<span id="members" class="members"></span>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
<div id="settingsWrap" class="settingsWrap hide">
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">&#9881;</button>
<div id="settingsPanel" class="settingsPanel hide">
<label>Langue <select id="audioSelect"></select></label>
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
<label>Qualité <select id="qualitySelect"></select></label>
</div>
</div>
<button id="leaveBtn" class="linklike">Quitter</button>
</div>
<div id="presenceOverlay" class="presenceOverlay"></div>
</div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
</div>`;
const video = root.querySelector('#video');
const loadingNotice = root.querySelector('#loadingNotice');
const errorBox = root.querySelector('#playerError');
const errorText = root.querySelector('#playerErrorText');
const itemTitleEl = root.querySelector('#itemTitle');
const membersEl = root.querySelector('#members');
const presenceOverlay = root.querySelector('#presenceOverlay');
const pauseBanner = root.querySelector('#pauseRequestBanner');
const nextBtn = root.querySelector('#nextBtn');
const followerNotice = root.querySelector('#followerNotice');
const settingsWrap = root.querySelector('#settingsWrap');
const settingsBtn = root.querySelector('#settingsBtn');
const settingsPanel = root.querySelector('#settingsPanel');
const audioSelect = root.querySelector('#audioSelect');
const subtitleSelect = root.querySelector('#subtitleSelect');
const qualitySelect = root.querySelector('#qualitySelect');
const video = root.querySelector('#video') as HTMLVideoElement;
const loadingNotice = root.querySelector('#loadingNotice')!;
const errorBox = root.querySelector('#playerError')!;
const errorText = root.querySelector('#playerErrorText')!;
const itemTitleEl = root.querySelector('#itemTitle')!;
const membersEl = root.querySelector('#members')!;
const presenceOverlay = root.querySelector('#presenceOverlay')!;
const pauseBanner = root.querySelector('#pauseRequestBanner')!;
const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement;
const followerNotice = root.querySelector('#followerNotice')!;
const settingsWrap = root.querySelector('#settingsWrap')!;
const settingsBtn = root.querySelector('#settingsBtn')!;
const settingsPanel = root.querySelector('#settingsPanel')!;
const audioSelect = root.querySelector('#audioSelect') as HTMLSelectElement;
const subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement;
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
@ -142,9 +114,9 @@ function renderVideoPlayer(root, partyId, party) {
// never be reachable to remove, and would pile up across visits. This was
// the actual leak: every join-a-party-then-navigate-away-without-"Quitter"
// used to leave one of these (plus the heartbeat interval, the socket,
// and the hls.js instance) running forever - see router.js's cleanup hook.
function onDocumentClick(e) {
if (!settingsWrap.contains(e.target)) settingsPanel.classList.add('hide');
// and the hls.js instance) running forever - see router.ts's cleanup hook.
function onDocumentClick(e: MouseEvent) {
if (!settingsWrap.contains(e.target as Node)) settingsPanel.classList.add('hide');
}
document.addEventListener('click', onDocumentClick);
@ -155,15 +127,15 @@ function renderVideoPlayer(root, partyId, party) {
let isHost = false;
let hasNextQueued = false;
let conn = null;
let hls = null;
let currentItemId = null;
let conn: PartyConnection | null = null;
let hls: HlsInstance | null = null;
let currentItemId: string | null = null;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
let heartbeat = null;
let heartbeat: number | null = null;
let pauseRequestCooldownUntil = 0;
let pauseBannerTimer = null;
let pauseBannerTimer: number | null = null;
function showError(message, detail) {
function showError(message: string, detail?: unknown) {
loadingNotice.classList.add('hide');
errorText.textContent = message;
errorBox.classList.remove('hide');
@ -174,7 +146,7 @@ function renderVideoPlayer(root, partyId, party) {
errorBox.classList.add('hide');
}
function applyRemoteState(msg) {
function applyRemoteState(msg: { isPaused: boolean; positionTicks: number }) {
suppressLocalEvents = true;
const targetSeconds = msg.positionTicks / TICKS_PER_SECOND;
if (Math.abs(video.currentTime - targetSeconds) > DRIFT_TOLERANCE_SECONDS) {
@ -193,15 +165,15 @@ function renderVideoPlayer(root, partyId, party) {
/**
* Promotes this client to host - called either at init (if we already
* were) or on a live "hostChanged" event (the previous host left and the
* server picked us next - see socketHandler.js's applyLeave). Safe to
* call more than once: the early return covers the normal case, and
* addEventListener with the same function reference is a no-op if already
* attached, so even a re-entrant call can't double up the play/pause/
* seeked listeners. The heartbeat is guarded separately since setInterval
* has no such built-in idempotence - calling it twice would leak the
* first interval's id.
* server picked us next - see SocketGateway's applyLeave). Safe to call
* more than once: the early return covers the normal case, and
* addEventListener with the same function reference is a no-op if
* already attached, so even a re-entrant call can't double up the
* play/pause/seeked listeners. The heartbeat is guarded separately since
* setInterval has no such built-in idempotence - calling it twice would
* leak the first interval's id.
*/
function becomeHost(notify) {
function becomeHost(notify: boolean) {
if (isHost) return;
isHost = true;
video.controls = true;
@ -212,13 +184,13 @@ function renderVideoPlayer(root, partyId, party) {
video.addEventListener('play', sendLocalState);
video.addEventListener('pause', sendLocalState);
video.addEventListener('seeked', sendLocalState);
video.addEventListener('ended', () => { if (hasNextQueued) conn.next(); });
if (!heartbeat) heartbeat = setInterval(sendLocalState, 3000);
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
if (!heartbeat) heartbeat = window.setInterval(sendLocalState, 3000);
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
}
async function loadTrackOptions(itemId) {
async function loadTrackOptions(itemId: string) {
try {
const options = await getPlaybackOptions(itemId);
audioSelect.innerHTML = options.audioTracks.length
@ -232,7 +204,7 @@ function renderVideoPlayer(root, partyId, party) {
}
}
function loadVideo(itemId, resumeSeconds, wasPlaying) {
function loadVideo(itemId: string, resumeSeconds: number, wasPlaying: boolean) {
currentItemId = itemId;
clearError();
loadingNotice.classList.remove('hide');
@ -257,7 +229,7 @@ function renderVideoPlayer(root, partyId, party) {
if (window.Hls && window.Hls.isSupported()) {
hls = new window.Hls();
hls.on(window.Hls.Events.MANIFEST_PARSED, resume);
hls.on(window.Hls.Events.ERROR, (event, data) => {
hls.on(window.Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
showError(
"Erreur de lecture (HLS) - le flux n'a pas pu être chargé depuis Emby.",
@ -280,10 +252,10 @@ function renderVideoPlayer(root, partyId, party) {
}
// Changing language/subtitles/quality means a fresh transcode (Emby bakes
// the selection in server-side - see index.js) - reload at the same
// position instead of restarting from zero.
// the selection in server-side - see CatalogService) - reload at the
// same position instead of restarting from zero.
function reloadWithCurrentOptions() {
loadVideo(currentItemId, video.currentTime, !video.paused);
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
}
[audioSelect, subtitleSelect, qualitySelect].forEach((sel) => {
@ -293,11 +265,11 @@ function renderVideoPlayer(root, partyId, party) {
});
});
root.querySelector('#playerErrorRetry').addEventListener('click', () => {
(root.querySelector('#playerErrorRetry') as HTMLButtonElement).addEventListener('click', () => {
if (currentItemId) loadVideo(currentItemId, video.currentTime, true);
});
async function onItemChanged(msg) {
async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext;
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
@ -312,7 +284,7 @@ function renderVideoPlayer(root, partyId, party) {
// The pause-badge <span> is always in the markup, just hidden by
// .avatarPauseRequested's CSS - see onPauseRequested(), which is the only
// thing that ever shows it.
function renderMembers(members) {
function renderMembers(members: SocketMember[]) {
membersEl.textContent = members
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
.join(', ');
@ -342,13 +314,13 @@ function renderVideoPlayer(root, partyId, party) {
}
// Broadcast to everyone in the party, including the requester and the
// host (see socketHandler.js) - so this always flags the right circle no
// host (see SocketGateway) - so this always flags the right circle no
// matter whose screen it renders on.
function onPauseRequested(msg) {
function onPauseRequested(msg: PauseRequestedMsg) {
pauseBanner.textContent = `${msg.userName} demande une pause`;
pauseBanner.classList.remove('hide');
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
pauseBannerTimer = setTimeout(() => pauseBanner.classList.add('hide'), PAUSE_REQUEST_HIGHLIGHT_MS);
pauseBannerTimer = window.setTimeout(() => pauseBanner.classList.add('hide'), PAUSE_REQUEST_HIGHLIGHT_MS);
const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`);
if (avatar) {
@ -378,12 +350,12 @@ function renderVideoPlayer(root, partyId, party) {
if (party.youAreHost) becomeHost(false);
}
nextBtn.addEventListener('click', () => { if (conn) conn.next(); });
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
nextBtn.addEventListener('click', () => { conn?.next(); });
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
init();
// Called by router.js right before it renders whatever route comes next -
// Called by router.ts right before it renders whatever route comes next -
// whether that's from clicking "Quitter" (navigate above) or leaving any
// other way (sidebar link, browser back, ...). This is the fix for the
// actual leak: previously, only the "Quitter" button ran any of this.
@ -392,7 +364,7 @@ function renderVideoPlayer(root, partyId, party) {
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick);
if (conn) conn.leave();
conn?.leave();
leaveParty(partyId).catch(() => {});
};
}

View file

@ -0,0 +1,91 @@
/* ---- Series page: detail header + seasons tabs + episode list ---- */
.seriesHeader {
display: flex;
gap: 20px;
margin-bottom: 24px;
}
.seriesPoster {
width: 160px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.seriesPoster.noImage { visibility: hidden; }
@media (max-width: 600px) {
.seriesHeader { flex-direction: column; }
.seriesPoster { width: 120px; }
}
/* Tabs: one per season. */
.tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
margin-bottom: 20px;
border-bottom: 1px solid rgba(255,255,255,.1);
}
.tab {
background: none;
color: var(--muted);
font-weight: 600;
padding: 8px 4px;
border-radius: 0;
white-space: nowrap;
border-bottom: 2px solid transparent;
}
.tab:hover { background: none; color: var(--text); }
.tab.active { color: var(--accent-bright); border-bottom-color: var(--accent-bright); }
.episodeList {
display: flex;
flex-direction: column;
gap: 12px;
}
.episodeRow {
display: flex;
gap: 14px;
align-items: center;
background: var(--bg-header);
border-radius: 10px;
padding: 10px;
}
.episodeRow img {
width: 120px;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.episodeRow img.noImage { visibility: hidden; }
.episodeInfo { flex: 1; min-width: 0; }
.episodeInfo strong { display: block; margin-bottom: 4px; }
.episodeInfo p {
margin: 0;
color: var(--muted);
font-size: 13px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.episodeRow button { flex-shrink: 0; }
@media (max-width: 600px) {
.episodeRow { flex-wrap: wrap; }
.episodeInfo { order: 3; flex-basis: 100%; }
}

View file

@ -0,0 +1,11 @@
<button class="linklike" id="back">&larr; Retour</button>
<div class="seriesHeader">
<img class="seriesPoster" id="seriesPoster" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<h1 id="seriesTitle"></h1>
<p class="seriesOverview hide" id="seriesOverview"></p>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
<nav id="seasonTabs" class="tabs"></nav>
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>

View file

@ -0,0 +1,103 @@
import './Series.css';
import html from './Series.html';
import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { escapeHtml } from '../../tiles';
import type { ItemDto, SeasonDto } from '../../types';
export async function renderSeries(root: HTMLElement, params: RouteParams): Promise<void> {
const seriesId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>';
let series: ItemDto;
let seasons: SeasonDto[];
try {
[series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]);
} catch {
root.innerHTML = '<p class="error">Impossible de charger cette série.</p>';
return;
}
root.innerHTML = html;
root.querySelector('#back')!.addEventListener('click', () => history.back());
(root.querySelector('#seriesPoster') as HTMLImageElement).src = series.imageUrl;
root.querySelector('#seriesTitle')!.innerHTML = series.year
? `${escapeHtml(series.name)} <span class="seriesYear">(${series.year})</span>`
: escapeHtml(series.name);
if (series.overview) {
const overviewEl = root.querySelector('#seriesOverview')!;
overviewEl.textContent = series.overview;
overviewEl.classList.remove('hide');
}
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
if (!seasons.length) {
root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
return;
}
const tabsEl = root.querySelector('#seasonTabs')!;
tabsEl.innerHTML = seasons
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
.join('');
tabsEl.querySelectorAll<HTMLButtonElement>('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
if (tab.classList.contains('active')) return;
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
loadEpisodes(root, seriesId, tab.dataset.seasonId as string);
});
});
loadEpisodes(root, seriesId, seasons[0]!.id);
}
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
const listEl = root.querySelector('#episodeList')!;
listEl.innerHTML = '<p class="hint">Chargement...</p>';
let episodes: ItemDto[];
try {
episodes = await getSeriesEpisodes(seriesId, seasonId);
} catch {
listEl.innerHTML = '<p class="error">Impossible de charger les épisodes.</p>';
return;
}
if (!episodes.length) {
listEl.innerHTML = '<p class="hint">Aucun épisode dans cette saison.</p>';
return;
}
// Starting a party from any episode here queues the rest of the series
// from that point on server-side (see CatalogService/index.ts) - the
// host can then use "Épisode suivant" in the player instead of
// restarting per episode.
listEl.innerHTML = episodes.map((ep) => `
<div class="episodeRow">
<img src="${ep.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo">
<strong>${ep.year ? ep.year + ' · ' : ''}${escapeHtml(ep.name)}</strong>
<p class="hint">${escapeHtml(ep.overview || '')}</p>
</div>
<button class="startEpisodeBtn" data-item-id="${ep.id}">Lancer une watch party</button>
</div>
`).join('');
listEl.querySelectorAll<HTMLButtonElement>('.startEpisodeBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
const party = await createParty(btn.dataset.itemId as string);
navigate(`/party/${party.partyId}`);
} catch (err) {
alert((err as Error).message);
btn.disabled = false;
btn.textContent = 'Lancer une watch party';
}
});
});
}

16
client/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}

View file

@ -52,18 +52,28 @@ possible and this gets revisited.
## Components
```
server/ Node.js + Express + Socket.IO. Owns party/room state and the
realtime sync channel. Never touches a media byte - see "Media
never flows through our backend" below.
server/ Node.js + Express + Socket.IO, TypeScript (compiled by tsc).
Proxies 100% of Emby access (catalog, images, HLS/audio
streaming) and owns party/room state + the realtime sync
channel - see "Auth and Emby access" right below for why it's
everything, not just party coordination, and "TypeScript and a
service-oriented architecture" further down for the current
file-by-file layout (one singleton service class per feature).
client/ Static frontend, plain JS + ES modules, no build step (same bias
as the earlier plugin's control panel). Talks to two different
things and must not confuse them:
- Emby directly (embyApi.js): catalog, images, streaming.
- Our own backend (party.js): create/join/leave a party, and
the Socket.IO connection for realtime sync.
client/ Frontend, TypeScript, esbuild-bundled to one app.js + one
app.css (client/dist) - no framework. Talks only to server/,
never to Emby directly (api/embyApi.ts and api/party.ts are
both just fetch/Socket.IO wrappers around this app's own
/api/* routes). One folder per view, each colocating its own
.ts/.html/.css - see "TypeScript and a service-oriented
architecture" further down.
```
Both projects predate this exact shape - see git history and "TypeScript
and a service-oriented architecture" further down for the plain-JS ->
TypeScript rewrite itself, including why (a deliberate, requested choice,
not a default) and what changed structurally.
### Auth and Emby access: the server proxies everything
Revised after the first version: the browser used to call Emby directly
@ -873,6 +883,215 @@ same way a video's does (reasoned to, per "Streaming" above, not
observed), and the real audio quality/gapless-ness of playback through a
transcode. First things to check once this gets a real retest.
## TypeScript and a service-oriented architecture
A full rewrite, requested directly: both `server/` and `client/` are now
TypeScript, and the server is organized as one singleton service class per
feature rather than route handlers with inline logic. This is the largest
single change in this project's history - it touches nearly every file -
so this section covers the shape of it rather than every line moved.
### Why this needed real decisions, not just "add types"
This app was "no build step" from the very first version, deliberately -
the client's plain ES modules loaded straight in the browser, the server
ran its `.js` directly. TypeScript can't compile itself away at
runtime - something has to turn `.ts` into `.js` before either project
can run - so "TypeScript" and "no build step" are mutually exclusive, not
a small addition to the existing setup. Two independent decisions followed:
- **Server: plain `tsc`.** The server doesn't need bundling (Node resolves
`require()`s itself, there's no browser payload-size concern) - just
type-stripping and down-leveling, which is exactly what `tsc` does.
`server/dist` mirrors `server/src`'s structure 1:1.
- **Client: esbuild.** The client *does* need bundling (a browser
shouldn't have to make a dozen round trips for a dozen modules, and
there's no reason to hand-manage `<script type="module">` ordering
either) - esbuild was picked over a heavier tool (Vite, webpack)
because this app's whole client is a couple thousand lines across a
dozen files; esbuild's own built-in behaviors (bundling CSS reached
from JS/TS imports into a sibling `.css` file automatically, a `text`
loader for importing an HTML template as a string) cover everything
needed with a ~30-line hand-written build script
(`client/build.mjs`), not a config file for a tool built for much
bigger apps. Both are consistent with this project's running bias
against dependencies it doesn't need.
- Both projects compile **independently** - the server's `tsc` never
sees client code and vice versa. A real shared-types package was
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 copies of the DTO shapes instead (Party,
ItemDto, the music types, ...) - a normal, accepted trade-off for a
two-small-projects setup like this one.
- `npm run dev` in `server/` uses `tsx` (a single extra dev dependency)
to run straight from `.ts` with no separate compile step while
iterating; `npm run build` (`tsc`) is what actually ships, and what
`server/Dockerfile`'s build stage runs. The client's `npm run watch`
(esbuild's own watch mode) plays the same role there.
### Server: services, not route handlers with logic inline
The old `index.js` had ~15 route handlers with their actual logic - Emby
queries, DTO shaping, override-forcing - written directly inside the
`app.get(...)` callback. `server/src/index.ts` is now a thin wiring layer
only: every route reads the session, calls exactly one service method,
and returns what it gets back. The services, one singleton class per
feature (same shape `PartyService`/`MusicQueueService` already had before
this rewrite - this generalizes that pattern, it doesn't invent it):
- **`AuthService`** (from `embyAuth.js`) - login + the in-memory session store.
- **`CatalogService`** (new - previously inline in `index.js`) - every
Emby browsing/streaming call and the DTO shaping (`toItemDto`,
`applyPlaybackOverrides`) that used to be bare functions at the bottom
of the file. The single largest extraction.
- **`PartyService`** (renamed from `PartyManager`) - unchanged behavior,
now fully typed (see "A real type bug caught during the port" below).
- **`MusicQueueService`** - unchanged behavior, ported as-is (it was
already the newest, already-service-shaped code).
- **`SocketGateway`** (from `socketHandler.js`) - the realtime protocol.
Not stateful itself (no singleton *state*, unlike the four above) but
kept as a class for the same reason `Logger`/`EmbyClient` are: shape
consistency across `server/src`, not because every one of them
strictly needs to be a class.
- **`EmbyClient`** (from `embyProxy.js`) - low-level Emby HTTP +
response proxying. Deliberately *not* called a "service": it has no
state and isn't a feature on its own, just the shared plumbing
`CatalogService`'s routes are built on.
**Error handling got a real pass while this code was being touched
anyway.** Express 4 doesn't catch a rejected Promise from an `async`
route handler on its own - an unhandled rejection there would either
crash the process or hang the request, depending on Node version. Every
route is now wrapped in a small `asyncRoute()` helper
(`index.ts`) that forwards a thrown/rejected error to a single
error-handling middleware at the bottom of the file. `CatalogService`
methods throw a `CatalogFetchError` (carrying its own HTTP status -
e.g. `getRawItem` preserves Emby's real 404 instead of a blanket 502,
which the old code did per-route by hand) rather than touching an Express
response directly, so the same service method works whether it's called
from a route that has a `res` handy or (in principle) from anywhere else.
This wasn't a change asked for - it fell out of centralizing the routes
into services, and it's a real robustness improvement over the old
per-route `if (!embyRes.ok) { res.status(...).json(...); return; }`
repeated fifteen times with no handling at all for a request that
outright throws.
### Client: one folder per view, colocating .ts/.html/.css
Each view is now a small folder (`src/views/<name>/<Name>.ts` +
`.html` + `.css`) instead of one `.js` file with everything - including
the markup, as a big template literal - inline. The static shell of a
view's markup lives in its `.html` (imported as a raw string via
esbuild's `text` loader - see `global.d.ts`'s `declare module '*.html'`);
dynamic, per-item content (a tile grid, an episode list, a music queue)
still gets built as a template-literal-returning function inside the
`.ts`, same as before - only the outer, unchanging shell moved out.
Genuinely shared CSS (the tile grid, the presence-avatar circle, the
series/album detail header pattern, track-list rows) lives in small files
under `src/styles/`, imported by every `.ts` that needs it; `src/styles/base.css`
holds the truly global primitives (the theme's CSS variables, `button`/
`input`/`.linklike`, `.pageHeader`) and is imported exactly once, by
`app.ts`.
This is deliberately *not* pushed to the point of zero duplication or
class renaming across views that happen to share a visual pattern (the
series and album detail headers, for instance, keep their own
`seriesHeader`/`albumHeader` + cover-image classes, sharing only the
inner `seriesInfo`/`seriesYear`/`seriesOverview` block via
`styles/detail.css`) - renaming shared classes purely for tidiness would
have meant touching working, already-tested markup and CSS in lockstep
across files with no functional benefit, for no reason better than
aesthetics, in a change already large enough to review.
`Player.ts` keeps the same internal shape it had before this rewrite: one
file holding both the `/party/:id` route's dispatcher (`renderPartyPlayer`,
exported, registered in `app.ts`) and the video-specific renderer, which
imports `renderMusicPlayer` from the sibling `musicPlayer` folder - the
dispatcher pattern (fetch once, pick a renderer, hand back a synchronous
cleanup wrapper - see "Player: not theater-mode..." above) predates this
rewrite and didn't need to change shape, just types.
### A real type bug caught during the port
Porting `SocketGateway`'s `"members"` broadcast, it was tempting to reuse
the same `PublicMember` interface `PartyService.toPublicState()`'s REST
response uses. They're not the same shape: the REST response's
`members[]` includes `isPaused`/`positionTicks` per member; the socket
broadcast never did (a follower's player only ever reads *`"playstate"`*
events for position, never the member list, for that - see
`SocketGateway`'s original JS, which built a visibly smaller object
inline). Reusing the wrong interface would have type-checked fine (a
smaller object being assigned into a type asking for more fields is
exactly the class of mistake a structural type system like TypeScript's
*does* catch, but only if the two shapes are actually kept distinct) -
caught by comparing the ported code against the original JS line by line
rather than trusting the port "looked right", and fixed by giving the
socket broadcast its own `SocketMemberDto` type. Worth noting because
it's a fairly direct demonstration of what "clean TypeScript" is actually
for here: not decoration, a mistake this port itself introduced and then
caught before it shipped.
### A real Express+TypeScript friction point, resolved once
`tsconfig.json` for both projects enables `noUncheckedIndexedAccess` - a
stricter-than-`strict` flag that treats every indexed access
(`arr[i]`, and critically, Express's `req.params.foo`, which is typed as
an index signature) as possibly-`undefined`, to actually catch
out-of-bounds access. This is genuinely useful (`PartyService.advanceQueue`'s
`party.queue[party.queueIndex]` is exactly the kind of access this
catches if the index math is ever wrong) but makes every single
`req.params.xxx` read in `index.ts` a type error, since Express's own
types don't know a given route only ever matches with that param
present. Resolved once with a `param(req, name)` helper in `index.ts`
(asserts the value as `string` - a matched Express route with a required,
non-optional `:name` segment always has it, which none of these routes'
patterns violate) rather than either scattering non-null assertions at
every call site or writing out a params-generic interface per route for
15 routes.
### Verified
Both projects type-check cleanly in strict mode
(`npm run typecheck`/`tsc --noEmit`, no errors) - not just "compiles",
which `esbuild` would do even with type errors present, since it
transpiles without checking; the client's own separate `tsc --noEmit`
step is what actually guards against that gap.
Server: every unit/integration test written earlier for the JS version
(party lifecycle logging, the music queue's timer/vote/pause behavior
including real `setTimeout`-based auto-advance, the socket-level
integration through `SocketGateway`+`PartyService`+`MusicQueueService`
together, the library-type filter, the playback-override-forcing logic)
was re-run against the **compiled** `server/dist` output, not the
TypeScript source - every one passed unchanged (same assertions, same
counts). The compiled server was also actually started (`node dist/index.js`)
and confirmed listening cleanly, both directly and via `tsx` for the dev
path.
Client: since esbuild bundles everything into one `app.js` (no more
individually-importable view modules the way separate `<script
type="module">` files were before), testing moved from "import one view
and call it directly" to driving the **real, compiled bundle** through
its actual boot → login → router → view chain, with `fetch`/`window.io`/
`window.Hls` mocked and real clicks/hash-navigation - arguably a more
realistic test than the old per-module approach, not just an adaptation
forced by the bundler. Confirmed this way: login, the sidebar, home's
carousels, the movies library, a video party (presence, host controls,
leave), the full music hierarchy (library → artist → album), a music
party (presence, vote-skip, pause toggle, the inline browse-and-add
panel posting the right payload), and the series page - all through
`client/dist`'s actual output, no thrown errors anywhere in the sequence.
**Not verified**: an actual `docker build` of the new multi-stage
Dockerfile - this dev environment has no Docker installed at all. The
Dockerfile's build stage runs the exact same `npm run build` commands
already confirmed working locally for both projects, just inside
`node:20-alpine` instead of this machine, so the main remaining risk is
something environment-specific (a native dependency, a Node version
mismatch) rather than the build commands themselves being wrong - still,
a real build is the first thing to try after this lands, before assuming
the Dockerfile is right.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's

View file

@ -1,5 +1,31 @@
# Build context must be the repo root (not server/), since this also needs
# the client/ folder: docker build -f server/Dockerfile -t watchparty .
#
# Two stages: the first compiles both TypeScript projects (server/src -> JS
# via tsc, client/src -> a bundled JS+CSS via esbuild - see
# docs/architecture.md's TypeScript-migration section for why two separate
# toolchains rather than one shared build), the second is the actual
# runtime image, holding only the compiled output plus production
# dependencies - none of the TypeScript/esbuild toolchain itself ships.
FROM node:20-alpine AS build
WORKDIR /app
COPY server/package.json server/package-lock.json* ./server/
RUN cd server && npm install
COPY server/tsconfig.json ./server/
COPY server/src ./server/src
RUN cd server && npm run build
COPY client/package.json client/package-lock.json* ./client/
RUN cd client && npm install
COPY client/build.mjs ./client/
COPY client/tsconfig.json ./client/
COPY client/src ./client/src
RUN cd client && npm run build
FROM node:20-alpine
WORKDIR /app
@ -7,11 +33,11 @@ WORKDIR /app
COPY server/package.json server/package-lock.json* ./server/
RUN cd server && npm install --omit=dev
COPY server ./server
COPY client ./client
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/client/dist ./client/dist
WORKDIR /app/server
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/index.js"]
CMD ["node", "dist/index.js"]

662
server/package-lock.json generated
View file

@ -11,6 +11,454 @@
"express": "^4.19.2",
"socket.io": "^4.7.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.14.2",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
@ -21,6 +469,27 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@ -30,13 +499,100 @@
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "26.5.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "^1"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.9",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz",
"integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "<1"
}
},
"node_modules/@types/serve-static/node_modules/@types/send": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/ws": {
@ -346,6 +902,48 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -443,6 +1041,21 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -1034,6 +1647,25 @@
"node": ">=0.6"
}
},
"node_modules/tsx": {
"version": "4.23.13",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@ -1047,10 +1679,24 @@
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/unpipe": {

View file

@ -3,10 +3,12 @@
"version": "0.1.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
"main": "dist/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node src/index.js"
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit"
},
"engines": {
"node": ">=18"
@ -14,5 +16,11 @@
"dependencies": {
"express": "^4.19.2",
"socket.io": "^4.7.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.14.2",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
}
}

44
server/src/Logger.ts Normal file
View file

@ -0,0 +1,44 @@
'use strict';
/**
* Minimal server-side logging - plain stdout lines, no logging library.
* This app runs as a single Docker container watched via `docker logs`/
* journalctl (see README's Deploying section), not behind a log
* aggregator, so a consistent prefix + timestamp is what actually makes
* these greppable in practice, not structured JSON nobody's parsing.
*
* Static methods rather than a singleton instance: there's no state here
* at all (unlike PartyService/MusicQueueService, which really do own
* something) - a class is just a namespace for these two, kept as a class
* for consistency with the rest of server/src/services' shape.
*/
export class Logger {
/**
* Party business-lifecycle events - created/joined/left/destroyed, host
* handoff, queue advance, playstate transitions, pause/skip requests.
* Deliberately covers only that, not connection-level noise like every
* Socket.IO reconnect or the 3s playstate heartbeat re-sending the same
* state (see SocketGateway, which only calls this on an actual
* play/pause transition, not every heartbeat tick).
*/
static party(message: string): void {
console.log(`[watchparty:party] ${new Date().toISOString()} ${message}`);
}
/**
* The streaming route specifically (CatalogService's /master.m3u8
* handler) - a different concern from the party lifecycle above (it's
* about one HTTP request, not party state). Deliberately logs only the
* individual override values it cares about, never the full resolved
* URL or params blob - Emby's own TranscodingUrl can carry an api_key,
* and that has no business ending up in a log file.
*/
static stream(message: string): void {
console.log(`[watchparty:stream] ${new Date().toISOString()} ${message}`);
}
/** `"name(id)"` - the same shape in every party lifecycle log line. */
static who(userId: string, userName: string): string {
return `${userName}(${userId})`;
}
}

19
server/src/config.ts Normal file
View file

@ -0,0 +1,19 @@
'use strict';
/**
* Environment-derived config, read once at startup. Its own tiny module
* (rather than living inside AuthService, which used to own
* EMBY_SERVER_URL) since both AuthService and CatalogService/EmbyClient
* need it, and there's no reason CatalogService should have to depend on
* AuthService just to read a URL.
*/
export const EMBY_SERVER_URL = (process.env.EMBY_SERVER_URL || '').replace(/\/+$/, '');
if (!EMBY_SERVER_URL) {
console.warn(
'[watchparty] EMBY_SERVER_URL is not set - every Emby call will fail. ' +
'Set it to your Emby server base URL, e.g. https://theater.media.kyuno.fr'
);
}
export const PORT = Number(process.env.PORT) || 3000;

View file

@ -0,0 +1,88 @@
'use strict';
import type { Request, Response } from 'express';
import { Readable } from 'stream';
import { EMBY_SERVER_URL } from '../config';
/**
* Low-level HTTP plumbing for talking to Emby and proxying its responses
* back to the browser - not a "feature" service itself (no state, nothing
* a client action changes), just the shared mechanics CatalogService's
* routes are built on. See docs/architecture.md for why the server proxies
* 100% of Emby access instead of the browser talking to Emby directly.
*/
export class EmbyClient {
/** Fetches a path from Emby with the given user's token, forwarding
* Range (needed for direct-play video seeking) when the browser sent one. */
static async fetch(pathAndQuery: string, token: string, options: { range?: string } = {}): Promise<globalThis.Response> {
const url = pathAndQuery.startsWith('http') ? pathAndQuery : `${EMBY_SERVER_URL}${pathAndQuery}`;
const headers: Record<string, string> = { 'X-Emby-Token': token };
if (options.range) headers.Range = options.range;
return fetch(url, { headers });
}
static isPlaylistPath(path: string): boolean {
return /\.m3u8(\?|$)/i.test(path);
}
/**
* Rewrites every URI line in an HLS playlist so it routes back through
* our own proxy (`/api/catalog/stream/:itemId/raw?u=...`) instead of
* Emby directly - whether Emby wrote that line as a relative or an
* absolute URL. Without this, hls.js would follow the playlist's own
* URLs straight to Emby, defeating the point of proxying the master
* playlist in the first place. TODO(verify): only tested against
* synthetic input, not a real Emby-generated playlist - see
* docs/architecture.md.
*/
static rewritePlaylist(text: string, itemId: string): string {
return text
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
return `/api/catalog/stream/${itemId}/raw?u=${encodeURIComponent(trimmed)}`;
})
.join('\n');
}
/**
* Proxies an Emby response to an Express response: forwards status,
* content-type, and the headers that make byte-range seeking work
* (content-range/accept-ranges), rewriting HLS playlists in transit and
* streaming everything else (images, video/audio segments) through
* untouched - no buffering, so this doesn't hold a whole segment in memory.
*/
static async proxyResponse(req: Request, res: Response, pathAndQuery: string, token: string, itemId: string): Promise<void> {
let embyRes: globalThis.Response;
try {
embyRes = await EmbyClient.fetch(pathAndQuery, token, { range: req.headers.range });
} catch {
res.status(502).json({ error: 'Serveur Emby injoignable.' });
return;
}
res.status(embyRes.status);
const contentType = embyRes.headers.get('content-type') || '';
if (contentType) res.setHeader('Content-Type', contentType);
for (const h of ['content-range', 'accept-ranges']) {
const v = embyRes.headers.get(h);
if (v) res.setHeader(h, v);
}
if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
const text = await embyRes.text();
res.send(EmbyClient.rewritePlaylist(text, itemId));
return;
}
const contentLength = embyRes.headers.get('content-length');
if (contentLength) res.setHeader('Content-Length', contentLength);
if (!embyRes.body) {
res.end();
return;
}
Readable.fromWeb(embyRes.body as import('stream/web').ReadableStream<Uint8Array>).pipe(res);
}
}

View file

@ -1,108 +0,0 @@
'use strict';
const crypto = require('crypto');
const EMBY_SERVER_URL = (process.env.EMBY_SERVER_URL || '').replace(/\/+$/, '');
if (!EMBY_SERVER_URL) {
console.warn(
'[watchparty] EMBY_SERVER_URL is not set - every Emby call will fail. ' +
'Set it to your Emby server base URL, e.g. https://theater.media.kyuno.fr'
);
}
const SESSION_COOKIE = 'wp_session';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// sessionId -> { embyToken, userId, userName, createdAt }
// In-memory, like party state - a restart signs everyone out, which is fine.
const sessions = new Map();
function authHeaderValue() {
return 'MediaBrowser Client="WatchParty", Device="Server", DeviceId="watchparty-server", Version="0.1.0"';
}
/**
* Signs in against Emby *from the server*. The browser only ever sends its
* username/password once, to us; we forward that to Emby, and from then on
* the browser only holds an opaque session id (cookie) - never Emby's own
* access token. This is what makes "the server proxies everything" possible:
* the browser has nothing it could use to talk to Emby even if it tried.
*
* Returns a new session id, or null on bad credentials / unreachable Emby.
*/
async function login(username, password) {
if (!EMBY_SERVER_URL) return null;
let res;
try {
res = await fetch(`${EMBY_SERVER_URL}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': authHeaderValue()
},
body: JSON.stringify({ Username: username, Pw: password })
});
} catch (err) {
console.error('[watchparty] Emby login request failed:', err.message);
return null;
}
if (!res.ok) {
return null;
}
const data = await res.json();
const sessionId = crypto.randomUUID();
sessions.set(sessionId, {
embyToken: data.AccessToken,
userId: data.User.Id,
userName: data.User.Name,
serverId: data.ServerId, // needed to build "open in Emby" deep links
createdAt: Date.now()
});
return sessionId;
}
function getSession(sessionId) {
const session = sessions.get(sessionId);
if (!session) return null;
if (Date.now() - session.createdAt > SESSION_TTL_MS) {
sessions.delete(sessionId);
return null;
}
return session;
}
function endSession(sessionId) {
sessions.delete(sessionId);
}
function parseCookies(cookieHeader) {
const out = {};
(cookieHeader || '').split(';').forEach((part) => {
const idx = part.indexOf('=');
if (idx === -1) return;
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
});
return out;
}
/** Reads the caller's session from an Express request's cookie, or null. */
function sessionFromRequest(req) {
const cookies = parseCookies(req.headers.cookie);
const id = cookies[SESSION_COOKIE];
return id ? getSession(id) : null;
}
module.exports = {
EMBY_SERVER_URL,
SESSION_COOKIE,
SESSION_TTL_MS,
login,
getSession,
endSession,
parseCookies,
sessionFromRequest
};

View file

@ -1,79 +0,0 @@
'use strict';
const { Readable } = require('stream');
const { EMBY_SERVER_URL } = require('./embyAuth');
/** Fetches a path from Emby with the given user's token, forwarding Range
* (needed for direct-play video seeking) when the browser sent one. */
async function embyFetch(pathAndQuery, token, { range } = {}) {
const url = pathAndQuery.startsWith('http') ? pathAndQuery : `${EMBY_SERVER_URL}${pathAndQuery}`;
const headers = { 'X-Emby-Token': token };
if (range) headers.Range = range;
return fetch(url, { headers });
}
function isPlaylistPath(path) {
return /\.m3u8(\?|$)/i.test(path);
}
/**
* Rewrites every URI line in an HLS playlist so it routes back through our
* own proxy (`/api/catalog/stream/:itemId/raw?u=...`) instead of Emby
* directly - whether Emby wrote that line as a relative or an absolute URL.
* Without this, hls.js would follow the playlist's own URLs straight to
* Emby, defeating the point of proxying the master playlist in the first
* place. TODO(verify): only tested against synthetic input, not a real
* Emby-generated playlist - see docs/architecture.md.
*/
function rewritePlaylist(text, itemId) {
return text
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
return `/api/catalog/stream/${itemId}/raw?u=${encodeURIComponent(trimmed)}`;
})
.join('\n');
}
/**
* Proxies an Emby response to an Express response: forwards status,
* content-type, and the headers that make byte-range seeking work
* (content-range/accept-ranges), rewriting HLS playlists in transit and
* streaming everything else (images, video segments) through untouched -
* no buffering, so this doesn't hold a whole video segment in memory.
*/
async function proxyEmbyResponse(req, res, pathAndQuery, token, itemId) {
let embyRes;
try {
embyRes = await embyFetch(pathAndQuery, token, { range: req.headers.range });
} catch (err) {
res.status(502).json({ error: 'Serveur Emby injoignable.' });
return;
}
res.status(embyRes.status);
const contentType = embyRes.headers.get('content-type') || '';
if (contentType) res.setHeader('Content-Type', contentType);
for (const h of ['content-range', 'accept-ranges']) {
const v = embyRes.headers.get(h);
if (v) res.setHeader(h, v);
}
if (isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
const text = await embyRes.text();
res.send(rewritePlaylist(text, itemId));
return;
}
const contentLength = embyRes.headers.get('content-length');
if (contentLength) res.setHeader('Content-Length', contentLength);
if (!embyRes.body) {
res.end();
return;
}
Readable.fromWeb(embyRes.body).pipe(res);
}
module.exports = { embyFetch, proxyEmbyResponse, isPlaylistPath };

View file

@ -1,623 +0,0 @@
'use strict';
const path = require('path');
const http = require('http');
const express = require('express');
const { Server: SocketIoServer } = require('socket.io');
const {
EMBY_SERVER_URL,
SESSION_COOKIE,
SESSION_TTL_MS,
login,
getSession,
endSession,
parseCookies,
sessionFromRequest
} = require('./embyAuth');
const { embyFetch, proxyEmbyResponse } = require('./embyProxy');
const { PartyManager } = require('./partyManager');
const { MusicQueueService } = require('./musicQueueService');
const { attachSocketServer, broadcastLobby, applyLeave } = require('./socketHandler');
const { streamLog } = require('./logger');
const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
app.use(express.json());
// Everything the browser needs - the app shell AND every Emby call - is
// same-origin from here on. See docs/architecture.md for why: the previous
// version had the browser call Emby directly and hit real CORS problems.
app.use(express.static(path.join(__dirname, '..', '..', 'client')));
function requireSession(req, res, next) {
const session = sessionFromRequest(req);
if (!session) {
res.status(401).json({ error: 'Session expirée, reconnecte-toi.' });
return;
}
req.embySession = session;
next();
}
// --- Auth ----------------------------------------------------------------
// The browser only ever sends a username/password once, to us. From then on
// it holds an opaque session cookie - never Emby's own access token.
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
res.status(400).json({ error: "Nom d'utilisateur et mot de passe requis." });
return;
}
const sessionId = await login(username, password);
if (!sessionId) {
res.status(401).json({ error: 'Identifiants refusés.' });
return;
}
res.cookie(SESSION_COOKIE, sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: req.secure,
maxAge: SESSION_TTL_MS
});
res.json({ userName: getSession(sessionId).userName });
});
app.post('/api/auth/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[SESSION_COOKIE]) {
endSession(cookies[SESSION_COOKIE]);
}
res.clearCookie(SESSION_COOKIE);
res.json({ ok: true });
});
app.get('/api/auth/me', requireSession, (req, res) => {
res.json({ userName: req.embySession.userName });
});
// --- Catalog (proxied - the browser never talks to Emby directly) --------
app.get('/api/catalog/search', requireSession, async (req, res) => {
const q = (req.query.q || '').trim();
if (q.length < 2) {
res.json({ items: [] });
return;
}
const params = new URLSearchParams({
searchTerm: q,
IncludeItemTypes: 'Movie,Episode,Series',
Recursive: 'true',
Fields: 'Overview,ProductionYear',
ImageTypeLimit: '1',
Limit: '24'
});
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Recherche impossible côté Emby.' });
return;
}
const data = await embyRes.json();
res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) });
});
app.get('/api/catalog/item/:itemId', requireSession, async (req, res) => {
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${req.params.itemId}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(embyRes.status).json({ error: 'Titre introuvable.' });
return;
}
res.json(toItemDto(await embyRes.json(), req.embySession.serverId));
});
// Emby's /Views can return all sorts of things beyond actual media
// libraries - boxset collections, playlists, live TV, photos, books, a
// mixed-content folder with no CollectionType at all - none of which this
// app has any UI for. Only these four are ever shown, in the sidebar or
// anywhere else: everything else is dropped here, at the source, so every
// consumer (shell.js's sidebar, home.js's carousels, direct navigation to
// /library/:id) is filtered the same way without each having to repeat the
// same check.
const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos']);
// One entry per library, shown in the sidebar - see client/js/shell.js.
app.get('/api/catalog/libraries', requireSession, async (req, res) => {
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Views`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les bibliothèques.' });
return;
}
const data = await embyRes.json();
res.json({
libraries: (data.Items || [])
.filter((v) => VISIBLE_COLLECTION_TYPES.has(v.CollectionType))
.map((v) => ({
id: v.Id,
name: v.Name,
// "movies" / "tvshows" / "music" / "musicvideos" - drives which
// browsing experience client/js/views/library.js renders. See
// docs/architecture.md.
collectionType: v.CollectionType
}))
});
});
// Home page carousel: Emby's own "recently added" endpoint, one row per library.
app.get('/api/catalog/libraries/:libraryId/recent', requireSession, async (req, res) => {
const params = new URLSearchParams({ ParentId: req.params.libraryId, Limit: '16', Fields: 'ProductionYear' });
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/Latest?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les ajouts récents.' });
return;
}
const data = await embyRes.json();
// Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}.
const items = Array.isArray(data) ? data : (data.Items || []);
res.json({ items: items.map((i) => toItemDto(i, req.embySession.serverId)) });
});
// Series -> Seasons -> Episodes, for the "Contenus vidéo en série" browsing page.
app.get('/api/catalog/series/:seriesId/seasons', requireSession, async (req, res) => {
const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Seasons?userId=${req.embySession.userId}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les saisons.' });
return;
}
const data = await embyRes.json();
res.json({
seasons: (data.Items || []).map((s) => ({ id: s.Id, name: s.Name, indexNumber: s.IndexNumber }))
});
});
app.get('/api/catalog/series/:seriesId/episodes', requireSession, async (req, res) => {
const params = new URLSearchParams({ userId: req.embySession.userId, Fields: 'Overview' });
if (req.query.seasonId) params.set('SeasonId', req.query.seasonId);
const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Episodes?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les épisodes.' });
return;
}
const data = await embyRes.json();
res.json({ episodes: (data.Items || []).map((e) => toItemDto(e, req.embySession.serverId)) });
});
// Library -> Artists -> Albums -> Tracks, for the "Musique" browsing pages
// (client/js/views/library.js, artist.js, album.js). Each its own Emby
// endpoint/query rather than guessing folder structure, same reasoning as
// Shows -> Seasons -> Episodes above.
app.get('/api/catalog/libraries/:libraryId/artists', requireSession, async (req, res) => {
const params = new URLSearchParams({ ParentId: req.params.libraryId, UserId: req.embySession.userId, SortBy: 'SortName' });
const embyRes = await embyFetch(`/Artists?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les artistes.' });
return;
}
const data = await embyRes.json();
res.json({ artists: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) });
});
app.get('/api/catalog/artists/:artistId/albums', requireSession, async (req, res) => {
const params = new URLSearchParams({
ArtistIds: req.params.artistId,
UserId: req.embySession.userId,
IncludeItemTypes: 'MusicAlbum',
Recursive: 'true',
SortBy: 'ProductionYear,SortName',
Fields: 'ProductionYear'
});
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les albums.' });
return;
}
const data = await embyRes.json();
res.json({ albums: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) });
});
app.get('/api/catalog/albums/:albumId/tracks', requireSession, async (req, res) => {
const params = new URLSearchParams({
ParentId: req.params.albumId,
UserId: req.embySession.userId,
IncludeItemTypes: 'Audio',
SortBy: 'IndexNumber',
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist'
});
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les morceaux.' });
return;
}
const data = await embyRes.json();
res.json({ tracks: (data.Items || []).map((t) => toItemDto(t, req.embySession.serverId)) });
});
// Audio/subtitle tracks available for an item, read from Emby's own playback
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
app.get('/api/catalog/item/:itemId/playback-options', requireSession, async (req, res) => {
const infoRes = await embyFetch(`/Items/${req.params.itemId}/PlaybackInfo?UserId=${req.embySession.userId}`, req.embySession.embyToken);
if (!infoRes.ok) {
res.status(502).json({ error: 'Impossible de lire les options de lecture.' });
return;
}
const info = await infoRes.json();
const source = (info.MediaSources || [])[0];
const streams = (source && source.MediaStreams) || [];
res.json({
audioTracks: streams
.filter((s) => s.Type === 'Audio')
.map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` })),
subtitleTracks: streams
.filter((s) => s.Type === 'Subtitle')
.map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` }))
});
});
app.get('/api/catalog/libraries/:libraryId/items', requireSession, async (req, res) => {
const params = new URLSearchParams({
ParentId: req.params.libraryId,
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
SortBy: 'SortName',
Fields: 'Overview,ProductionYear',
ImageTypeLimit: '1',
Limit: '300'
});
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer le contenu de cette bibliothèque.' });
return;
}
const data = await embyRes.json();
res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) });
});
// Images/video are requested by <img>/<video> elements, which send the
// session cookie (same-origin) but can't carry a bearer header - so these
// two routes check the cookie directly instead of using the requireSession
// middleware's JSON error response, which a browser media element ignores.
app.get('/api/catalog/image/:itemId', async (req, res) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await proxyEmbyResponse(req, res, `/Items/${req.params.itemId}/Images/Primary?maxWidth=400`, session.embyToken, req.params.itemId);
});
app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
const itemId = req.params.itemId;
// Ask Emby how it wants this played rather than hand-building a
// /Videos/{id}/master.m3u8 URL ourselves: the first version did that with
// only VideoCodec/AudioCodec, and Emby's server threw "Value cannot be
// null (Parameter 'key')" - almost certainly an internal dictionary
// lookup (MediaSourceId/PlaySessionId/DeviceId) that URL never supplied.
// PlaybackInfo returns a MediaSource with its own ready-to-use
// TranscodingUrl carrying whatever Emby actually needs.
//
// AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate (and
// SubtitleMethod=Encode, forced by applyPlaybackOverrides whenever a
// subtitle is requested - see its own comment below) come from the
// player's language/subtitle/quality pickers, applied to the negotiation
// itself, not just the final URL. That distinction matters here: whether
// to actually re-encode the video (required to burn in a subtitle) is a
// decision Emby makes *during* PlaybackInfo, not something a later
// query-string tweak on the resulting URL can retroactively change if
// Emby already set up a plain remux. Still not trusted to have taken
// effect on its own, though (this app never sends the
// POST-with-DeviceProfile body real Emby web clients use for that
// negotiation, so there's no guarantee a plain GET applies it the same
// way) - applyPlaybackOverrides is applied a second time below, to
// whatever URL we end up with, so a track pick can't silently no-op
// regardless of which path Emby's response takes.
const infoParams = new URLSearchParams({ UserId: session.userId });
applyPlaybackOverrides(infoParams, req.query);
const infoRes = await embyFetch(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session.embyToken);
if (!infoRes.ok) {
res.status(502).json({ error: 'Impossible de préparer la lecture de ce titre.' });
return;
}
const info = await infoRes.json();
const source = (info.MediaSources || [])[0];
if (!source) {
res.status(502).json({ error: 'Aucune source de lecture disponible pour ce titre.' });
return;
}
// TODO(verify): TranscodingUrl not confirmed present for every source
// type/Emby version (e.g. a source Emby considers direct-playable may not
// have one at all) - the hand-built fallback below now at least supplies
// the parameters PlaybackInfo reveals (MediaSourceId, PlaySessionId,
// UserId) instead of guessing, but is still a fallback, not the primary path.
// Music tracks stream through this exact route too (same PlaybackInfo ->
// TranscodingUrl dance - Emby's negotiation isn't video-specific) - the
// only place that actually differs is the hand-built fallback path,
// which is genuinely different between the two (/Videos/ vs /Audio/) and
// has no way to know which without being told: PlaybackInfo's own
// response doesn't reliably carry the item's Type back, and looking it
// up would be an extra Emby round trip for what the client already
// knows. So the client passes mediaType=audio (see streamUrl() in
// embyApi.js) - cosmetic in every other sense, this is the one place it
// actually changes what gets requested.
const isAudio = req.query.mediaType === 'audio';
let target;
if (source.TranscodingUrl) {
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
const params = new URLSearchParams(urlQuery || '');
applyPlaybackOverrides(params, req.query);
target = `${urlPath}?${params}`;
logStreamRequest(itemId, 'negotiated', params);
} else {
const params = new URLSearchParams({
UserId: session.userId,
MediaSourceId: source.Id || '',
PlaySessionId: info.PlaySessionId || '',
AudioCodec: 'aac',
...(isAudio ? {} : { VideoCodec: 'h264' })
});
applyPlaybackOverrides(params, req.query);
target = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
logStreamRequest(itemId, 'fallback', params);
}
await proxyEmbyResponse(req, res, target, session.embyToken, itemId);
});
// Deliberately logs only these specific override values, never the full
// resolved URL/params blob - Emby's own TranscodingUrl can carry an
// api_key, and that has no business ending up in a log file. Added
// alongside the language/subtitle fix above so it's actually possible to
// confirm from docker logs/journalctl whether a requested override made it
// into the request Emby received, without a devtools session on whatever
// machine is running the server.
function logStreamRequest(itemId, source, params) {
streamLog(
`master.m3u8 itemId=${itemId} source=${source} ` +
`audio=${params.get('AudioStreamIndex') || '(default)'} ` +
`subtitle=${params.get('SubtitleStreamIndex') || '(none)'} ` +
`subtitleMethod=${params.get('SubtitleMethod') || '(none)'} ` +
`quality=${params.get('MaxStreamingBitrate') || '(auto)'}`
);
}
/**
* Forces this app's own audio/subtitle/quality selection onto a transcode
* URL, whether that URL came from Emby's own PlaybackInfo negotiation or
* was hand-built as a fallback - see the route above. Without this, a pick
* from the player's settings menu could silently do nothing if Emby's
* negotiation didn't apply the query-string override the same way its own
* web client's POST-with-DeviceProfile request would have.
*
* SubtitleMethod=Encode is forced whenever a subtitle track is requested:
* left to its own default, Emby may instead deliver text subtitles as a
* separate selectable HLS rendition (SubtitleMethod=Hls) rather than
* burning them into the video - and this player never wires up hls.js's
* subtitleTrack API to pick one, so a rendition-based subtitle would be
* present in the manifest but never actually shown. Encode guarantees the
* subtitle is part of the one video stream this player already renders.
* Picking "Aucun" removes any subtitle Emby's own negotiation added, rather
* than just not adding a new one - otherwise turning subtitles off from a
* state where Emby had already picked a default would be a no-op too.
*/
function applyPlaybackOverrides(params, query) {
if (query.AudioStreamIndex) params.set('AudioStreamIndex', query.AudioStreamIndex);
if (query.SubtitleStreamIndex) {
params.set('SubtitleStreamIndex', query.SubtitleStreamIndex);
params.set('SubtitleMethod', 'Encode');
} else {
params.delete('SubtitleStreamIndex');
params.delete('SubtitleMethod');
}
if (query.MaxStreamingBitrate) params.set('MaxStreamingBitrate', query.MaxStreamingBitrate);
}
// Everything an HLS playlist references (variant playlists, segments) comes
// back through here - see embyProxy.rewritePlaylist.
app.get('/api/catalog/stream/:itemId/raw', async (req, res) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
const target = req.query.u || '';
const itemId = req.params.itemId;
let pathAndQuery;
if (/^https?:\/\//i.test(target)) {
if (!target.startsWith(EMBY_SERVER_URL)) {
res.status(400).end(); // never proxy an arbitrary URL, only our own Emby server
return;
}
pathAndQuery = target;
} else {
pathAndQuery = `/Videos/${itemId}/${target}`;
}
await proxyEmbyResponse(req, res, pathAndQuery, session.embyToken, itemId);
});
// 'MusicVideo' is Emby's own item type for a musicvideos library's
// contents (distinct from the generic 'Video', which is home videos) -
// needed now that musicvideos is one of the visible library types (see
// VISIBLE_COLLECTION_TYPES above), otherwise every tile in it would render
// with no "Lancer une watch party" button at all. 'Audio' is a music track.
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
function toItemDto(item, serverId) {
return {
id: item.Id,
name: item.Name,
overview: item.Overview,
year: item.ProductionYear,
type: item.Type,
// Series/Season/MusicArtist/MusicAlbum aren't directly playable - only
// show "start a party" for things Emby can actually stream. "Voir sur
// Emby" always works though.
canWatchParty: WATCH_PARTY_TYPES.has(item.Type),
imageUrl: `/api/catalog/image/${item.Id}`,
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
// Track-specific (undefined/null for anything else - harmless, every
// consumer that cares about these only reads them for an Audio item).
durationTicks: item.RunTimeTicks || null,
indexNumber: item.IndexNumber != null ? item.IndexNumber : null,
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
albumName: item.Album || null
};
}
// --- Parties ---------------------------------------------------------------
// No REST "list parties" endpoint: the home page's "watch parties en cours"
// list is live, over Socket.IO's "lobby" room (socketHandler.js) - see
// docs/architecture.md. No join codes either - see partyManager.js's class
// comment for why a live list makes one redundant.
app.post('/api/parties', requireSession, async (req, res) => {
const itemId = req.body && req.body.itemId;
if (!itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${itemId}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(404).json({ error: 'Titre introuvable.' });
return;
}
const item = await embyRes.json();
const mediaType = item.Type === 'Audio' ? 'music' : 'video';
// Starting a party from an episode queues the rest of the series from
// there on, so the host can hit "next" (see socketHandler.js) instead of
// everyone having to separately start a new party per episode. Doesn't
// apply to music - that party's queue is the live, participative one
// instead (see MusicQueueService.init below).
let queue = null;
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
const epsRes = await embyFetch(
`/Shows/${item.SeriesId}/Episodes?userId=${req.embySession.userId}&Fields=Overview`,
req.embySession.embyToken
);
if (epsRes.ok) {
const epsData = await epsRes.json();
const all = epsData.Items || [];
const startIndex = all.findIndex((e) => e.Id === itemId);
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
queue = remaining.map((e) => ({
itemId: e.Id,
itemName: formatEpisodeName(e),
itemImageUrl: `/api/catalog/image/${e.Id}`
}));
}
}
const party = PartyManager.create(
req.embySession.userId,
req.embySession.userName,
itemId,
queue ? queue[0].itemName : item.Name,
`/api/catalog/image/${itemId}`,
queue,
mediaType
);
if (mediaType === 'music') {
MusicQueueService.init(party, {
itemId,
itemName: item.Name,
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
albumName: item.Album || null,
itemImageUrl: `/api/catalog/image/${itemId}`,
durationTicks: item.RunTimeTicks || null
}, req.embySession.userId, req.embySession.userName);
}
broadcastLobby(io); // the new party shows up on everyone's home page immediately
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
function formatEpisodeName(episode) {
if (episode.ParentIndexNumber != null && episode.IndexNumber != null) {
const prefix = `S${episode.ParentIndexNumber}E${episode.IndexNumber}`;
return episode.SeriesName ? `${episode.SeriesName} - ${prefix} - ${episode.Name}` : `${prefix} - ${episode.Name}`;
}
return episode.Name;
}
// Adding a track to a music party's shared queue is REST, not a socket
// event (see socketHandler.js's top comment): the album-browsing page
// (client/js/views/album.js) that this is actually called from has no live
// connection to the party it's adding into - only the player view
// (musicPlayer.js) does. MusicQueueService's own "queueUpdated" broadcast
// (wired to the real io instance via attachIo, below) reaches anyone
// actually connected regardless of which page made this request.
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Watch party musique introuvable.' });
return;
}
if (!party.members.has(req.embySession.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
const track = req.body || {};
if (!track.itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
MusicQueueService.addTrack(party, track, req.embySession.userId, req.embySession.userName);
broadcastLobby(io); // the queue/now-playing preview may have just changed
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (!party) {
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
return;
}
PartyManager.join(party, req.embySession.userId, req.embySession.userName);
broadcastLobby(io); // member count changed - reflect it on the home page immediately
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (party) {
// Shared with the socket "leave"/"disconnect" handlers so the host-
// handoff notification and the lobby refresh only live in one place.
applyLeave(io, party, req.embySession.userId);
}
res.json({ ok: true });
});
app.get('/api/parties/:partyId', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (!party) {
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
return;
}
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
const server = http.createServer(app);
// Same-origin by default (no `cors` option needed) - the client connects
// with a bare io() call, consistent with everything else in this app never
// crossing origins. Socket.IO's own heartbeat (pingInterval/pingTimeout,
// defaults are fine here) is what detects dead connections - see
// socketHandler.js's top comment for why that matters.
const io = new SocketIoServer(server);
attachSocketServer(io, { parseCookies, getSession });
// Gives MusicQueueService a way to broadcast from its own internal
// auto-advance timer (no socket/request in scope there at all) and to
// refresh the home page's lobby card when the timer - not a person
// clicking anything - changes what's playing. Passed in rather than
// required directly from socketHandler.js/musicQueueService.js to avoid a
// circular require between the two.
MusicQueueService.attachIo(io, () => broadcastLobby(io));
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(`[watchparty] listening on :${port} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
});

344
server/src/index.ts Normal file
View file

@ -0,0 +1,344 @@
'use strict';
import path from 'path';
import http from 'http';
import express, { type NextFunction, type Request, type Response } from 'express';
import { Server as SocketIoServer } from 'socket.io';
import { EMBY_SERVER_URL, PORT } from './config';
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
import { CatalogService, CatalogFetchError } from './services/CatalogService';
import { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService';
import { SocketGateway } from './realtime/SocketGateway';
import type { ClientToServerEvents, MediaType, QueueItem, ServerToClientEvents } from './types';
const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
app.use(express.json());
// Everything the browser needs - the app shell AND every Emby call - is
// same-origin from here on. See docs/architecture.md for why: the previous
// version had the browser call Emby directly and hit real CORS problems.
// client/dist is esbuild's output (client/src compiled) - see
// client/build.mjs and docs/architecture.md's TypeScript-migration section.
app.use(express.static(path.join(__dirname, '..', '..', 'client', 'dist')));
function requireSession(req: Request, res: Response, next: NextFunction): void {
const session = AuthService.sessionFromRequest(req);
if (!session) {
res.status(401).json({ error: 'Session expirée, reconnecte-toi.' });
return;
}
req.embySession = session;
next();
}
/**
* Express 4 doesn't catch a rejected Promise from an async route handler
* on its own - an unhandled rejection there would either crash the
* process or just hang the request forever, depending on Node version.
* Every route below is wrapped in this so a service throwing
* (CatalogFetchError, a raw network failure, anything) always turns into
* a real HTTP response instead - see the error-handling middleware at the
* bottom of this file for where it actually gets turned into one.
*/
function asyncRoute(handler: (req: Request, res: Response) => Promise<void>) {
return (req: Request, res: Response, next: NextFunction) => {
handler(req, res).catch(next);
};
}
/**
* Reads a required `:name` route param. A matched Express route always has
* one (Express itself guarantees it; none of these routes use an optional
* `:name?` segment) - this just gives TypeScript the same certainty:
* `req.params` is an index-signature type, so `noUncheckedIndexedAccess`
* (tsconfig.json) sees every access as possibly-`undefined` on its own.
*/
function param(req: Request, name: string): string {
return req.params[name] as string;
}
// --- Auth ----------------------------------------------------------------
// The browser only ever sends a username/password once, to us. From then on
// it holds an opaque session cookie - never Emby's own access token.
app.post('/api/auth/login', asyncRoute(async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
res.status(400).json({ error: "Nom d'utilisateur et mot de passe requis." });
return;
}
const sessionId = await AuthService.login(username, password);
if (!sessionId) {
res.status(401).json({ error: 'Identifiants refusés.' });
return;
}
res.cookie(SESSION_COOKIE, sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: req.secure,
maxAge: SESSION_TTL_MS
});
res.json({ userName: AuthService.getSession(sessionId)!.userName });
}));
app.post('/api/auth/logout', (req, res) => {
const cookies = AuthService.parseCookies(req.headers.cookie);
if (cookies[SESSION_COOKIE]) {
AuthService.endSession(cookies[SESSION_COOKIE]);
}
res.clearCookie(SESSION_COOKIE);
res.json({ ok: true });
});
app.get('/api/auth/me', requireSession, (req, res) => {
res.json({ userName: req.embySession!.userName });
});
// --- Catalog (proxied - the browser never talks to Emby directly) --------
// Thin wrappers over CatalogService - see its own file for the actual
// Emby calls/DTO shaping.
app.get('/api/catalog/search', requireSession, asyncRoute(async (req, res) => {
const items = await CatalogService.search(req.embySession!, String(req.query.q || ''));
res.json({ items });
}));
app.get('/api/catalog/item/:itemId', requireSession, asyncRoute(async (req, res) => {
res.json(await CatalogService.getItem(req.embySession!, param(req, 'itemId')));
}));
// One entry per library, shown in the sidebar - see client/src's Shell.
app.get('/api/catalog/libraries', requireSession, asyncRoute(async (req, res) => {
res.json({ libraries: await CatalogService.getLibraries(req.embySession!) });
}));
// Home page carousel: Emby's own "recently added" endpoint, one row per library.
app.get('/api/catalog/libraries/:libraryId/recent', requireSession, asyncRoute(async (req, res) => {
res.json({ items: await CatalogService.getRecentItems(req.embySession!, param(req, 'libraryId')) });
}));
// Series -> Seasons -> Episodes, for the "Contenus vidéo en série" browsing page.
app.get('/api/catalog/series/:seriesId/seasons', requireSession, asyncRoute(async (req, res) => {
res.json({ seasons: await CatalogService.getSeriesSeasons(req.embySession!, param(req, 'seriesId')) });
}));
app.get('/api/catalog/series/:seriesId/episodes', requireSession, asyncRoute(async (req, res) => {
const seasonId = typeof req.query.seasonId === 'string' ? req.query.seasonId : undefined;
res.json({ episodes: await CatalogService.getSeriesEpisodes(req.embySession!, param(req, 'seriesId'), seasonId) });
}));
// Library -> Artists -> Albums -> Tracks, for the "Musique" browsing pages.
app.get('/api/catalog/libraries/:libraryId/artists', requireSession, asyncRoute(async (req, res) => {
res.json({ artists: await CatalogService.getArtists(req.embySession!, param(req, 'libraryId')) });
}));
app.get('/api/catalog/artists/:artistId/albums', requireSession, asyncRoute(async (req, res) => {
res.json({ albums: await CatalogService.getArtistAlbums(req.embySession!, param(req, 'artistId')) });
}));
app.get('/api/catalog/albums/:albumId/tracks', requireSession, asyncRoute(async (req, res) => {
res.json({ tracks: await CatalogService.getAlbumTracks(req.embySession!, param(req, 'albumId')) });
}));
// Audio/subtitle tracks available for an item, read from Emby's own playback
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute(async (req, res) => {
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId')));
}));
app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
}));
// Images/video/audio are requested by <img>/<video>/<audio> elements, which
// send the session cookie (same-origin) but can't carry a bearer header -
// so these three routes check the cookie directly instead of using the
// requireSession middleware's JSON error response, which a browser media
// element ignores.
app.get('/api/catalog/image/:itemId', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await CatalogService.proxyImage(req, res, session, param(req, 'itemId'));
}));
app.get('/api/catalog/stream/:itemId/master.m3u8', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await CatalogService.proxyStream(req, res, session, param(req, 'itemId'));
}));
// Everything an HLS playlist references (variant playlists, segments)
// comes back through here - see EmbyClient.rewritePlaylist.
app.get('/api/catalog/stream/:itemId/raw', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await CatalogService.proxyRawStreamAsset(req, res, session, param(req, 'itemId'));
}));
// --- Parties ---------------------------------------------------------------
// No REST "list parties" endpoint: the home page's "watch parties en cours"
// list is live, over Socket.IO's "lobby" room (SocketGateway) - see
// docs/architecture.md. No join codes either - see PartyService's class
// comment for why a live list makes one redundant.
app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
const itemId = req.body?.itemId;
if (!itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
const session = req.embySession!;
const item = await CatalogService.getRawItem(session, itemId);
const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
// Starting a party from an episode queues the rest of the series from
// there on, so the host can hit "next" (see SocketGateway) instead of
// everyone having to separately start a new party per episode. Doesn't
// apply to music - that party's queue is the live, participative one
// instead (see MusicQueueService.init below).
let queue: QueueItem[] | null = null;
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
const startIndex = all.findIndex((e) => e.Id === itemId);
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
queue = remaining.map((e) => ({
itemId: e.Id,
itemName: formatEpisodeName(e),
itemImageUrl: `/api/catalog/image/${e.Id}`
}));
}
const party = PartyService.create(
session.userId,
session.userName,
itemId,
queue ? queue[0]!.itemName : item.Name,
`/api/catalog/image/${itemId}`,
queue,
mediaType
);
if (mediaType === 'music') {
MusicQueueService.init(party, {
itemId,
itemName: item.Name,
artistName: item.AlbumArtist || item.Artists?.[0] || null,
albumName: item.Album || null,
itemImageUrl: `/api/catalog/image/${itemId}`,
durationTicks: item.RunTimeTicks || null
}, session.userId, session.userName);
}
SocketGateway.broadcastLobby(io); // the new party shows up on everyone's home page immediately
res.json(PartyService.toPublicState(party, session.userId));
}));
function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?: number; SeriesName?: string; Name: string }): string {
if (episode.ParentIndexNumber != null && episode.IndexNumber != null) {
const prefix = `S${episode.ParentIndexNumber}E${episode.IndexNumber}`;
return episode.SeriesName ? `${episode.SeriesName} - ${prefix} - ${episode.Name}` : `${prefix} - ${episode.Name}`;
}
return episode.Name;
}
// Adding a track to a music party's shared queue is REST, not a socket
// event (see SocketGateway's top comment): the album-browsing page that
// this is actually called from has no live connection to the party it's
// adding into - only the player view does. MusicQueueService's own
// "queueUpdated" broadcast (wired to the real io instance via attachIo,
// below) reaches anyone actually connected regardless of which page made
// this request.
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Watch party musique introuvable.' });
return;
}
const session = req.embySession!;
if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
const track = req.body || {};
if (!track.itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
MusicQueueService.addTrack(party, track, session.userId, session.userName);
SocketGateway.broadcastLobby(io); // the queue/now-playing preview may have just changed
res.json(PartyService.toPublicState(party, session.userId));
});
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party) {
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
return;
}
const session = req.embySession!;
PartyService.join(party, session.userId, session.userName);
SocketGateway.broadcastLobby(io); // member count changed - reflect it on the home page immediately
res.json(PartyService.toPublicState(party, session.userId));
});
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (party) {
// Shared with the socket "leave"/"disconnect" handlers so the host-
// handoff notification and the lobby refresh only live in one place.
SocketGateway.applyLeave(io, party, req.embySession!.userId);
}
res.json({ ok: true });
});
app.get('/api/parties/:partyId', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party) {
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
return;
}
res.json(PartyService.toPublicState(party, req.embySession!.userId));
});
// Catches whatever asyncRoute() forwarded (or a synchronous throw) -
// CatalogFetchError carries its own status/message; anything else is an
// unexpected bug, logged in full server-side but never leaked to the
// client beyond a generic message.
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof CatalogFetchError) {
res.status(err.status).json({ error: err.message });
return;
}
console.error('[watchparty] unexpected error', err);
res.status(500).json({ error: 'Erreur serveur.' });
});
const server = http.createServer(app);
// Same-origin by default (no `cors` option needed) - the client connects
// with a bare io() call, consistent with everything else in this app never
// crossing origins. Socket.IO's own heartbeat (pingInterval/pingTimeout,
// defaults are fine here) is what detects dead connections - see
// SocketGateway's top comment for why that matters.
const io = new SocketIoServer<ClientToServerEvents, ServerToClientEvents>(server);
SocketGateway.attach(io, (h) => AuthService.parseCookies(h), (id) => AuthService.getSession(id));
// Gives MusicQueueService a way to broadcast from its own internal
// auto-advance timer (no socket/request in scope there at all) and to
// refresh the home page's lobby card when the timer - not a person
// clicking anything - changes what's playing. Passed in rather than
// imported directly from SocketGateway/MusicQueueService to avoid a
// circular import between the two.
MusicQueueService.attachIo(io, () => SocketGateway.broadcastLobby(io));
setInterval(() => PartyService.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
server.listen(PORT, () => {
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
});

View file

@ -1,39 +0,0 @@
'use strict';
/**
* Minimal server-side logging for the party lifecycle - plain stdout lines,
* no logging library. This app runs as a single Docker container watched
* via `docker logs`/journalctl (see README's Deploying section), not behind
* a log aggregator, so a consistent prefix + timestamp is what actually
* makes these greppable in practice, not structured JSON nobody's parsing.
*
* Deliberately covers the party's business-level lifecycle only (created,
* joined, left, destroyed, host handoff, queue advance, playstate
* transitions, pause requests) - not connection-level noise like every
* Socket.IO reconnect or the 3s playstate heartbeat re-sending the same
* state (see socketHandler.js, which only calls this on an actual
* play/pause transition, not every heartbeat tick).
*/
function partyLog(message) {
console.log(`[watchparty:party] ${new Date().toISOString()} ${message}`);
}
// Separate prefix/function for the streaming route (index.js's
// /master.m3u8 handler) - a different concern from the party lifecycle
// above (it's about one HTTP request, not party state), added specifically
// to make the language/subtitle/quality override actually debuggable from
// docker logs/journalctl without needing a browser devtools session on the
// machine running the server. Deliberately logs only the individual
// override values it cares about, never the full resolved URL or params
// blob - Emby's own TranscodingUrl can carry an api_key, and that has no
// business ending up in a log file.
function streamLog(message) {
console.log(`[watchparty:stream] ${new Date().toISOString()} ${message}`);
}
/** `"name(id)"` - the same shape in every party lifecycle log line. */
function who(userId, userName) {
return `${userName}(${userId})`;
}
module.exports = { partyLog, streamLog, who };

View file

@ -1,228 +0,0 @@
'use strict';
const { partyLog, who } = require('./logger');
/**
* Owns the "participative playlist" behavior for a music party - kept out
* of partyManager.js because it's genuinely a different kind of shared
* state than the video model's single host-driven position (see
* docs/architecture.md's "Music" section): a live, mutable queue anyone
* can add to, server-authoritative timing (nobody is "the host" driving
* playback - a track just plays until its known duration runs out or a
* vote skips it), and skip-by-vote instead of a single controller.
*
* Chosen permission model (asked, not assumed - see session history):
* - Anyone can add a track to the queue, any time.
* - Anyone can pause/resume - it's reversible and low-stakes, unlike
* skipping to different content entirely.
* - Skipping the current track needs a strict majority vote of
* currently CONNECTED members ("membres présents"), not a simple click.
*
* One singleton instance (same shape as PartyManager below) - the state
* itself lives on each `party.music` object; this class is just the
* behavior around it, so it slots into the "one service class per
* feature" architecture the app is moving toward without a separate
* rewrite pass just for this file.
*/
class MusicQueueService {
constructor() {
this._io = null;
this._onTrackChanged = () => {};
}
/**
* Called once at server startup (index.js), after the Socket.IO server
* exists - nothing here can broadcast before this runs. `onTrackChanged`
* is how a party's home-page lobby card (host/preview image/name) gets
* refreshed when the *timer* advances the queue with nobody clicking
* anything - index.js wires it to broadcastLobby(io). Passed in rather
* than required directly from socketHandler.js to avoid a circular
* require (socketHandler.js already requires this module).
*/
attachIo(io, onTrackChanged) {
this._io = io;
this._onTrackChanged = onTrackChanged || (() => {});
}
/** Called once, when a party is created from a music track (index.js). */
init(party, firstTrack, addedByUserId, addedByUserName) {
party.music = { queue: [], current: null, skipVotes: new Set(), advanceTimer: null };
if (firstTrack) {
this.addTrack(party, firstTrack, addedByUserId, addedByUserName);
}
}
addTrack(party, track, addedByUserId, addedByUserName) {
const entry = {
itemId: track.itemId,
itemName: track.itemName,
artistName: track.artistName || null,
albumName: track.albumName || null,
itemImageUrl: track.itemImageUrl,
// Emby ticks (100ns, same convention as video position) - converted
// once here so the rest of this class only ever deals in seconds.
durationSeconds: track.durationTicks ? Math.round(track.durationTicks / 10000000) : null,
addedByUserId,
addedByUserName
};
party.music.queue.push(entry);
party.lastActivityUtc = Date.now();
partyLog(`musicQueueAdd partyId=${party.id} user=${who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${party.music.queue.length}`);
if (!party.music.current) {
this._playNext(party); // queue was empty - this track starts immediately
}
this._broadcast(party);
}
/** No host check - anyone can toggle this (see class comment). */
setPaused(party, isPaused) {
const current = party.music.current;
if (!current || current.isPaused === isPaused) return;
if (isPaused) {
current.isPaused = true;
current.pausedElapsedMs = Date.now() - current.startedUtc;
this._clearTimer(party);
} else {
current.isPaused = false;
current.startedUtc = Date.now() - current.pausedElapsedMs;
current.pausedElapsedMs = null;
this._scheduleAdvance(party);
}
party.lastActivityUtc = Date.now();
partyLog(`musicPlaystate partyId=${party.id} -> ${isPaused ? 'paused' : 'playing'}`);
this._broadcast(party);
}
/**
* Registers `userId`'s vote to skip the current track - a strict
* majority of `connectedMemberCount` (computed by the caller, which has
* the member list - see socketHandler.js) needed to actually skip, so a
* party of one skips on its own first vote. Broadcasts either way, so
* everyone sees the live tally even before it passes.
*/
voteSkip(party, userId, userName, connectedMemberCount) {
if (!party.music.current) return false;
party.music.skipVotes.add(userId);
const needed = Math.floor(connectedMemberCount / 2) + 1;
const votes = party.music.skipVotes.size;
partyLog(`musicVoteSkip partyId=${party.id} user=${who(userId, userName)} votes=${votes}/${connectedMemberCount} (needs ${needed})`);
const passed = votes >= needed;
if (passed) {
this._playNext(party);
}
this._broadcast(party);
return passed;
}
/**
* Called when a member leaves (socketHandler.js/partyManager.js) so a
* stale vote from someone no longer around can't help a future skip
* clear a bar computed against a smaller, more-accurate connected count.
*/
removeVote(party, userId) {
if (party.music) party.music.skipVotes.delete(userId);
}
/** Live position, in seconds - computed on demand from wall-clock time,
* never ticked/stored, so there's nothing to drift or leak. */
getElapsedSeconds(party) {
const current = party.music.current;
if (!current) return 0;
const elapsedMs = current.isPaused ? current.pausedElapsedMs : (Date.now() - current.startedUtc);
return Math.max(0, Math.floor(elapsedMs / 1000));
}
/** Public snapshot for toPublicState()/the "queueUpdated" broadcast. */
getState(party) {
const current = party.music.current;
return {
queue: party.music.queue.map(publicTrack),
current: current ? { ...publicTrack(current.track), isPaused: current.isPaused, elapsedSeconds: this.getElapsedSeconds(party) } : null,
skipVotes: party.music.skipVotes.size
// Deliberately no "votes needed" here - the client already gets the
// member list (with each one's connected flag) from the existing
// "members" broadcast and can compute the same
// Math.floor(connected/2)+1 this class uses, without this payload
// needing to duplicate that count on every single queue change.
};
}
/**
* Called from PartyManager.leave()'s destroy path. This is the actual
* leak fix: an uncleared advanceTimer would fire against a party that no
* longer exists in PartyManager's map, forever holding a reference to
* it (and everything it closes over) - the exact class of bug the
* Socket.IO/router-cleanup work earlier in this project was about
* catching (see docs/architecture.md).
*/
destroy(party) {
if (party.music) this._clearTimer(party);
}
_playNext(party) {
this._clearTimer(party);
party.music.skipVotes.clear();
const next = party.music.queue.shift();
if (!next) {
party.music.current = null;
partyLog(`musicQueueEmpty partyId=${party.id}`);
return;
}
party.music.current = { track: next, startedUtc: Date.now(), isPaused: false, pausedElapsedMs: null };
// Keeps the lobby/home-page party card's preview (name + image)
// current even though this is a queue-driven change, not a host
// action - see PartyManager.toPublicState, which reads these same
// top-level fields for both video and music parties alike.
party.itemName = next.itemName;
party.itemImageUrl = next.itemImageUrl;
partyLog(`musicNowPlaying partyId=${party.id} track="${next.itemName}"`);
this._scheduleAdvance(party);
}
/**
* Server-authoritative auto-advance: schedules the move to the next
* track for exactly when this one's known duration runs out, rather
* than waiting for any client to report "it ended" (which, with no
* single host for music, could race across several clients' players all
* finishing within milliseconds of each other and all trying to
* advance). A track with no known duration just relies on a manual
* vote-skip instead - nothing scheduled, nothing to leak.
*/
_scheduleAdvance(party) {
const current = party.music.current;
if (!current || !current.track.durationSeconds) return;
const elapsedMs = Date.now() - current.startedUtc;
const remainingMs = current.track.durationSeconds * 1000 - elapsedMs;
party.music.advanceTimer = setTimeout(() => {
this._playNext(party);
this._broadcast(party);
this._onTrackChanged(party);
}, Math.max(0, remainingMs));
}
_clearTimer(party) {
if (party.music && party.music.advanceTimer) {
clearTimeout(party.music.advanceTimer);
party.music.advanceTimer = null;
}
}
_broadcast(party) {
if (this._io) this._io.to(party.id).emit('queueUpdated', this.getState(party));
}
}
function publicTrack(t) {
return {
itemId: t.itemId,
itemName: t.itemName,
artistName: t.artistName,
albumName: t.albumName,
itemImageUrl: t.itemImageUrl,
durationSeconds: t.durationSeconds,
addedByUserId: t.addedByUserId,
addedByUserName: t.addedByUserName
};
}
module.exports = { MusicQueueService: new MusicQueueService() };

View file

@ -1,196 +0,0 @@
'use strict';
const crypto = require('crypto');
const { partyLog, who } = require('./logger');
const { MusicQueueService } = require('./musicQueueService');
/**
* In-memory party store - mirrors the Party/PartyMember/PartyManager design
* from the earlier Emby-plugin attempt (see git history / docs/architecture.md),
* adapted for a model where we own the player: one host drives playback,
* everyone else's client just applies whatever the host reports. Nothing is
* persisted to disk; a server restart clears every party, which is fine - a
* party is meant to live for one watch session, not persist for weeks.
*
* No join codes: the home page's live "watch parties en cours" list (see
* socketHandler.js's lobby broadcast) is the only way to find a party, and
* since that's real-time, a code that has to be copied/typed out of band
* doesn't add anything - see docs/architecture.md.
*/
class PartyManager {
constructor() {
this.partiesById = new Map();
}
/**
* `queue` is the ordered list of { itemId, itemName, itemImageUrl } this
* party will play through - for a movie/standalone episode it's just the
* one item; for "start watching this series from here", index.js builds
* the rest of the season/series into it. `itemId`/`itemName`/`itemImageUrl`
* always mirror queue[queueIndex] - kept as top-level fields too since
* that's what most of the UI reads. Unused for `mediaType: 'music'`
* parties (still populated with a one-item fallback so those same
* top-level fields have *something* sane in them for the lobby/home-page
* card) - a music party's real, mutable queue is `party.music`, owned by
* musicQueueService.js, not this fixed pre-built one.
*/
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl, queue, mediaType) {
const party = {
id: crypto.randomUUID(),
mediaType: mediaType || 'video',
itemId,
itemName,
itemImageUrl,
queue: queue && queue.length ? queue : [{ itemId, itemName, itemImageUrl }],
queueIndex: 0,
hostUserId,
createdUtc: Date.now(),
lastActivityUtc: Date.now(),
// userId -> { userId, userName, connection, isPaused, positionTicks, lastUpdatedUtc }
members: new Map()
};
party.members.set(hostUserId, this._newMember(hostUserId, hostUserName));
this.partiesById.set(party.id, party);
partyLog(`created partyId=${party.id} mediaType=${party.mediaType} host=${who(hostUserId, hostUserName)} item="${itemName}" queueLength=${party.queue.length}`);
return party;
}
getById(partyId) {
return this.partiesById.get(partyId) || null;
}
join(party, userId, userName) {
const member = this._newMember(userId, userName);
party.members.set(userId, member);
party.lastActivityUtc = Date.now();
partyLog(`joined partyId=${party.id} user=${who(userId, userName)} memberCount=${party.members.size}`);
return member;
}
/**
* Removes a member. If they were the host, promotes whoever's left
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
* left, the party is destroyed outright. Returns what happened so callers
* (socketHandler.js's applyLeave) can notify the new host and refresh the
* home page's live list - see docs/architecture.md.
*/
leave(party, userId) {
const leavingMember = party.members.get(userId);
const wasHost = party.hostUserId === userId;
party.members.delete(userId);
party.lastActivityUtc = Date.now();
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
let newHostUserId = party.hostUserId;
if (wasHost) {
const next = party.members.keys().next();
newHostUserId = next.done ? null : next.value;
party.hostUserId = newHostUserId;
}
const destroyed = party.members.size === 0;
const whoLeft = leavingMember ? who(userId, leavingMember.userName) : userId;
if (destroyed) {
this.partiesById.delete(party.id);
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
partyLog(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
} else if (wasHost && newHostUserId != null) {
const newHost = party.members.get(newHostUserId);
partyLog(`left partyId=${party.id} user=${whoLeft} memberCount=${party.members.size} hostHandoff->${who(newHostUserId, newHost.userName)}`);
} else {
partyLog(`left partyId=${party.id} user=${whoLeft} memberCount=${party.members.size}`);
}
return { hostChanged: wasHost && newHostUserId != null, newHostUserId, destroyed };
}
/**
* Moves to the next item in the queue (e.g. next episode). Returns false
* if there's nothing after the current position. Resets every member's
* known position/pause state, since it's a new item playing from zero.
*/
advanceQueue(party) {
if (party.queueIndex >= party.queue.length - 1) {
return false;
}
party.queueIndex += 1;
const next = party.queue[party.queueIndex];
party.itemId = next.itemId;
party.itemName = next.itemName;
party.itemImageUrl = next.itemImageUrl;
for (const member of party.members.values()) {
member.positionTicks = 0;
member.isPaused = false;
member.lastUpdatedUtc = Date.now();
}
party.lastActivityUtc = Date.now();
partyLog(`advanced partyId=${party.id} to item="${next.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
return true;
}
/** All active parties - powers the "watch parties en cours" home section. */
listActive() {
return Array.from(this.partiesById.values());
}
removeIdleParties(idleTimeoutMs) {
const cutoff = Date.now() - idleTimeoutMs;
for (const party of this.partiesById.values()) {
if (party.lastActivityUtc < cutoff) {
this.partiesById.delete(party.id);
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
partyLog(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
}
}
}
/**
* `viewerUserId` lets the response say "youAreHost" instead of the client
* having to know its own Emby user id to figure that out itself - now that
* auth is a server-side session (see embyAuth.js), the client doesn't hold
* an id to compare with at all.
*/
toPublicState(party, viewerUserId) {
const host = party.members.get(party.hostUserId);
return {
partyId: party.id,
mediaType: party.mediaType,
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
createdUtc: party.createdUtc,
// hasNext/queueLength/queueIndex describe the fixed video episode
// queue - meaningless for a music party (see `music` below instead).
hasNext: party.queueIndex < party.queue.length - 1,
queueLength: party.queue.length,
queueIndex: party.queueIndex,
hostUserName: host ? host.userName : null,
memberCount: party.members.size,
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
members: Array.from(party.members.values()).map((m) => ({
userId: m.userId,
userName: m.userName,
isHost: m.userId === party.hostUserId,
isPaused: m.isPaused,
positionTicks: m.positionTicks,
connected: !!m.connection
})),
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null
};
}
_newMember(userId, userName) {
return {
userId,
userName,
connection: null,
isPaused: true,
positionTicks: 0,
lastUpdatedUtc: Date.now()
};
}
}
module.exports = { PartyManager: new PartyManager() };

View file

@ -0,0 +1,229 @@
'use strict';
import { Logger } from '../Logger';
import { MusicQueueService } from '../services/MusicQueueService';
import { PartyService } from '../services/PartyService';
import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } from '../types';
/**
* Realtime side of the app: one party's own room, plus a shared "lobby"
* room that powers the home page's live "watch parties en cours" list.
*
* Auth comes from the session cookie sent during the initial HTTP
* handshake - exposed here via socket.request.headers.cookie.
*
* Events (client -> server unless noted) - see types.ts's
* ClientToServerEvents/ServerToClientEvents for the exact payload shapes:
* "subscribeLobby" joins the "lobby" room
* "parties" (server -> client) full snapshot, sent on subscribe
* and again whenever any party changes
* "join" {partyId} joins that party's own room
* "members" (server -> client) that party's member list
*
* -- mediaType: 'video' parties --
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
* "playstate" (server -> client, to everyone in the party but the sender)
* "next" (host only - advance the queue)
* "itemChanged" (server -> client)
*
* -- mediaType: 'music' parties (see MusicQueueService for the model) --
* "voteSkip" (anyone - see MusicQueueService for the threshold)
* "musicPlaystate" {isPaused} (anyone - no host check, unlike video's "playstate")
* "queueUpdated" (server -> client) - see MusicQueueService.getState() for the shape
* (adding a track is REST, not a socket event - POST
* /api/parties/:partyId/tracks in index.ts - since it needs to work
* from the album-browsing page too, which has no live connection to
* the party it might be adding into; MusicQueueService's own
* "queueUpdated" broadcast fires regardless of which path called it)
*
* -- both --
* "hostChanged" (server -> client, to the new host only, no payload)
* "requestPause" (throttled per member - see below;
* video only - a music party has no
* host to ask, everyone already has
* direct pause/vote-skip access)
* "pauseRequested" (server -> client, everyone in the party)
* "leave"
* "errorMessage" (server -> client)
* ("error" is a reserved Socket.IO event name - using our own to avoid
* colliding with its internal error handling.)
*/
class SocketGatewayClass {
attach(io: AppServer, parseCookies: (header: string | undefined) => Record<string, string>, getSession: (id: string) => { userId: string; userName: string } | null): void {
io.on('connection', (socket) => {
const cookies = parseCookies(socket.request.headers.cookie);
const session = cookies.wp_session ? getSession(cookies.wp_session) : null;
if (!session) {
socket.emit('errorMessage', { message: 'Session expirée - reconnecte-toi.' });
socket.disconnect(true);
return;
}
const userId = session.userId;
let party: Party | null = null;
socket.on('subscribeLobby', () => {
socket.join('lobby');
socket.emit('parties', this.lobbySnapshot());
});
socket.on('join', (msg) => {
const p = PartyService.getById(msg?.partyId);
const member = p?.members.get(userId);
if (!p || !member) {
socket.emit('errorMessage', { message: "Tu n'es pas membre de cette party." });
socket.disconnect(true);
return;
}
party = p;
member.connection = socket;
socket.join(party.id);
this.broadcastMembers(io, party);
});
socket.on('playstate', (msg) => {
if (!party || userId !== party.hostUserId) {
return; // only the host drives playback - see docs/architecture.md
}
const member = party.members.get(userId)!;
const wasPaused = member.isPaused;
member.isPaused = !!msg?.isPaused;
member.positionTicks = Number(msg?.positionTicks) || 0;
member.lastUpdatedUtc = Date.now();
party.lastActivityUtc = Date.now();
// Logged only on an actual play/pause transition, not every one of
// these - the host's player also re-sends the current state on a
// plain 3s heartbeat (see player), which would otherwise spam this
// line for every active party every 3 seconds regardless of
// whether anything actually changed.
if (member.isPaused !== wasPaused) {
const positionSeconds = Math.round(member.positionTicks / 10000000);
Logger.party(`playstate partyId=${party.id} host=${Logger.who(userId, member.userName)} -> ${member.isPaused ? 'paused' : 'playing'} at ${positionSeconds}s`);
}
socket.to(party.id).emit('playstate', { isPaused: member.isPaused, positionTicks: member.positionTicks });
});
socket.on('next', () => {
if (!party || userId !== party.hostUserId) {
return; // only the host can advance the queue
}
if (PartyService.advanceQueue(party)) {
io.to(party.id).emit('itemChanged', {
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
queueIndex: party.queueIndex,
queueLength: party.queue.length,
hasNext: party.queueIndex < party.queue.length - 1
});
}
});
// Majority of currently CONNECTED members, not everyone who's ever
// joined - see MusicQueueService.
socket.on('voteSkip', () => {
const member = party?.mediaType === 'music' ? party.members.get(userId) : undefined;
if (!party || !member) return;
const connectedCount = Array.from(party.members.values()).filter((m) => m.connection).length;
MusicQueueService.voteSkip(party, userId, member.userName, connectedCount);
this.broadcastLobby(io);
});
// No host check, unlike video's "playstate" - see MusicQueueService.
socket.on('musicPlaystate', (msg) => {
if (!party || party.mediaType !== 'music' || !party.members.get(userId)) return;
MusicQueueService.setPaused(party, !!msg?.isPaused);
});
// Anyone (not just followers - harmless either way) can ask the host
// to pause; the presence overlay (the video player) is what triggers
// this. Broadcast to the whole party, not just the host, so everyone
// sees why playback is about to pause. Throttled per member
// server-side (not just the client's own cooldown, which a stray
// extra click - or anyone poking the socket directly - would
// bypass). Video only: a music party has no host to ask - everyone
// already has direct pause access via "musicPlaystate".
socket.on('requestPause', () => {
if (!party || party.mediaType === 'music') return;
const member = party.members.get(userId);
if (!member) return;
const now = Date.now();
if (member.lastPauseRequestUtc && now - member.lastPauseRequestUtc < 5000) return;
member.lastPauseRequestUtc = now;
Logger.party(`pauseRequested partyId=${party.id} user=${Logger.who(userId, member.userName)}`);
io.to(party.id).emit('pauseRequested', { userId, userName: member.userName });
});
socket.on('leave', () => {
if (!party) return;
this.applyLeave(io, party, userId);
party = null;
});
// Fires on a clean "leave", a dead-connection timeout (Socket.IO's
// own heartbeat - see docs/architecture.md), or any other way the
// socket goes away - the one cleanup path that always runs, unlike
// relying on the client to always get a chance to emit "leave" first.
socket.on('disconnect', () => {
if (party) this.applyLeave(io, party, userId);
});
});
}
/**
* Removes `userId` from `party` and handles everything that follows from
* it: if they were the host, the new host (if any) gets a personal
* "hostChanged" so their player can promote itself without a page
* reload; either way, the party's own room and the lobby both get
* refreshed. Shared by the socket "leave"/"disconnect" handlers above
* and index.ts's REST leave endpoint so this logic - especially the
* host handoff - only has to be right in one place.
*/
applyLeave(io: AppServer, party: Party, userId: string): LeaveResult {
const result = PartyService.leave(party, userId);
if (!result.destroyed) {
this.broadcastMembers(io, party);
if (result.hostChanged && result.newHostUserId) {
const newHost = party.members.get(result.newHostUserId);
newHost?.connection?.emit('hostChanged');
}
}
this.broadcastLobby(io);
return result;
}
broadcastLobby(io: AppServer): void {
io.to('lobby').emit('parties', this.lobbySnapshot());
}
private broadcastMembers(io: AppServer, party: Party): void {
const members: SocketMemberDto[] = Array.from(party.members.values()).map((m) => ({
userId: m.userId,
userName: m.userName,
isHost: m.userId === party.hostUserId,
connected: !!m.connection
}));
io.to(party.id).emit('members', members);
}
private lobbySnapshot(): LobbyPartyDto[] {
return PartyService.listActive().map((party) => {
const host = party.members.get(party.hostUserId);
return {
partyId: party.id,
mediaType: party.mediaType,
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
hostUserName: host ? host.userName : null,
memberCount: party.members.size,
createdUtc: party.createdUtc // client computes/ticks elapsed time itself from this
};
});
}
}
export const SocketGateway = new SocketGatewayClass();

View file

@ -0,0 +1,101 @@
'use strict';
import crypto from 'crypto';
import type { Request } from 'express';
import { EMBY_SERVER_URL } from '../config';
import type { EmbySession } from '../types';
export const SESSION_COOKIE = 'wp_session';
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function authHeaderValue(): string {
return 'MediaBrowser Client="WatchParty", Device="Server", DeviceId="watchparty-server", Version="0.1.0"';
}
interface EmbyAuthResponse {
AccessToken: string;
ServerId: string;
User: { Id: string; Name: string };
}
/**
* Server-side Emby login + in-memory session store. The browser only ever
* sends its username/password once, to us; we forward that to Emby, and
* from then on the browser only holds an opaque session id (cookie) -
* never Emby's own access token. This is what makes "the server proxies
* everything" possible: the browser has nothing it could use to talk to
* Emby even if it tried. See docs/architecture.md.
*
* Singleton, same shape as PartyService/MusicQueueService - sessions are
* in-memory, like party state, so a restart signs everyone out, which is
* fine (an Emby login is one click away).
*/
class AuthServiceClass {
private sessions = new Map<string, EmbySession>();
/** Returns a new session id, or null on bad credentials / unreachable Emby. */
async login(username: string, password: string): Promise<string | null> {
if (!EMBY_SERVER_URL) return null;
let res: globalThis.Response;
try {
res = await fetch(`${EMBY_SERVER_URL}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': authHeaderValue()
},
body: JSON.stringify({ Username: username, Pw: password })
});
} catch (err) {
console.error('[watchparty] Emby login request failed:', (err as Error).message);
return null;
}
if (!res.ok) return null;
const data = (await res.json()) as EmbyAuthResponse;
const sessionId = crypto.randomUUID();
this.sessions.set(sessionId, {
embyToken: data.AccessToken,
userId: data.User.Id,
userName: data.User.Name,
serverId: data.ServerId, // needed to build "open in Emby" deep links
createdUtc: Date.now()
});
return sessionId;
}
getSession(sessionId: string): EmbySession | null {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (Date.now() - session.createdUtc > SESSION_TTL_MS) {
this.sessions.delete(sessionId);
return null;
}
return session;
}
endSession(sessionId: string): void {
this.sessions.delete(sessionId);
}
parseCookies(cookieHeader: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
(cookieHeader || '').split(';').forEach((part) => {
const idx = part.indexOf('=');
if (idx === -1) return;
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
});
return out;
}
/** Reads the caller's session from an Express request's cookie, or null. */
sessionFromRequest(req: Request): EmbySession | null {
const cookies = this.parseCookies(req.headers.cookie);
const id = cookies[SESSION_COOKIE];
return id ? this.getSession(id) : null;
}
}
export const AuthService = new AuthServiceClass();

View file

@ -0,0 +1,385 @@
'use strict';
import type { Request, Response } from 'express';
import { EMBY_SERVER_URL } from '../config';
import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger';
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
// 'MusicVideo' is Emby's own item type for a musicvideos library's
// contents (distinct from the generic 'Video', which is home videos).
// 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't
// directly playable - only show "start a party" for things Emby can
// actually stream. "Voir sur Emby" always works though.
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
// Emby's /Views can return all sorts of things beyond actual media
// libraries - boxset collections, playlists, live TV, photos, books, a
// mixed-content folder with no CollectionType at all - none of which this
// app has any UI for. Only these four are ever shown, in the sidebar or
// anywhere else: everything else is dropped here, at the source, so every
// consumer (shell/sidebar, home's carousels, direct navigation to a
// library route) is filtered the same way without each having to repeat
// the same check.
const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos']);
interface EmbyItemsResponse {
Items?: EmbyItem[];
}
interface PlaybackInfoResponse {
MediaSources?: Array<{
Id?: string;
TranscodingUrl?: string;
MediaStreams?: Array<{ Type: string; Index: number; DisplayTitle?: string; Language?: string }>;
}>;
PlaySessionId?: string;
}
/**
* Everything catalog-shaped: search, item detail, libraries, recently
* added, Series -> Seasons -> Episodes, Library -> Artists -> Albums ->
* Tracks, playback negotiation, and the actual image/HLS/audio proxying.
* The single largest extraction from the old index.js - previously ~15
* route handlers' worth of logic inlined directly in the Express app.
*
* Stateless (no singleton instance state, unlike PartyService/
* MusicQueueService/AuthService) - kept as a class anyway for consistency
* with the rest of server/src/services, and because it's still very much
* a "feature" in the sense this app's architecture groups by (see
* docs/architecture.md).
*/
class CatalogServiceClass {
async search(session: EmbySession, query: string): Promise<ItemDto[]> {
const q = query.trim();
if (q.length < 2) return [];
const params = new URLSearchParams({
searchTerm: q,
IncludeItemTypes: 'Movie,Episode,Series',
Recursive: 'true',
Fields: 'Overview,ProductionYear',
ImageTypeLimit: '1',
Limit: '24'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Recherche impossible côté Emby.');
return (data.Items || []).map((i) => this.toItemDto(i, session.serverId));
}
/** Raw Emby item (Type, SeriesId, RunTimeTicks, ...) - used where a
* caller needs more than the DTO exposes, e.g. deciding a new party's
* mediaType from the clicked item's real Emby Type (see index.ts).
* Unlike every other method here, this preserves Emby's own status
* (typically a real 404) rather than a blanket 502 - "titre introuvable"
* is a not-found, not an upstream failure. */
async getRawItem(session: EmbySession, itemId: string): Promise<EmbyItem> {
return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
}
async getItem(session: EmbySession, itemId: string): Promise<ItemDto> {
return this.toItemDto(await this.getRawItem(session, itemId), session.serverId);
}
async getLibraries(session: EmbySession): Promise<LibraryDto[]> {
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Views`, session, 'Impossible de récupérer les bibliothèques.');
return (data.Items || [])
.filter((v) => VISIBLE_COLLECTION_TYPES.has((v as unknown as { CollectionType?: string }).CollectionType || ''))
.map((v) => ({
id: v.Id,
name: v.Name,
// "movies" / "tvshows" / "music" / "musicvideos" - drives which
// browsing experience client/src/views/library renders.
collectionType: (v as unknown as { CollectionType?: string }).CollectionType || ''
}));
}
/** Home page carousel: Emby's own "recently added" endpoint, one row per library. */
async getRecentItems(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
const params = new URLSearchParams({ ParentId: libraryId, Limit: '16', Fields: 'ProductionYear' });
const data = await this.fetchJson<EmbyItemsResponse | EmbyItem[]>(`/Users/${session.userId}/Items/Latest?${params}`, session, 'Impossible de récupérer les ajouts récents.');
// Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}.
const items = Array.isArray(data) ? data : (data.Items || []);
return items.map((i) => this.toItemDto(i, session.serverId));
}
async getSeriesSeasons(session: EmbySession, seriesId: string): Promise<SeasonDto[]> {
const data = await this.fetchJson<EmbyItemsResponse>(`/Shows/${seriesId}/Seasons?userId=${session.userId}`, session, 'Impossible de récupérer les saisons.');
return (data.Items || []).map((s) => ({ id: s.Id, name: s.Name, indexNumber: s.IndexNumber }));
}
async getSeriesEpisodes(session: EmbySession, seriesId: string, seasonId?: string): Promise<ItemDto[]> {
const params = new URLSearchParams({ userId: session.userId, Fields: 'Overview' });
if (seasonId) params.set('SeasonId', seasonId);
const data = await this.fetchJson<EmbyItemsResponse>(`/Shows/${seriesId}/Episodes?${params}`, session, 'Impossible de récupérer les épisodes.');
return (data.Items || []).map((e) => this.toItemDto(e, session.serverId));
}
/** Raw episodes (no season filter) - used by index.ts to build a new
* party's fixed episode queue when starting from a specific episode. */
async getAllSeriesEpisodesRaw(session: EmbySession, seriesId: string): Promise<EmbyItem[]> {
const data = await this.fetchJson<EmbyItemsResponse>(`/Shows/${seriesId}/Episodes?userId=${session.userId}&Fields=Overview`, session, 'Impossible de récupérer les épisodes.');
return data.Items || [];
}
// Library -> Artists -> Albums -> Tracks, for the "Musique" browsing
// pages. Each its own Emby endpoint/query rather than guessing folder
// structure, same reasoning as Shows -> Seasons -> Episodes above.
async getArtists(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName' });
const data = await this.fetchJson<EmbyItemsResponse>(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.');
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
}
async getArtistAlbums(session: EmbySession, artistId: string): Promise<ItemDto[]> {
const params = new URLSearchParams({
ArtistIds: artistId,
UserId: session.userId,
IncludeItemTypes: 'MusicAlbum',
Recursive: 'true',
SortBy: 'ProductionYear,SortName',
Fields: 'ProductionYear'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer les albums.');
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
}
async getAlbumTracks(session: EmbySession, albumId: string): Promise<ItemDto[]> {
const params = new URLSearchParams({
ParentId: albumId,
UserId: session.userId,
IncludeItemTypes: 'Audio',
SortBy: 'IndexNumber',
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer les morceaux.');
return (data.Items || []).map((t) => this.toItemDto(t, session.serverId));
}
async getLibraryItems(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
const params = new URLSearchParams({
ParentId: libraryId,
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
SortBy: 'SortName',
Fields: 'Overview,ProductionYear',
ImageTypeLimit: '1',
Limit: '300'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette bibliothèque.');
return (data.Items || []).map((i) => this.toItemDto(i, session.serverId));
}
/** Audio/subtitle tracks available for an item, read from Emby's own
* playback negotiation (PlaybackInfo) - powers the player's
* language/subtitle pickers. */
async getPlaybackOptions(session: EmbySession, itemId: string): Promise<PlaybackOptionsDto> {
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de lire les options de lecture.');
const source = (info.MediaSources || [])[0];
const streams = source?.MediaStreams || [];
const label = (s: { Index: number; DisplayTitle?: string; Language?: string }) => s.DisplayTitle || s.Language || `Piste ${s.Index}`;
return {
audioTracks: streams.filter((s) => s.Type === 'Audio').map((s) => ({ index: s.Index, label: label(s) })),
subtitleTracks: streams.filter((s) => s.Type === 'Subtitle').map((s) => ({ index: s.Index, label: label(s) }))
};
}
// --- Direct proxying (images/video/audio) - these need the raw
// Express req/res, unlike everything above which just returns JSON. ---
async proxyImage(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
await EmbyClient.proxyResponse(req, res, `/Items/${itemId}/Images/Primary?maxWidth=400`, session.embyToken, itemId);
}
/**
* Ask Emby how it wants this played rather than hand-building a
* /Videos/{id}/master.m3u8 URL ourselves: the first version did that
* with only VideoCodec/AudioCodec, and Emby's server threw "Value
* cannot be null (Parameter 'key')" - almost certainly an internal
* dictionary lookup (MediaSourceId/PlaySessionId/DeviceId) that URL
* never supplied. PlaybackInfo returns a MediaSource with its own
* ready-to-use TranscodingUrl carrying whatever Emby actually needs.
*
* Music tracks stream through this exact method too (same PlaybackInfo
* -> TranscodingUrl dance - Emby's negotiation isn't video-specific) -
* the only place that actually differs is the hand-built fallback path,
* which is genuinely different between the two (/Videos/ vs /Audio/)
* and has no way to know which without being told: PlaybackInfo's own
* response doesn't reliably carry the item's Type back, and looking it
* up would be an extra Emby round trip for what the client already
* knows - so the client passes `mediaType=audio` (see audioStreamUrl()
* in client/src/api/embyApi.ts).
*/
async proxyStream(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
// AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate (and
// SubtitleMethod=Encode, forced by applyPlaybackOverrides whenever a
// subtitle is requested - see its own comment below) come from the
// player's language/subtitle/quality pickers, applied to the
// negotiation itself, not just the final URL. That distinction
// matters: whether to actually re-encode (required to burn in a
// subtitle) is a decision Emby makes *during* PlaybackInfo, not
// something a later query-string tweak on the resulting URL can
// retroactively change if Emby already set up a plain remux. Still
// not trusted to have taken effect on its own, though (this app never
// sends the POST-with-DeviceProfile body real Emby web clients use
// for that negotiation) - applyPlaybackOverrides runs a second time
// below, on whatever URL results, so a track pick can't silently
// no-op regardless of which path Emby's response takes.
const query = req.query as Record<string, string | undefined>;
const infoParams = new URLSearchParams({ UserId: session.userId });
this.applyPlaybackOverrides(infoParams, query);
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session, 'Impossible de préparer la lecture de ce titre.');
const source = (info.MediaSources || [])[0];
if (!source) {
throw new CatalogFetchError('Aucune source de lecture disponible pour ce titre.', 502);
}
// TODO(verify): TranscodingUrl not confirmed present for every source
// type/Emby version (e.g. a source Emby considers direct-playable may
// not have one at all) - the hand-built fallback below now at least
// supplies the parameters PlaybackInfo reveals (MediaSourceId,
// PlaySessionId, UserId) instead of guessing, but is still a
// fallback, not the primary path.
const isAudio = query.mediaType === 'audio';
let target: string;
if (source.TranscodingUrl) {
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
const params = new URLSearchParams(urlQuery || '');
this.applyPlaybackOverrides(params, query);
target = `${urlPath}?${params}`;
this.logStreamRequest(itemId, 'negotiated', params);
} else {
const params = new URLSearchParams({
UserId: session.userId,
MediaSourceId: source.Id || '',
PlaySessionId: info.PlaySessionId || '',
AudioCodec: 'aac',
...(isAudio ? {} : { VideoCodec: 'h264' })
});
this.applyPlaybackOverrides(params, query);
target = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
this.logStreamRequest(itemId, 'fallback', params);
}
await EmbyClient.proxyResponse(req, res, target, session.embyToken, itemId);
}
/** Everything an HLS playlist references (variant playlists, segments)
* comes back through here - see EmbyClient.rewritePlaylist. */
async proxyRawStreamAsset(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
const target = (req.query.u as string) || '';
let pathAndQuery: string;
if (/^https?:\/\//i.test(target)) {
if (!target.startsWith(EMBY_SERVER_URL)) {
res.status(400).end(); // never proxy an arbitrary URL, only our own Emby server
return;
}
pathAndQuery = target;
} else {
pathAndQuery = `/Videos/${itemId}/${target}`;
}
await EmbyClient.proxyResponse(req, res, pathAndQuery, session.embyToken, itemId);
}
/**
* Forces this app's own audio/subtitle/quality selection onto a
* transcode URL, whether that URL came from Emby's own PlaybackInfo
* negotiation or was hand-built as a fallback. Without this, a pick
* from the player's settings menu could silently do nothing if Emby's
* negotiation didn't apply the query-string override the same way its
* own web client's POST-with-DeviceProfile request would have.
*
* SubtitleMethod=Encode is forced whenever a subtitle track is
* requested: left to its own default, Emby may instead deliver text
* subtitles as a separate selectable HLS rendition (SubtitleMethod=Hls)
* rather than burning them into the video - and this player never wires
* up hls.js's subtitleTrack API to pick one, so a rendition-based
* subtitle would be present in the manifest but never actually shown.
* Encode guarantees the subtitle is part of the one video stream this
* player already renders. Picking "Aucun" removes any subtitle Emby's
* own negotiation added, rather than just not adding a new one -
* otherwise turning subtitles off from a state where Emby had already
* picked a default would be a no-op too.
*/
private applyPlaybackOverrides(params: URLSearchParams, query: Record<string, string | undefined>): void {
if (query.AudioStreamIndex) params.set('AudioStreamIndex', query.AudioStreamIndex);
if (query.SubtitleStreamIndex) {
params.set('SubtitleStreamIndex', query.SubtitleStreamIndex);
params.set('SubtitleMethod', 'Encode');
} else {
params.delete('SubtitleStreamIndex');
params.delete('SubtitleMethod');
}
if (query.MaxStreamingBitrate) params.set('MaxStreamingBitrate', query.MaxStreamingBitrate);
}
// Deliberately logs only these specific override values, never the full
// resolved URL/params blob - Emby's own TranscodingUrl can carry an
// api_key, and that has no business ending up in a log file. Makes it
// possible to confirm from docker logs/journalctl whether a requested
// override made it into the request Emby received, without a devtools
// session on whatever machine is running the server.
private logStreamRequest(itemId: string, source: 'negotiated' | 'fallback', params: URLSearchParams): void {
Logger.stream(
`master.m3u8 itemId=${itemId} source=${source} ` +
`audio=${params.get('AudioStreamIndex') || '(default)'} ` +
`subtitle=${params.get('SubtitleStreamIndex') || '(none)'} ` +
`subtitleMethod=${params.get('SubtitleMethod') || '(none)'} ` +
`quality=${params.get('MaxStreamingBitrate') || '(auto)'}`
);
}
private toItemDto(item: EmbyItem, serverId: string): ItemDto {
return {
id: item.Id,
name: item.Name,
overview: item.Overview,
year: item.ProductionYear,
type: item.Type,
canWatchParty: WATCH_PARTY_TYPES.has(item.Type || ''),
imageUrl: `/api/catalog/image/${item.Id}`,
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
// Track-specific (null for anything else - harmless, every consumer
// that cares about these only reads them for an Audio item).
durationTicks: item.RunTimeTicks || null,
indexNumber: item.IndexNumber != null ? item.IndexNumber : null,
artistName: item.AlbumArtist || item.Artists?.[0] || null,
albumName: item.Album || null
};
}
/**
* GETs an Emby endpoint as this session's user and parses the JSON body,
* or throws a CatalogFetchError - never touches an Express response
* directly. Every route in index.ts runs through asyncRoute(), which
* catches this (and anything else) and turns it into the right HTTP
* response in one place, rather than each of the ~15 routes below
* needing its own try/catch around every await.
*/
private async fetchJson<T>(
pathAndQuery: string,
session: EmbySession,
errorMessage: string,
options: { preserveEmbyStatus?: boolean } = {}
): Promise<T> {
const embyRes = await EmbyClient.fetch(pathAndQuery, session.embyToken);
if (!embyRes.ok) {
throw new CatalogFetchError(errorMessage, options.preserveEmbyStatus ? embyRes.status : 502);
}
return (await embyRes.json()) as T;
}
}
/**
* A known, user-facing catalog failure (Emby unreachable, item not found,
* ...) - index.ts's asyncRoute() catches this specifically and responds
* with `status`/`message` verbatim; anything else that throws is an
* unexpected bug and gets a generic 500 instead (see index.ts's error
* -handling middleware).
*/
export class CatalogFetchError extends Error {
constructor(message: string, public status: number) {
super(message);
}
}
export const CatalogService = new CatalogServiceClass();

View file

@ -0,0 +1,219 @@
'use strict';
import { Logger } from '../Logger';
import type { AppServer, MusicCurrentPublic, MusicPublicState, MusicTrack, MusicTrackInput, Party } from '../types';
/**
* Owns the "participative playlist" behavior for a music party - kept out
* of PartyService because it's genuinely a different kind of shared state
* than the video model's single host-driven position (see
* docs/architecture.md's "Music" section): a live, mutable queue anyone
* can add to, server-authoritative timing (nobody is "the host" driving
* playback - a track just plays until its known duration runs out or a
* vote skips it), and skip-by-vote instead of a single controller.
*
* Chosen permission model (asked, not assumed - see session history):
* - Anyone can add a track to the queue, any time.
* - Anyone can pause/resume - it's reversible and low-stakes, unlike
* skipping to different content entirely.
* - Skipping the current track needs a strict majority vote of
* currently CONNECTED members ("membres présents"), not a simple click.
*
* Singleton (exported as an instance below) - the state itself lives on
* each `party.music` object; this class is just the behavior around it.
*/
class MusicQueueServiceClass {
private io: AppServer | null = null;
private onTrackChanged: (party: Party) => void = () => {};
/**
* Called once at server startup (index.ts), after the Socket.IO server
* exists - nothing here can broadcast before this runs. `onTrackChanged`
* is how a party's home-page lobby card (host/preview image/name) gets
* refreshed when the *timer* advances the queue with nobody clicking
* anything - index.ts wires it to broadcastLobby(io). Passed in rather
* than imported directly from SocketGateway to avoid a circular import
* (SocketGateway already imports this module).
*/
attachIo(io: AppServer, onTrackChanged?: (party: Party) => void): void {
this.io = io;
this.onTrackChanged = onTrackChanged || (() => {});
}
/** Called once, when a party is created from a music track (index.ts). */
init(party: Party, firstTrack: MusicTrackInput | null, addedByUserId: string, addedByUserName: string): void {
party.music = { queue: [], current: null, skipVotes: new Set(), advanceTimer: null };
if (firstTrack) {
this.addTrack(party, firstTrack, addedByUserId, addedByUserName);
}
}
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
const music = party.music!;
const entry: MusicTrack = {
itemId: track.itemId,
itemName: track.itemName,
artistName: track.artistName || null,
albumName: track.albumName || null,
itemImageUrl: track.itemImageUrl,
// Emby ticks (100ns, same convention as video position) - converted
// once here so the rest of this class only ever deals in seconds.
durationSeconds: track.durationTicks ? Math.round(track.durationTicks / 10000000) : null,
addedByUserId,
addedByUserName
};
music.queue.push(entry);
party.lastActivityUtc = Date.now();
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
if (!music.current) {
this.playNext(party); // queue was empty - this track starts immediately
}
this.broadcast(party);
}
/** No host check - anyone can toggle this (see class comment). */
setPaused(party: Party, isPaused: boolean): void {
const current = party.music?.current;
if (!current || current.isPaused === isPaused) return;
if (isPaused) {
current.isPaused = true;
current.pausedElapsedMs = Date.now() - current.startedUtc;
this.clearTimer(party);
} else {
current.isPaused = false;
current.startedUtc = Date.now() - (current.pausedElapsedMs ?? 0);
current.pausedElapsedMs = null;
this.scheduleAdvance(party);
}
party.lastActivityUtc = Date.now();
Logger.party(`musicPlaystate partyId=${party.id} -> ${isPaused ? 'paused' : 'playing'}`);
this.broadcast(party);
}
/**
* Registers `userId`'s vote to skip the current track - a strict
* majority of `connectedMemberCount` (computed by the caller, which has
* the member list - see SocketGateway) needed to actually skip, so a
* party of one skips on its own first vote. Broadcasts either way, so
* everyone sees the live tally even before it passes.
*/
voteSkip(party: Party, userId: string, userName: string, connectedMemberCount: number): boolean {
const music = party.music;
if (!music || !music.current) return false;
music.skipVotes.add(userId);
const needed = Math.floor(connectedMemberCount / 2) + 1;
const votes = music.skipVotes.size;
Logger.party(`musicVoteSkip partyId=${party.id} user=${Logger.who(userId, userName)} votes=${votes}/${connectedMemberCount} (needs ${needed})`);
const passed = votes >= needed;
if (passed) {
this.playNext(party);
}
this.broadcast(party);
return passed;
}
/**
* Called when a member leaves (SocketGateway/PartyService) so a stale
* vote from someone no longer around can't help a future skip clear a
* bar computed against a smaller, more-accurate connected count.
*/
removeVote(party: Party, userId: string): void {
party.music?.skipVotes.delete(userId);
}
/** Live position, in seconds - computed on demand from wall-clock time,
* never ticked/stored, so there's nothing to drift or leak. */
getElapsedSeconds(party: Party): number {
const current = party.music?.current;
if (!current) return 0;
const elapsedMs = current.isPaused ? (current.pausedElapsedMs ?? 0) : (Date.now() - current.startedUtc);
return Math.max(0, Math.floor(elapsedMs / 1000));
}
/** Public snapshot for PartyService.toPublicState()/the "queueUpdated" broadcast. */
getState(party: Party): MusicPublicState {
const music = party.music;
if (!music) return { queue: [], current: null, skipVotes: 0 };
const current: MusicCurrentPublic | null = music.current
? { ...music.current.track, isPaused: music.current.isPaused, elapsedSeconds: this.getElapsedSeconds(party) }
: null;
return {
queue: music.queue,
current,
skipVotes: music.skipVotes.size
// Deliberately no "votes needed" here - the client already gets the
// member list (with each one's connected flag) from the existing
// "members" broadcast and can compute the same
// Math.floor(connected/2)+1 this class uses, without this payload
// needing to duplicate that count on every single queue change.
};
}
/**
* Called from PartyService.leave()'s destroy path. This is the actual
* leak fix: an uncleared advanceTimer would fire against a party that no
* longer exists in PartyService's map, forever holding a reference to
* it (and everything it closes over) - the exact class of bug the
* Socket.IO/router-cleanup work earlier in this project was about
* catching (see docs/architecture.md).
*/
destroy(party: Party): void {
if (party.music) this.clearTimer(party);
}
private playNext(party: Party): void {
const music = party.music!;
this.clearTimer(party);
music.skipVotes.clear();
const next = music.queue.shift();
if (!next) {
music.current = null;
Logger.party(`musicQueueEmpty partyId=${party.id}`);
return;
}
music.current = { track: next, startedUtc: Date.now(), isPaused: false, pausedElapsedMs: null };
// Keeps the lobby/home-page party card's preview (name + image)
// current even though this is a queue-driven change, not a host
// action - see PartyService.toPublicState, which reads these same
// top-level fields for both video and music parties alike.
party.itemName = next.itemName;
party.itemImageUrl = next.itemImageUrl;
Logger.party(`musicNowPlaying partyId=${party.id} track="${next.itemName}"`);
this.scheduleAdvance(party);
}
/**
* Server-authoritative auto-advance: schedules the move to the next
* track for exactly when this one's known duration runs out, rather
* than waiting for any client to report "it ended" (which, with no
* single host for music, could race across several clients' players all
* finishing within milliseconds of each other and all trying to
* advance). A track with no known duration just relies on a manual
* vote-skip instead - nothing scheduled, nothing to leak.
*/
private scheduleAdvance(party: Party): void {
const music = party.music!;
const current = music.current;
if (!current || !current.track.durationSeconds) return;
const elapsedMs = Date.now() - current.startedUtc;
const remainingMs = current.track.durationSeconds * 1000 - elapsedMs;
music.advanceTimer = setTimeout(() => {
this.playNext(party);
this.broadcast(party);
this.onTrackChanged(party);
}, Math.max(0, remainingMs));
}
private clearTimer(party: Party): void {
if (party.music?.advanceTimer) {
clearTimeout(party.music.advanceTimer);
party.music.advanceTimer = null;
}
}
private broadcast(party: Party): void {
this.io?.to(party.id).emit('queueUpdated', this.getState(party));
}
}
export const MusicQueueService = new MusicQueueServiceClass();

View file

@ -0,0 +1,211 @@
'use strict';
import crypto from 'crypto';
import { Logger } from '../Logger';
import { MusicQueueService } from './MusicQueueService';
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
/**
* In-memory party store - mirrors the Party/PartyMember design from the
* earlier Emby-plugin attempt (see git history / docs/architecture.md),
* adapted for a model where we own the player: for a video party, one host
* drives playback and everyone else's client just applies whatever the
* host reports (a music party has no host for playback at all - see
* MusicQueueService). Nothing is persisted to disk; a server restart
* clears every party, which is fine - a party is meant to live for one
* watch/listen session, not persist for weeks.
*
* No join codes: the home page's live "watch parties en cours" list (see
* SocketGateway's lobby broadcast) is the only way to find a party, and
* since that's real-time, a code that has to be copied/typed out of band
* doesn't add anything - see docs/architecture.md.
*
* Singleton (exported as an instance below), same shape as
* MusicQueueService/AuthService.
*/
class PartyServiceClass {
private partiesById = new Map<string, Party>();
/**
* `queue` is the ordered list of {itemId, itemName, itemImageUrl} this
* party will play through - for a movie/standalone episode it's just the
* one item; for "start watching this series from here", CatalogService
* builds the rest of the season/series into it. `itemId`/`itemName`/
* `itemImageUrl` always mirror queue[queueIndex] - kept as top-level
* fields too since that's what most of the UI reads. Unused for
* `mediaType: 'music'` parties (still populated with a one-item fallback
* so those same top-level fields have *something* sane in them for the
* lobby/home-page card) - a music party's real, mutable queue is
* `party.music`, owned by MusicQueueService, not this fixed pre-built one.
*/
create(
hostUserId: string,
hostUserName: string,
itemId: string,
itemName: string,
itemImageUrl: string,
queue: QueueItem[] | null,
mediaType: MediaType
): Party {
const party: Party = {
id: crypto.randomUUID(),
mediaType: mediaType || 'video',
itemId,
itemName,
itemImageUrl,
queue: queue && queue.length ? queue : [{ itemId, itemName, itemImageUrl }],
queueIndex: 0,
hostUserId,
createdUtc: Date.now(),
lastActivityUtc: Date.now(),
members: new Map()
};
party.members.set(hostUserId, this.newMember(hostUserId, hostUserName));
this.partiesById.set(party.id, party);
Logger.party(`created partyId=${party.id} mediaType=${party.mediaType} host=${Logger.who(hostUserId, hostUserName)} item="${itemName}" queueLength=${party.queue.length}`);
return party;
}
getById(partyId: string): Party | null {
return this.partiesById.get(partyId) || null;
}
join(party: Party, userId: string, userName: string): PartyMember {
const member = this.newMember(userId, userName);
party.members.set(userId, member);
party.lastActivityUtc = Date.now();
Logger.party(`joined partyId=${party.id} user=${Logger.who(userId, userName)} memberCount=${party.members.size}`);
return member;
}
/**
* Removes a member. If they were the host, promotes whoever's left
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
* left, the party is destroyed outright. Returns what happened so
* callers (SocketGateway's applyLeave) can notify the new host and
* refresh the home page's live list - see docs/architecture.md.
*/
leave(party: Party, userId: string): LeaveResult {
const leavingMember = party.members.get(userId);
const wasHost = party.hostUserId === userId;
party.members.delete(userId);
party.lastActivityUtc = Date.now();
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
let newHostUserId: string | null = party.hostUserId;
if (wasHost) {
const next = party.members.keys().next();
newHostUserId = next.done ? null : next.value;
// Only reassign when there's a real successor - if nobody's left,
// the party is destroyed immediately below and this stale value is
// never read again, so there's no reason to let it go null.
if (newHostUserId) party.hostUserId = newHostUserId;
}
const destroyed = party.members.size === 0;
const whoLeft = leavingMember ? Logger.who(userId, leavingMember.userName) : userId;
if (destroyed) {
this.partiesById.delete(party.id);
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
} else if (wasHost && newHostUserId != null) {
const newHost = party.members.get(newHostUserId)!;
Logger.party(`left partyId=${party.id} user=${whoLeft} memberCount=${party.members.size} hostHandoff->${Logger.who(newHostUserId, newHost.userName)}`);
} else {
Logger.party(`left partyId=${party.id} user=${whoLeft} memberCount=${party.members.size}`);
}
return { hostChanged: wasHost && newHostUserId != null, newHostUserId, destroyed };
}
/**
* Moves to the next item in the (fixed, video) queue - e.g. next
* episode. Returns false if there's nothing after the current position.
* Resets every member's known position/pause state, since it's a new
* item playing from zero.
*/
advanceQueue(party: Party): boolean {
if (party.queueIndex >= party.queue.length - 1) {
return false;
}
party.queueIndex += 1;
const next = party.queue[party.queueIndex]!;
party.itemId = next.itemId;
party.itemName = next.itemName;
party.itemImageUrl = next.itemImageUrl;
for (const member of party.members.values()) {
member.positionTicks = 0;
member.isPaused = false;
member.lastUpdatedUtc = Date.now();
}
party.lastActivityUtc = Date.now();
Logger.party(`advanced partyId=${party.id} to item="${next.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
return true;
}
/** All active parties - powers the "watch parties en cours" home section. */
listActive(): Party[] {
return Array.from(this.partiesById.values());
}
removeIdleParties(idleTimeoutMs: number): void {
const cutoff = Date.now() - idleTimeoutMs;
for (const party of this.partiesById.values()) {
if (party.lastActivityUtc < cutoff) {
this.partiesById.delete(party.id);
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
}
}
}
/**
* `viewerUserId` lets the response say "youAreHost" instead of the
* client having to know its own Emby user id to figure that out itself -
* now that auth is a server-side session (see AuthService), the client
* doesn't hold an id to compare with at all.
*/
toPublicState(party: Party, viewerUserId: string | null): PartyPublicState {
const host = party.members.get(party.hostUserId);
return {
partyId: party.id,
mediaType: party.mediaType,
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
createdUtc: party.createdUtc,
// hasNext/queueLength/queueIndex describe the fixed video episode
// queue - meaningless for a music party (see `music` below instead).
hasNext: party.queueIndex < party.queue.length - 1,
queueLength: party.queue.length,
queueIndex: party.queueIndex,
hostUserName: host ? host.userName : null,
memberCount: party.members.size,
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
members: Array.from(party.members.values()).map((m) => ({
userId: m.userId,
userName: m.userName,
isHost: m.userId === party.hostUserId,
isPaused: m.isPaused,
positionTicks: m.positionTicks,
connected: !!m.connection
})),
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null
};
}
private newMember(userId: string, userName: string): PartyMember {
return {
userId,
userName,
connection: null,
isPaused: true,
positionTicks: 0,
lastUpdatedUtc: Date.now()
};
}
}
export const PartyService = new PartyServiceClass();

View file

@ -1,227 +0,0 @@
'use strict';
const { PartyManager } = require('./partyManager');
const { MusicQueueService } = require('./musicQueueService');
const { partyLog, who } = require('./logger');
/**
* Realtime side of the app: one party's own room, plus a shared "lobby" room
* that powers the home page's live "watch parties en cours" list.
*
* Auth comes from the session cookie sent during the initial HTTP handshake -
* exposed here via socket.request.headers.cookie.
*
* Events (client -> server unless noted):
* "subscribeLobby" joins the "lobby" room
* "parties" (server -> client) [...] full snapshot, sent on subscribe
* and again whenever any party changes
* "join" {partyId} joins that party's own room
* "members" (server -> client) [...] that party's member list
*
* -- mediaType: 'video' parties --
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
* "playstate" (server -> client, to everyone in the party but the sender)
* "next" (host only - advance the queue)
* "itemChanged" (server -> client) {itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
*
* -- mediaType: 'music' parties (see musicQueueService.js for the model) --
* "voteSkip" (anyone - see musicQueueService.js for the threshold)
* "musicPlaystate" {isPaused} (anyone - no host check, unlike video's "playstate")
* "queueUpdated" (server -> client) - see MusicQueueService.getState() for the shape
* (adding a track is REST, not a socket event - POST
* /api/parties/:partyId/tracks in index.js - since it needs to work
* from the album-browsing page too, which has no live connection to
* the party it might be adding into; MusicQueueService's own
* "queueUpdated" broadcast fires regardless of which path called it)
*
* -- both --
* "hostChanged" (server -> client, to the new host only, no payload)
* "requestPause" (throttled per member - see below;
* video only - a music party has no
* host to ask, everyone already has
* direct pause/vote-skip access)
* "pauseRequested" (server -> client, everyone in the party) {userId, userName}
* "leave"
* "errorMessage" (server -> client) {message}
* ("error" is a reserved Socket.IO event name - using our own to avoid
* colliding with its internal error handling.)
*/
function attachSocketServer(io, { parseCookies, getSession }) {
io.on('connection', (socket) => {
const cookies = parseCookies(socket.request.headers.cookie);
const session = cookies.wp_session ? getSession(cookies.wp_session) : null;
if (!session) {
socket.emit('errorMessage', { message: 'Session expirée - reconnecte-toi.' });
socket.disconnect(true);
return;
}
const userId = session.userId;
let party = null;
socket.on('subscribeLobby', () => {
socket.join('lobby');
socket.emit('parties', lobbySnapshot());
});
socket.on('join', (msg) => {
const p = PartyManager.getById(msg && msg.partyId);
const member = p && p.members.get(userId);
if (!p || !member) {
socket.emit('errorMessage', { message: "Tu n'es pas membre de cette party." });
socket.disconnect(true);
return;
}
party = p;
member.connection = socket;
socket.join(party.id);
broadcastMembers(io, party);
});
socket.on('playstate', (msg) => {
if (!party || userId !== party.hostUserId) {
return; // only the host drives playback - see docs/architecture.md
}
const member = party.members.get(userId);
const wasPaused = member.isPaused;
member.isPaused = !!(msg && msg.isPaused);
member.positionTicks = Number(msg && msg.positionTicks) || 0;
member.lastUpdatedUtc = Date.now();
party.lastActivityUtc = Date.now();
// Logged only on an actual play/pause transition, not every one of
// these - the host's player also re-sends the current state on a
// plain 3s heartbeat (see player.js), which would otherwise spam this
// line for every active party every 3 seconds regardless of whether
// anything actually changed.
if (member.isPaused !== wasPaused) {
const positionSeconds = Math.round(member.positionTicks / 10000000);
partyLog(`playstate partyId=${party.id} host=${who(userId, member.userName)} -> ${member.isPaused ? 'paused' : 'playing'} at ${positionSeconds}s`);
}
socket.to(party.id).emit('playstate', { isPaused: member.isPaused, positionTicks: member.positionTicks });
});
socket.on('next', () => {
if (!party || userId !== party.hostUserId) {
return; // only the host can advance the queue
}
if (PartyManager.advanceQueue(party)) {
io.to(party.id).emit('itemChanged', {
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
queueIndex: party.queueIndex,
queueLength: party.queue.length,
hasNext: party.queueIndex < party.queue.length - 1
});
}
});
// Majority of currently CONNECTED members, not everyone who's ever
// joined - see musicQueueService.js.
socket.on('voteSkip', () => {
const member = party && party.mediaType === 'music' && party.members.get(userId);
if (!member) return;
const connectedCount = Array.from(party.members.values()).filter((m) => m.connection).length;
MusicQueueService.voteSkip(party, userId, member.userName, connectedCount);
broadcastLobby(io);
});
// No host check, unlike video's "playstate" - see musicQueueService.js.
socket.on('musicPlaystate', (msg) => {
if (!party || party.mediaType !== 'music' || !party.members.get(userId)) return;
MusicQueueService.setPaused(party, !!(msg && msg.isPaused));
});
// Anyone (not just followers - harmless either way) can ask the host to
// pause; the presence overlay (client/js/views/player.js) is what
// triggers this. Broadcast to the whole party, not just the host, so
// everyone sees why playback is about to pause. Throttled per member
// server-side (not just the client's own cooldown, which a stray extra
// click - or anyone poking the socket directly - would bypass). Video
// only: a music party has no host to ask - everyone already has direct
// pause access via "musicPlaystate".
socket.on('requestPause', () => {
if (!party || party.mediaType === 'music') return;
const member = party.members.get(userId);
if (!member) return;
const now = Date.now();
if (member.lastPauseRequestUtc && now - member.lastPauseRequestUtc < 5000) return;
member.lastPauseRequestUtc = now;
partyLog(`pauseRequested partyId=${party.id} user=${who(userId, member.userName)}`);
io.to(party.id).emit('pauseRequested', { userId, userName: member.userName });
});
socket.on('leave', () => {
if (!party) return;
applyLeave(io, party, userId);
party = null;
});
// Fires on a clean "leave", a dead-connection timeout (Socket.IO's own
// heartbeat - see docs/architecture.md), or any other way the socket
// goes away - the one cleanup path that always runs, unlike relying on
// the client to always get a chance to emit "leave" first.
socket.on('disconnect', () => {
if (party) applyLeave(io, party, userId);
});
});
}
/**
* Removes `userId` from `party` and handles everything that follows from
* it: if they were the host, the new host (if any) gets a personal
* "hostChanged" so their player can promote itself without a page reload;
* either way, the party's own room and the lobby both get refreshed.
* Shared by the socket "leave"/"disconnect" handlers above and index.js's
* REST leave endpoint so this logic - especially the host handoff - only
* has to be right in one place.
*/
function applyLeave(io, party, userId) {
const result = PartyManager.leave(party, userId);
if (!result.destroyed) {
broadcastMembers(io, party);
if (result.hostChanged) {
const newHost = party.members.get(result.newHostUserId);
if (newHost && newHost.connection) {
newHost.connection.emit('hostChanged');
}
}
}
broadcastLobby(io);
return result;
}
function broadcastMembers(io, party) {
const members = Array.from(party.members.values()).map((m) => ({
userId: m.userId,
userName: m.userName,
isHost: m.userId === party.hostUserId,
connected: !!m.connection
}));
io.to(party.id).emit('members', members);
}
function lobbySnapshot() {
return PartyManager.listActive().map((party) => {
const host = party.members.get(party.hostUserId);
return {
partyId: party.id,
mediaType: party.mediaType,
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
hostUserName: host ? host.userName : null,
memberCount: party.members.size,
createdUtc: party.createdUtc // client computes/ticks elapsed time itself from this
};
});
}
function broadcastLobby(io) {
io.to('lobby').emit('parties', lobbySnapshot());
}
module.exports = { attachSocketServer, broadcastLobby, applyLeave };

294
server/src/types.ts Normal file
View file

@ -0,0 +1,294 @@
/**
* Shared server-side types. Kept in one file rather than colocated with
* each service, since several of these (Session, ItemDto, Party*) are used
* by three or more services and colocating would just mean everyone
* importing from whichever service happened to "own" the type first.
*
* Not shared with the client build (client/src/types.ts has its own,
* overlapping copies of the DTO shapes) - the two projects compile
* independently (tsc here, esbuild there), and a real shared package would
* be a bigger structural commitment than this app's size justifies. A
* handful of duplicated interfaces across the client/server boundary is a
* normal, accepted trade-off for that - see docs/architecture.md.
*/
import type { Server as IoServer, Socket as IoSocket } from 'socket.io';
// --- Realtime (Socket.IO) event maps ---------------------------------------
// Typed once here rather than left as bare strings at every .emit()/.on()
// call site - see SocketGateway, MusicQueueService (which broadcasts
// "queueUpdated" directly, independent of SocketGateway's own handlers).
export interface ClientToServerEvents {
subscribeLobby: () => void;
join: (msg: { partyId: string }) => void;
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
next: () => void;
voteSkip: () => void;
musicPlaystate: (msg: { isPaused: boolean }) => void;
requestPause: () => void;
leave: () => void;
}
/** The socket "members" broadcast's shape - deliberately smaller than
* PartyPublicState.members below (no isPaused/positionTicks): that extra
* detail is REST-only (a follower's player only ever reads "playstate"
* events for position, never the member list, for that). */
export interface SocketMemberDto {
userId: string;
userName: string;
isHost: boolean;
connected: boolean;
}
export interface ServerToClientEvents {
parties: (parties: LobbyPartyDto[]) => void;
members: (members: SocketMemberDto[]) => void;
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
itemChanged: (msg: {
itemId: string;
itemName: string;
itemImageUrl: string;
queueIndex: number;
queueLength: number;
hasNext: boolean;
}) => void;
queueUpdated: (state: MusicPublicState) => void;
hostChanged: () => void;
pauseRequested: (msg: { userId: string; userName: string }) => void;
errorMessage: (msg: { message: string }) => void;
}
export type AppSocket = IoSocket<ClientToServerEvents, ServerToClientEvents>;
export type AppServer = IoServer<ClientToServerEvents, ServerToClientEvents>;
// --- Auth / session -------------------------------------------------------
export interface EmbySession {
embyToken: string;
userId: string;
userName: string;
serverId: string;
createdUtc: number;
}
// Augments Express's own Request type (declaration merging) so
// `req.embySession` is known everywhere without a separate "AuthedRequest"
// type at every route - set once by requireSession in index.ts.
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
embySession?: EmbySession;
}
}
}
// --- Catalog ---------------------------------------------------------------
/** Emby's own item shape, as returned by /Users/{id}/Items and friends -
* only the fields this app actually reads, not the full Emby schema. */
export interface EmbyItem {
Id: string;
Name: string;
Overview?: string;
ProductionYear?: number;
Type?: string;
SeriesId?: string;
SeriesName?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
RunTimeTicks?: number;
Album?: string;
AlbumArtist?: string;
Artists?: string[];
}
/** This app's own DTO shape - what toItemDto() in CatalogService produces,
* sent to the client for every item type (movie/episode/series/artist/
* album/track/musicvideo alike). Track-specific fields are present but
* meaningless for anything else. */
export interface ItemDto {
id: string;
name: string;
overview?: string;
year?: number;
type?: string;
canWatchParty: boolean;
imageUrl: string;
embyUrl: string;
durationTicks: number | null;
indexNumber: number | null;
artistName: string | null;
albumName: string | null;
}
export interface LibraryDto {
id: string;
name: string;
collectionType: string;
}
export interface SeasonDto {
id: string;
name: string;
indexNumber?: number;
}
export interface PlaybackTrackOption {
index: number;
label: string;
}
export interface PlaybackOptionsDto {
audioTracks: PlaybackTrackOption[];
subtitleTracks: PlaybackTrackOption[];
}
// --- Party (video) -----------------------------------------------------------
/** One entry in a video party's fixed, pre-built queue (a movie/standalone
* episode is just a one-item queue) - see PartyService.create. */
export interface QueueItem {
itemId: string;
itemName: string;
itemImageUrl: string;
}
export type MediaType = 'video' | 'music';
export interface PartyMember {
userId: string;
userName: string;
connection: AppSocket | null;
isPaused: boolean;
positionTicks: number;
lastUpdatedUtc: number;
lastPauseRequestUtc?: number;
}
/** The full internal party record - never sent to the client as-is, see
* PartyService.toPublicState for the shape that actually goes out. */
export interface Party {
id: string;
mediaType: MediaType;
itemId: string;
itemName: string;
itemImageUrl: string;
queue: QueueItem[];
queueIndex: number;
hostUserId: string;
createdUtc: number;
lastActivityUtc: number;
members: Map<string, PartyMember>;
/** Only present once MusicQueueService.init() has run - i.e. mediaType
* === 'music'. Owned and shaped entirely by MusicQueueService; every
* other service treats this as an opaque bag it just carries around. */
music?: MusicState;
}
export interface PublicMember {
userId: string;
userName: string;
isHost: boolean;
isPaused: boolean;
positionTicks: number;
connected: boolean;
}
/** What GET /api/parties/:id and POST /api/parties actually return - the
* video queue fields (hasNext/queueLength/queueIndex) are meaningless for
* a music party, which carries `music` instead. */
export interface PartyPublicState {
partyId: string;
mediaType: MediaType;
itemId: string;
itemName: string;
itemImageUrl: string;
createdUtc: number;
hasNext: boolean;
queueLength: number;
queueIndex: number;
hostUserName: string | null;
memberCount: number;
youAreHost: boolean;
members: PublicMember[];
music: MusicPublicState | null;
}
export interface LobbyPartyDto {
partyId: string;
mediaType: MediaType;
itemId: string;
itemName: string;
itemImageUrl: string;
hostUserName: string | null;
memberCount: number;
createdUtc: number;
}
export interface LeaveResult {
hostChanged: boolean;
newHostUserId: string | null;
destroyed: boolean;
}
// --- Music (participative queue) --------------------------------------------
/** One track in a music party's shared, mutable queue - see
* MusicQueueService. Distinct from QueueItem above: that one is fixed and
* built once server-side, this one grows live as members add to it. */
export interface MusicTrack {
itemId: string;
itemName: string;
artistName: string | null;
albumName: string | null;
itemImageUrl: string;
durationSeconds: number | null;
addedByUserId: string;
addedByUserName: string;
}
/** What a client actually sends when adding a track (POST
* /api/parties/:id/tracks) - Emby-tick-shaped, converted to
* MusicTrack.durationSeconds once by MusicQueueService.addTrack. */
export interface MusicTrackInput {
itemId: string;
itemName: string;
artistName?: string | null;
albumName?: string | null;
itemImageUrl: string;
durationTicks?: number | null;
}
export interface MusicCurrentTrack {
track: MusicTrack;
startedUtc: number;
isPaused: boolean;
/** Elapsed ms at the moment it was paused - null while playing. Combined
* with `isPaused`, this is the only state MusicQueueService.getElapsedSeconds
* needs; there is no separate ticking "position" stored anywhere. */
pausedElapsedMs: number | null;
}
/** party.music - owned entirely by MusicQueueService (see its class
* comment for why this is a separate service rather than living in
* PartyService alongside the video queue fields). */
export interface MusicState {
queue: MusicTrack[];
current: MusicCurrentTrack | null;
skipVotes: Set<string>;
advanceTimer: NodeJS.Timeout | null;
}
export interface MusicCurrentPublic extends MusicTrack {
isPaused: boolean;
elapsedSeconds: number;
}
/** The "queueUpdated" broadcast payload / party.music's public shape. */
export interface MusicPublicState {
queue: MusicTrack[];
current: MusicCurrentPublic | null;
skipVotes: number;
}

18
server/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"declaration": false
},
"include": ["src/**/*.ts"]
}