watchparty/server/src/index.ts
Nicolas 58d51f5f74
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Name the three party kinds: watch, listen and lounge party
- UI wording: "Watch party" = classic video, "Listen party" = classic
  audio, "Lounge party" = static party (tab, page, create buttons,
  toasts/confirms, server error messages); code names are unchanged
- home: "Parties en cours" with a Watch party / Listen party badge on
  each card
- music launch buttons say "Lancer une listen party"; a failed tile
  launch now restores its own label instead of always "watch party"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:22:15 +02:00

1005 lines
47 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 { DATA_DIR, EMBY_SERVER_URL, PORT } from './config';
import { Logger } from './Logger';
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
import { CatalogService, CatalogFetchError, imageUrlFor, splitPlaylistByMediaType } from './services/CatalogService';
import { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService';
import { PartyStreamService } from './services/PartyStreamService';
import { PlaystateService } from './services/PlaystateService';
import { EmbySessionService } from './services/EmbySessionService';
import { CastTokenService } from './services/CastTokenService';
import { StaticPartyService } from './services/StaticPartyService';
import { StaticPartyStore } from './services/StaticPartyStore';
import { SocketGateway } from './realtime/SocketGateway';
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents, StaticParty } from './types';
StaticPartyStore.configure(DATA_DIR);
StaticPartyService.init();
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;
}
/**
* Resolves the Emby session for one of the 4 streaming routes (the
* party's shared normal/burn-in stream) - normally the caller's own
* session cookie, same as every other route, but a Chromecast can't
* carry that cookie at all, so these 4 specifically also accept a
* short-lived, party-scoped `castToken` query param instead - see
* CastTokenService. Nothing else in the app ever looks at that param.
*/
function sessionForStream(req: Request, partyId: string): EmbySession | null {
return AuthService.sessionFromRequest(req) || CastTokenService.sessionFromToken(String(req.query.castToken || ''), partyId);
}
/**
* The same 4 streaming routes `sessionForStream` covers also need CORS -
* a Chromecast's Default Media Receiver plays this app's HLS through an
* MSE-based player (Chromecast's own Chromium build has no native HLS
* support), which fetches every segment via `fetch`/XHR from whatever
* origin Google hosts that receiver at - cross-origin from us. Without
* this, the browser blocks every one of those reads outright, before
* any of this app's own cookie/castToken check even runs - confirmed as
* the real cause of "casts, but nothing ever plays" after the castToken
* propagation fix above turned out not to be the whole story.
*
* `*` is safe here specifically because these routes never need to
* support *credentialed* cross-origin access - a real browser watching
* this app already does so same-origin (no CORS involved at all), and
* the one real cross-origin caller (the receiver) authenticates via the
* castToken query param, not a cookie; `Access-Control-Allow-Origin: *`
* combined with `Access-Control-Allow-Credentials` would be required
* before a cookie could ever ride along on a cross-origin request, and
* this deliberately never sends that second header. `Range` (needed
* for byte-range/segment fetches) isn't itself CORS-safelisted, so a
* real cross-origin GET carrying one triggers a preflight - handled
* below by answering `OPTIONS` for the same 4 paths.
*/
function allowCastCors(_req: Request, res: Response, next: NextFunction): void {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since');
next();
}
const CAST_CORS_PATHS = [
'/api/catalog/party-stream/:partyId/master.m3u8',
'/api/catalog/party-stream/:partyId/raw',
'/api/catalog/burn/:partyId/master.m3u8',
'/api/catalog/burn/:partyId/:segment'
];
app.options(CAST_CORS_PATHS, allowCastCors, (_req, res) => res.status(204).end());
// --- 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')) });
}));
// A playlist's own contents, in order - for views/playlist. See
// POST /api/parties below for actually launching one.
app.get('/api/catalog/playlists/:playlistId/items', requireSession, asyncRoute(async (req, res) => {
res.json({ items: await CatalogService.getPlaylistItems(req.embySession!, param(req, 'playlistId')) });
}));
// 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')));
}));
// Emby's own native intro/credits detection, when the library it's from has
// the feature enabled - powers the player's "Passer l'intro"/"Épisode
// suivant" buttons. See CatalogService.getPlaybackMarkers.
app.get('/api/catalog/item/:itemId/markers', requireSession, asyncRoute(async (req, res) => {
res.json(await CatalogService.getPlaybackMarkers(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'));
}));
// The party's own shared Emby stream - one negotiated PlaybackInfo
// session reused by every member, instead of each viewer's own
// <video>/<audio> negotiating (and getting its own PlaySessionId)
// independently - see PartyStreamService and docs/architecture.md's
// "One shared Emby stream per party" section. Keyed by partyId, not
// itemId - the party object itself is the source of truth for what
// it's currently streaming and with which audio/quality choice, so
// there's nothing else a client could usefully put in this URL.
// requireSession's JSON error doesn't fit here either, same reasoning
// as the routes above (a <video>/<audio> element can't carry a bearer
// header).
app.get('/api/catalog/party-stream/:partyId/master.m3u8', allowCastCors, asyncRoute(async (req, res) => {
const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return;
}
await PartyStreamService.serveMasterPlaylist(req, res, party, session);
}));
// Everything an HLS playlist references (variant playlists, segments)
// comes back through here - see EmbyClient.rewritePlaylist.
app.get('/api/catalog/party-stream/:partyId/raw', allowCastCors, asyncRoute(async (req, res) => {
const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return;
}
await PartyStreamService.serveRawAsset(req, res, party, session);
}));
// 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'));
}));
// The party's own self-hosted-ffmpeg burn-in stream (PGS/image subtitles
// only - see TranscodeService and Party.burnInSubtitleIndex's own
// comment); every other party keeps streaming through the party-stream
// routes above instead - PartyStreamService, not our own ffmpeg. Keyed
// by partyId, not itemId, same reasoning as those routes: the party
// object itself is the source of truth for which audio/subtitle
// combination is currently burned in, so there's nothing else a client
// could usefully put in this URL. requireSession's JSON error doesn't
// fit here either, same reasoning as the routes above.
app.get('/api/catalog/burn/:partyId/master.m3u8', allowCastCors, asyncRoute(async (req, res) => {
const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return;
}
if (!party.burnInSubtitleIndex) {
res.status(409).json({ error: "Cette watch party n'a pas de flux incrusté actif." });
return;
}
await TranscodeService.serveMasterPlaylist(req, res, party, session);
}));
app.get('/api/catalog/burn/:partyId/:segment', allowCastCors, asyncRoute(async (req, res) => {
const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return;
}
await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
}));
// --- 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);
// A playlist itself was never something Emby could stream - launching
// one means queuing everything inside it instead, a separate enough
// shape (video queue vs. seeding the participative music queue several
// tracks at once) that it's its own function rather than folded into
// the single-item path below. See docs/architecture.md.
if (item.Type === 'Playlist') {
await createPartyFromPlaylist(res, session, itemId, req.body?.startAtItemId);
return;
}
// Neither is itself streamable (same reason a Playlist above isn't) -
// launching one queues every track inside it instead: every album for
// an artist, every track for an album, in Emby's own sort order (see
// resolveMusicTracksForItem). Asked for directly, alongside the same
// action on the party's own existing queue - see the "queue" route
// below - both share this resolution.
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
const tracks = await resolveMusicTracksForItem(session, item);
createMusicPartyFromTracks(res, session, tracks);
return;
}
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(toVideoQueueItem);
}
const party = PartyService.create(
session.userId,
session.userName,
itemId,
queue ? queue[0]!.itemName : item.Name,
imageUrlFor(item),
queue,
mediaType
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
if (mediaType === 'music') {
MusicQueueService.init(party, {
itemId,
itemName: item.Name,
artistName: item.AlbumArtist || item.Artists?.[0] || null,
albumName: item.Album || null,
itemImageUrl: imageUrlFor(item),
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;
}
/** One video item (episode or movie) as a queue entry - shared by every
* path that builds a video queue: an episode's series, a playlist, a
* static watch party. */
function toVideoQueueItem(item: EmbyItem): QueueItem {
return {
itemId: item.Id,
itemName: item.Type === 'Episode' ? formatEpisodeName(item) : item.Name,
itemImageUrl: `/api/catalog/image/${item.Id}`
};
}
/**
* The "launch this playlist" half of POST /api/parties above - kept
* separate rather than merged into the single-item path, which is
* already working/relied-upon and doesn't need touching for this.
* splitPlaylistByMediaType (CatalogService) decides video vs music from
* the playlist's first item and drops anything that doesn't match (a
* mixed playlist - not something this app's party model can represent).
*
* `startAtItemId` is views/playlist's "Lire à partir d'ici" - optional,
* and only meaningful once `items` (the playlist's own contents, already
* fetched here) is in hand: unlike an Episode's `SeriesId`, a playlist
* item carries no back-reference to whichever playlist it came from, so
* this can only ever be resolved against a specific, already-known
* playlist's own item list - never from a bare itemId alone the way
* views/series's per-episode buttons resolve straight through the
* single-item path above (`item.SeriesId`). Same slice-not-wrap
* semantics as that path too: everything before the chosen item is
* simply left out of this party, not queued for later.
*/
async function createPartyFromPlaylist(res: Response, session: EmbySession, playlistId: string, startAtItemId?: string): Promise<void> {
const raw = await CatalogService.getPlaylistItemsRaw(session, playlistId);
const { mediaType, items: allItems } = splitPlaylistByMediaType(raw);
if (!allItems.length) {
res.status(400).json({ error: 'Cette playlist est vide.' });
return;
}
if (allItems.length !== raw.length) {
Logger.party(`playlist ${playlistId} is mixed-media - keeping only the ${allItems.length}/${raw.length} ${mediaType} item(s)`);
}
const startIndex = startAtItemId ? allItems.findIndex((i) => i.Id === startAtItemId) : -1;
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
if (mediaType === 'music') {
const trackInputs: MusicTrackInput[] = items.map((track) => ({
itemId: track.Id,
itemName: track.Name,
artistName: track.AlbumArtist || track.Artists?.[0] || null,
albumName: track.Album || null,
itemImageUrl: imageUrlFor(track),
durationTicks: track.RunTimeTicks || null
}));
createMusicPartyFromTracks(res, session, trackInputs);
return;
}
const queue: QueueItem[] = items.map(toVideoQueueItem);
const party = PartyService.create(
session.userId, session.userName,
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
queue, mediaType
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
}
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
* or MusicArtist - the three POST /api/parties and the "queue" route
* below ever call this for) resolved into a flat, ordered track list -
* a single track for Audio, every track in Emby's own IndexNumber order
* for an album, every album's tracks in Emby's own ProductionYear/
* SortName order for an artist's whole discography. Two Emby round
* trips for an artist's own albums+tracks would be one call each if
* this app ever needed to page through a huge discography - not
* attempted speculatively, since Emby has yet to show any sign of
* needing it for a personal library's worth of artists. */
async function resolveMusicTracksForItem(session: EmbySession, item: EmbyItem): Promise<MusicTrackInput[]> {
if (item.Type === 'MusicAlbum') {
const tracks = await CatalogService.getAlbumTracks(session, item.Id);
return tracks.map(itemDtoToTrackInput);
}
if (item.Type === 'MusicArtist') {
const albums = await CatalogService.getArtistAlbums(session, item.Id);
const perAlbum = await Promise.all(albums.map((album) => CatalogService.getAlbumTracks(session, album.id)));
return perAlbum.flat().map(itemDtoToTrackInput);
}
// A single Audio track - the only other type this is ever called for.
return [{
itemId: item.Id,
itemName: item.Name,
artistName: item.AlbumArtist || item.Artists?.[0] || null,
albumName: item.Album || null,
itemImageUrl: imageUrlFor(item),
durationTicks: item.RunTimeTicks || null
}];
}
function itemDtoToTrackInput(item: ItemDto): MusicTrackInput {
return {
itemId: item.id,
itemName: item.name,
artistName: item.artistName,
albumName: item.albumName,
itemImageUrl: item.imageUrl,
durationTicks: item.durationTicks
};
}
/** Shared by every "launch this as a listen party" path that can produce
* more than one track up front (an album, an artist's discography, a
* music playlist - see createPartyFromPlaylist above and the
* MusicAlbum/MusicArtist branch of POST /api/parties below) - seeds
* party.music with `tracks`, in order: the first auto-starts,
* everything else queues behind it via MusicQueueService.addTracks
* (one broadcast for however many tracks that is, not one per track). */
function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks: MusicTrackInput[]): void {
if (!tracks.length) {
res.status(400).json({ error: 'Aucun morceau à lire.' });
return;
}
const [first, ...rest] = tracks;
const party = PartyService.create(
session.userId, session.userName,
first!.itemId, first!.itemName, first!.itemImageUrl,
null, 'music'
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
MusicQueueService.init(party, first!, session.userId, session.userName);
MusicQueueService.addTracks(party, rest, session.userId, session.userName);
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
}
// 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: 'Listen party introuvable.' });
return;
}
const session = req.embySession!;
if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette 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));
});
// Same "queue this item" resolution as the MusicAlbum/MusicArtist branch
// of POST /api/parties above (a track queues itself; an album/artist
// expands into every track inside it - see resolveMusicTracksForItem),
// but adding into an ALREADY-LIVE party's queue instead of creating a
// new one - views/album's and views/artist's own "add to queue" buttons.
// Kept as its own route rather than overloading POST .../tracks above:
// that one takes an already-built track object the caller already has
// in hand (views/album's per-track buttons) - making it also guess
// whether it received that or a bare id-to-resolve would be more
// surprising than a second, clearly-named route.
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Listen party introuvable.' });
return;
}
const session = req.embySession!;
if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return;
}
const itemId = req.body?.itemId;
if (!itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
const item = await CatalogService.getRawItem(session, itemId);
const tracks = await resolveMusicTracksForItem(session, item);
MusicQueueService.addTracks(party, tracks, 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);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
// Joining a static watch party's group session from the lobby makes you
// a member of that static party too - its progress is now yours.
if (party.staticLink?.mode === 'group') {
const sp = StaticPartyService.getById(party.staticLink.staticPartyId);
if (sp) StaticPartyService.join(sp, { userId: session.userId, userName: 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) {
const member = party.members.get(req.embySession!.userId);
const deviceId = typeof req.body?.deviceId === 'string' ? req.body.deviceId : null;
// See PartyService.isStaleDevice's own comment for why this check
// exists at all - a REST call has no live connection to guard this
// with, unlike the socket "leave"/"disconnect" handlers.
if (!PartyService.isStaleDevice(member, deviceId)) {
// 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));
});
// Lets a member's Chromecast into the party's existing shared stream
// without ever handing it the sender's own session cookie (which a
// Chromecast can't carry) or the real Emby token - see CastTokenService
// and docs/architecture.md's "Casting to a Chromecast" section. Normal
// cookie auth here (requireSession) since this is called from the
// sender's own page, not the Chromecast itself.
app.post('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
const session = req.embySession!;
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return;
}
res.json({ token: CastTokenService.issue(party.id, session) });
});
// Called when the sender stops casting - see CastTokenService
// .revokeForMember's own comment for why this isn't the only way a
// token gets invalidated (leaving the party revokes it too, unconditionally).
app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
CastTokenService.revokeForMember(param(req, 'partyId'), req.embySession!.userId);
res.json({ ok: true });
});
// --- Static (persistent) watch parties ------------------------------------
// A whole series/video playlist watched by a group over several sessions,
// remembering the group's progress and each member's - see
// StaticPartyService and spec/static-watch-party.md. Each session is an
// ordinary live party (PartyService) linked back through party.staticLink.
/** Fetches a static party's source (every episode of the series, or the
* playlist's video items) under `session`'s own account - so each item
* carries *their* Emby UserData (Played). */
async function fetchStaticSourceItems(session: EmbySession, source: StaticParty['source']): Promise<EmbyItem[]> {
if (source.type === 'series') {
return CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId);
}
const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId));
return mediaType === 'video' ? items : [];
}
/**
* Re-fetches `sp`'s source for `session`: merges any new item into it
* (StaticPartyService.refreshItems) and returns the ids Emby already has
* as Played for this user. Best-effort - Emby being unreachable must not
* make the page unusable, so a failure is logged and treated as "no
* Played info, no new items" (the in-app progress still works on its own).
*/
async function syncStaticSource(session: EmbySession, sp: StaticParty): Promise<Set<string>> {
try {
const raw = await fetchStaticSourceItems(session, sp.source);
StaticPartyService.refreshItems(sp, raw.map(toVideoQueueItem));
return new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
} catch (err) {
Logger.staticParty(`source sync failed id=${sp.id} user=${Logger.who(session.userId, session.userName)}: ${(err as Error).message}`);
return new Set();
}
}
/** `sp`'s live group session id, if that party is still running - clears a
* stale one (a party can end without its static party having heard). */
function liveGroupPartyId(sp: StaticParty): string | null {
if (sp.livePartyId && !PartyService.getById(sp.livePartyId)) StaticPartyService.setLiveParty(sp, null);
return sp.livePartyId;
}
/** Finds `:staticId` or answers 404 - null means the response is sent. */
function staticPartyOr404(req: Request, res: Response): StaticParty | null {
const sp = StaticPartyService.getById(param(req, 'staticId'));
if (!sp) res.status(404).json({ error: 'Lounge party introuvable.' });
return sp;
}
app.get('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
const session = req.embySession!;
const parties = StaticPartyService.list();
const played = await Promise.all(parties.map((sp) => syncStaticSource(session, sp)));
res.json({
staticParties: parties.map((sp, i) => StaticPartyService.toSummary(sp, session.userId, played[i]!, liveGroupPartyId(sp)))
});
}));
app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
const sourceId = req.body?.sourceId;
if (!sourceId || typeof sourceId !== 'string') {
res.status(400).json({ error: 'sourceId is required.' });
return;
}
const session = req.embySession!;
const item = await CatalogService.getRawItem(session, sourceId);
// An episode stands for its whole series - the natural entry point from
// views/series is the series itself, but nothing's lost accepting one.
let source: StaticParty['source'];
let sourceItem = item;
if (item.Type === 'Series') {
source = { type: 'series', sourceId: item.Id };
} else if (item.Type === 'Episode' && item.SeriesId) {
source = { type: 'series', sourceId: item.SeriesId };
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
} else if (item.Type === 'Playlist') {
source = { type: 'playlist', sourceId: item.Id };
} else {
res.status(400).json({ error: 'Une lounge party se crée depuis une série ou une playlist vidéo.' });
return;
}
const raw = await fetchStaticSourceItems(session, source);
if (!raw.length) {
res.status(400).json({ error: source.type === 'playlist' ? 'Cette playlist ne contient aucune vidéo.' : "Cette série n'a aucun épisode." });
return;
}
const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : sourceItem.Name;
const sp = StaticPartyService.create(
{ userId: session.userId, userName: session.userName },
source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem)
);
const played = new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
res.json(StaticPartyService.toDetail(sp, session.userId, played, null));
}));
app.get('/api/static-parties/:staticId', requireSession, asyncRoute(async (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
const played = await syncStaticSource(session, sp);
res.json(StaticPartyService.toDetail(sp, session.userId, played, liveGroupPartyId(sp)));
}));
app.post('/api/static-parties/:staticId/join', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
res.json({ ok: true });
});
app.post('/api/static-parties/:staticId/leave', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
StaticPartyService.leave(sp, req.embySession!.userId);
res.json({ ok: true });
});
app.delete('/api/static-parties/:staticId', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
if (!StaticPartyService.delete(sp, req.embySession!.userId)) {
res.status(403).json({ error: 'Seule la personne qui a créé cette lounge party peut la supprimer.' });
return;
}
res.json({ ok: true });
});
app.post('/api/static-parties/:staticId/items/:itemId/watched', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
if (!StaticPartyService.markWatched(sp, { userId: session.userId, userName: session.userName }, param(req, 'itemId'))) {
res.status(404).json({ error: 'Cet élément ne fait pas partie de cette lounge party.' });
return;
}
res.json({ ok: true });
});
// Launches the group's next session - or joins it, if one's already
// running. Whoever launches it hosts it (see PartyService.create), and it
// resumes exactly where the group stopped last time: the host's starting
// position is seeded from the saved one, which is what Player.ts already
// reads to place every member's first frame (see its init()).
app.post('/api/static-parties/:staticId/session', requireSession, asyncRoute(async (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
const who = { userId: session.userId, userName: session.userName };
const existingId = liveGroupPartyId(sp);
if (!existingId) await syncStaticSource(session, sp); // pick up new episodes before building the queue
// Checked again after the await: someone else may have launched it meanwhile.
const liveId = liveGroupPartyId(sp);
const live = liveId ? PartyService.getById(liveId) : null;
if (live) {
PartyService.join(live, session.userId, session.userName);
PartyService.setMemberEmbyToken(live, session.userId, session.embyToken);
StaticPartyService.join(sp, who);
SocketGateway.broadcastLobby(io);
res.json({ partyId: live.id });
return;
}
const queue = StaticPartyService.groupQueue(sp);
if (!queue.length) {
res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette lounge party.' });
return;
}
const first = queue[0]!;
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
party.members.get(session.userId)!.positionTicks = sp.group.resumePositionTicks;
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
StaticPartyService.join(sp, who);
StaticPartyService.setLiveParty(sp, party.id);
SocketGateway.broadcastLobby(io);
res.json({ partyId: party.id });
}));
// Catching up on missed items - alone (a party hidden from the lobby) or as
// an ordinary watch party others can join. Only the participants' own
// progress changes; the group's doesn't (see StaticPartyService.onItemCompleted).
app.post('/api/static-parties/:staticId/catch-up', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
const mode = req.body?.mode === 'party' ? 'party' : 'solo';
const requested = new Set(Array.isArray(req.body?.itemIds) ? req.body.itemIds.filter((id: unknown) => typeof id === 'string') : []);
const queue = sp.items.filter((i) => requested.has(i.itemId)); // the static party's own order, never the caller's
if (!queue.length) {
res.status(400).json({ error: 'Aucun élément à rattraper.' });
return;
}
const first = queue[0]!;
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'catchUp' };
party.hiddenFromLobby = mode === 'solo';
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
Logger.staticParty(`catch-up started id=${sp.id} mode=${mode} partyId=${party.id} items=${queue.length} by=${Logger.who(session.userId, session.userName)}`);
if (!party.hiddenFromLobby) SocketGateway.broadcastLobby(io);
res.json({ partyId: party.id });
});
// 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);
// Shorter idle window than parties themselves: a burn-in session holds a
// real ffmpeg process + disk, not just an in-memory object, so a paused/
// abandoned one is worth reclaiming sooner. Mostly a backstop - the
// normal path is PartyService.leave's destroy branch ending it immediately.
setInterval(() => TranscodeService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
// Same reasoning as TranscodeService's own reaper just above - a cached
// negotiation is mostly harmless to keep around, but there's no reason
// to hold one for a party nobody's actively streaming from any more.
// Mostly a backstop too - the normal path is PartyService.leave's
// destroy branch (and an item/track change) ending it immediately.
setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
// Pushes every connected member's own resume position to Emby (their own
// UserData for the item, under their own account) - see
// docs/architecture.md's "Sync watch-party progress back to Emby"
// section for the full history of why this writes UserData directly
// rather than Emby's PlayingItems/Progress check-in endpoint. Best-effort:
// PlaystateService.updatePosition swallows its own failures, so one bad
// token or a transient Emby hiccup never stops the rest of this sweep.
// Marking an item Played (once the party moves past it) is handled
// separately, at the source of that transition - see
// PartyService.advanceQueue and MusicQueueService.playNext.
//
// Also where EmbySessionService's own start/progress/stop lifecycle is
// driven from, rather than scattered across every queue-advance/track-
// skip call site the way markPlayed needed to be - this loop already
// visits every connected member on every tick, so it's already in
// exactly the right place to notice "this member's current item just
// changed" (member.embySession.itemId no longer matches) and react:
// stop the old session if there was one, negotiate a fresh
// MediaSourceId/PlaySessionId under their own account, and start the
// new one. Same item as last tick just reports Progress with the
// already-cached ids - no renegotiation needed for that, the common case.
setInterval(() => {
for (const party of PartyService.listActive()) {
const state = PartyService.getCurrentPlaybackState(party);
if (!state) continue; // nothing currently playing (e.g. an empty music queue)
// A static watch party's group session: remember where the group is,
// so the next session resumes there (no-op for any other party).
if (party.staticLink) StaticPartyService.recordPosition(party.staticLink, state.itemId, state.positionTicks);
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
if (member.embySession && member.embySession.itemId === state.itemId) {
const s = member.embySession;
EmbySessionService.progress(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, state.positionTicks, state.isPaused);
continue;
}
// The item just changed for them (or this is the first tick since
// they started watching/listening) - stop whatever they had open
// before, if anything, then negotiate and start a fresh one.
const previous = member.embySession;
const embyToken = member.embyToken;
member.embySession = null;
if (previous) {
EmbySessionService.stop(embyToken, previous.itemId, previous.mediaSourceId, previous.playSessionId, member.positionTicks);
}
// CatalogService's own methods all take a full EmbySession, for
// consistency with every live user-facing route that already has
// one from requireSession - this sweep only ever has a member's
// bare stored token, same situation PlaystateService/
// EmbySessionService are already in. serverId/createdUtc are safe
// to fake: negotiatePlaybackSession (like every per-member Emby
// write in this app) never reads either, only embyToken/userId.
const fakeSession: EmbySession = { embyToken, userId: member.userId, userName: member.userName, serverId: '', createdUtc: 0 };
CatalogService.negotiatePlaybackSession(fakeSession, state.itemId).then((negotiated) => {
if (!negotiated) return;
member.embySession = { itemId: state.itemId, mediaSourceId: negotiated.mediaSourceId, playSessionId: negotiated.playSessionId };
EmbySessionService.start(embyToken, state.itemId, negotiated.mediaSourceId, negotiated.playSessionId, state.positionTicks, state.isPaused);
});
}
}
}, 20 * 1000);
// A pending (debounced) static-party save must reach disk before the
// process exits - `docker stop` sends SIGTERM, Ctrl+C sends SIGINT.
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
Logger.staticParty(`${signal} received - flushing before exit`);
StaticPartyStore.flushSync();
process.exit(0);
});
}
server.listen(PORT, () => {
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
});