Rewrite server + client in TypeScript, service-oriented server architecture
All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
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:
parent
efaa7f55f0
commit
4a31ee741e
76 changed files with 5567 additions and 3286 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal 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
8
.gitignore
vendored
|
|
@ -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
107
README.md
|
|
@ -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
47
client/build.mjs
Normal 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');
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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();
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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">← 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';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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">← 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>';
|
||||
}
|
||||
|
|
@ -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.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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">← 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
478
client/package-lock.json
generated
Normal 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
14
client/package.json
Normal 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
123
client/src/api/embyApi.ts
Normal 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
145
client/src/api/party.ts
Normal 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
43
client/src/app.ts
Normal 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
59
client/src/global.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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
105
client/src/router.ts
Normal 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();
|
||||
}
|
||||
81
client/src/shell/Shell.css
Normal file
81
client/src/shell/Shell.css
Normal 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; }
|
||||
}
|
||||
12
client/src/shell/Shell.html
Normal file
12
client/src/shell/Shell.html
Normal 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
46
client/src/shell/Shell.ts
Normal 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
122
client/src/styles/base.css
Normal 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; }
|
||||
21
client/src/styles/detail.css
Normal file
21
client/src/styles/detail.css
Normal 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;
|
||||
}
|
||||
25
client/src/styles/presence.css
Normal file
25
client/src/styles/presence.css
Normal 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;
|
||||
}
|
||||
29
client/src/styles/trackList.css
Normal file
29
client/src/styles/trackList.css
Normal 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
118
client/src/tiles.css
Normal 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; }
|
||||
}
|
||||
|
|
@ -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
140
client/src/types.ts
Normal 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;
|
||||
}
|
||||
21
client/src/views/album/Album.css
Normal file
21
client/src/views/album/Album.css
Normal 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; }
|
||||
}
|
||||
10
client/src/views/album/Album.html
Normal file
10
client/src/views/album/Album.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<button class="linklike" id="back">← 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>
|
||||
72
client/src/views/album/Album.ts
Normal file
72
client/src/views/album/Album.ts
Normal 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';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
3
client/src/views/artist/Artist.html
Normal file
3
client/src/views/artist/Artist.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<button class="linklike" id="back">← Retour</button>
|
||||
<div class="pageHeader"><h1></h1></div>
|
||||
<div id="grid" class="grid"></div>
|
||||
34
client/src/views/artist/Artist.ts
Normal file
34
client/src/views/artist/Artist.ts
Normal 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>';
|
||||
}
|
||||
44
client/src/views/home/Home.css
Normal file
44
client/src/views/home/Home.css
Normal 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; }
|
||||
10
client/src/views/home/Home.html
Normal file
10
client/src/views/home/Home.html
Normal 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>
|
||||
|
|
@ -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);
|
||||
2
client/src/views/library/Library.html
Normal file
2
client/src/views/library/Library.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<div class="pageHeader"><h1></h1></div>
|
||||
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>
|
||||
|
|
@ -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;
|
||||
}
|
||||
14
client/src/views/login/Login.html
Normal file
14
client/src/views/login/Login.html
Normal 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>
|
||||
22
client/src/views/login/Login.ts
Normal file
22
client/src/views/login/Login.ts
Normal 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.";
|
||||
}
|
||||
});
|
||||
}
|
||||
61
client/src/views/musicPlayer/MusicPlayer.css
Normal file
61
client/src/views/musicPlayer/MusicPlayer.css
Normal 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;
|
||||
}
|
||||
28
client/src/views/musicPlayer/MusicPlayer.html
Normal file
28
client/src/views/musicPlayer/MusicPlayer.html
Normal 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">▶</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>
|
||||
|
|
@ -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">▶</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">← 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">← Retour</button>' : '';
|
||||
function renderBrowseList(items: { id: string; name: string }[], withBack: boolean): string {
|
||||
const back = withBack ? '<button class="linklike browseBack">← 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(() => {});
|
||||
};
|
||||
}
|
||||
195
client/src/views/player/Player.css
Normal file
195
client/src/views/player/Player.css
Normal 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;
|
||||
}
|
||||
34
client/src/views/player/Player.html
Normal file
34
client/src/views/player/Player.html
Normal 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 →</button>
|
||||
|
||||
<div id="settingsWrap" class="settingsWrap hide">
|
||||
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">⚙</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>
|
||||
|
|
@ -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 →</button>
|
||||
|
||||
<div id="settingsWrap" class="settingsWrap hide">
|
||||
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">⚙</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(() => {});
|
||||
};
|
||||
}
|
||||
91
client/src/views/series/Series.css
Normal file
91
client/src/views/series/Series.css
Normal 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%; }
|
||||
}
|
||||
11
client/src/views/series/Series.html
Normal file
11
client/src/views/series/Series.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<button class="linklike" id="back">← 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>
|
||||
103
client/src/views/series/Series.ts
Normal file
103
client/src/views/series/Series.ts
Normal 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
16
client/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
662
server/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
44
server/src/Logger.ts
Normal 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
19
server/src/config.ts
Normal 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;
|
||||
88
server/src/emby/EmbyClient.ts
Normal file
88
server/src/emby/EmbyClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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
344
server/src/index.ts
Normal 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'})`);
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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() };
|
||||
|
|
@ -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() };
|
||||
229
server/src/realtime/SocketGateway.ts
Normal file
229
server/src/realtime/SocketGateway.ts
Normal 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();
|
||||
101
server/src/services/AuthService.ts
Normal file
101
server/src/services/AuthService.ts
Normal 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();
|
||||
385
server/src/services/CatalogService.ts
Normal file
385
server/src/services/CatalogService.ts
Normal 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();
|
||||
219
server/src/services/MusicQueueService.ts
Normal file
219
server/src/services/MusicQueueService.ts
Normal 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();
|
||||
211
server/src/services/PartyService.ts
Normal file
211
server/src/services/PartyService.ts
Normal 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();
|
||||
|
|
@ -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
294
server/src/types.ts
Normal 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
18
server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue