watchparty/server/src/index.ts
Nicolas 137977893a
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 14s
Render subtitles client-side (<track>) instead of asking Emby to burn them in
Six rounds of real-server debugging (see docs/architecture.md's player-
track-selection section) found three different, unrelated reasons a
server-side burn-in could fail - missing fonts, a bitmap format libass
fundamentally can't burn, ffmpeg failing outright on an otherwise
unremarkable file - none of which this app's own request ever caused or
could have fixed, all downstream of the one thing every attempt had in
common: asking Emby for a real-time video re-encode. Not needing that
re-encode at all closes the whole class of failure instead of chasing
each one.

- server/src/services/CatalogService.ts: new proxySubtitle asks Emby to
  convert the track to WebVTT (GET /Videos/{id}/{mediaSourceId}/
  Subtitles/{index}/Stream.vtt - a real Emby endpoint their own web
  client already uses for this) - entirely separate from the video
  transcode. applyPlaybackOverrides lost its SubtitleStreamIndex/
  SubtitleMethod=Encode branch - the video request no longer needs to
  know a subtitle was picked at all.
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption's flag
  renamed supportsBurnIn -> isTextBased - same underlying fact (image vs
  text codec), but the name shouldn't keep describing a mechanism this
  app no longer uses.
- server/src/index.ts: new GET /api/catalog/subtitle/:itemId/:subtitleIndex
  route, grouped with the other cookie-authed media-element routes (a
  <track> can't send a bearer header either).
- client/src/api/embyApi.ts: new subtitleUrl(); streamUrl no longer
  carries SubtitleStreamIndex at all.
- client/src/views/player/Player.ts: the subtitle <select> no longer
  reloads the video (reloadWithCurrentOptions, still used for audio/
  quality, never touches subtitles now). New applySubtitleTrack()
  removes/re-adds a fresh <track> and sets video.textTracks[0].mode =
  'showing' synchronously right after appending - deliberately not
  waiting on the <track>'s own "load" event, confirmed against a real
  browser that it doesn't fire reliably at all (a known cross-browser
  quirk), which would have left a subtitle silently never shown - the
  exact symptom this whole saga started from. Re-applied on every video
  (re)load so an audio/quality change (which does still reload the
  video) doesn't drop whatever subtitle was showing.
- server/test/catalogService.test.ts: updated/new cases for
  proxySubtitle and the renamed isTextBased flag; removed the test for
  the now-deleted subtitle-forcing behavior.

Verified against the real compiled bundle: picking a text track adds one
<track> with the right URL and flips it to showing; "Aucun" removes it;
an image-coded track is listed but disabled; changing audio reloads the
video but the subtitle survives it, same element, still showing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 16:31:25 +02:00

353 lines
15 KiB
TypeScript

'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/subtitles are requested by <img>/<video>/<audio>/
// <track> elements, which send the session cookie (same-origin) but can't
// carry a bearer header - so these four 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'));
}));
// A subtitle track's own text (WebVTT), for the player's native <track> -
// see CatalogService.proxySubtitle's own comment for why subtitles are
// rendered this way instead of asking Emby to burn them into the video.
app.get('/api/catalog/subtitle/:itemId/:subtitleIndex', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await CatalogService.proxySubtitle(req, res, session, param(req, 'itemId'), param(req, 'subtitleIndex'));
}));
// --- 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'})`);
});