Reported again after the castToken-propagation fix: "still doesn't work, the stream doesn't play on the TV side." That fix was real, but addressed what would break after the first segment - this was more fundamental, blocking things from the very first request. The Default Media Receiver plays this app's HLS through an MSE-based player (Chromecast's Chromium build has no native HLS support), which fetches every segment via fetch/XHR from whatever origin Google hosts the receiver at - cross-origin from this server. This app never sent a single CORS header anywhere; every consumer until now was its own same-origin page. Without one, the browser blocks every one of those reads outright, before this app's own cookie/castToken check even runs - the master playlist request itself included. Fixed with a small allowCastCors middleware on the same 4 streaming routes sessionForStream already covers - Access-Control-Allow-Origin: *, plus handling OPTIONS for the same 4 paths (Range, needed for segment fetches, isn't CORS-safelisted, so a real cross-origin request carrying one triggers a preflight this app had no answer for at all). `*` is safe here specifically because these routes never need credentialed cross-origin access: a real browser already reaches them same-origin, and the one genuine cross-origin caller authenticates via the castToken query param, never a cookie. Verified: full typecheck/test/build (122/122). Confirmed against the real running server that an OPTIONS preflight now answers 204 with the right headers, a real GET still carries Access-Control-Allow-Origin regardless of outcome, and an unrelated route carries no such header - scoped to exactly the 4 routes a Chromecast needs. Not verified: whether this is sufficient for a real receiver to actually play the stream end to end - no device reachable from this sandbox. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
622 lines
29 KiB
TypeScript
622 lines
29 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 { 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 { CastTokenService } from './services/CastTokenService';
|
|
import { SocketGateway } from './realtime/SocketGateway';
|
|
import type { ClientToServerEvents, EmbySession, 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;
|
|
}
|
|
|
|
/**
|
|
* 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')));
|
|
}));
|
|
|
|
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 watch 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 watch 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 watch 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 watch 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;
|
|
}
|
|
|
|
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,
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 first = items[0]!;
|
|
const party = PartyService.create(
|
|
session.userId, session.userName,
|
|
first.Id, first.Name, imageUrlFor(first),
|
|
null, mediaType
|
|
);
|
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
|
// Same mechanism POST /api/parties/:id/tracks already uses (see
|
|
// below) - init seeds+auto-starts the first track, addTrack queues
|
|
// the rest, exactly as if they'd been added one at a time.
|
|
items.forEach((track, i) => {
|
|
const trackInput = {
|
|
itemId: track.Id,
|
|
itemName: track.Name,
|
|
artistName: track.AlbumArtist || track.Artists?.[0] || null,
|
|
albumName: track.Album || null,
|
|
itemImageUrl: imageUrlFor(track),
|
|
durationTicks: track.RunTimeTicks || null
|
|
};
|
|
if (i === 0) {
|
|
MusicQueueService.init(party, trackInput, session.userId, session.userName);
|
|
} else {
|
|
MusicQueueService.addTrack(party, trackInput, session.userId, session.userName);
|
|
}
|
|
});
|
|
SocketGateway.broadcastLobby(io);
|
|
res.json(PartyService.toPublicState(party, session.userId));
|
|
return;
|
|
}
|
|
|
|
const queue: QueueItem[] = items.map((e) => ({
|
|
itemId: e.Id,
|
|
itemName: e.Type === 'Episode' ? formatEpisodeName(e) : e.Name,
|
|
itemImageUrl: `/api/catalog/image/${e.Id}`
|
|
}));
|
|
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));
|
|
}
|
|
|
|
// 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);
|
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
|
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));
|
|
});
|
|
|
|
// 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 watch 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 });
|
|
});
|
|
|
|
// 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.
|
|
setInterval(() => {
|
|
for (const party of PartyService.listActive()) {
|
|
const state = PartyService.getCurrentPlaybackState(party);
|
|
if (!state) continue; // nothing currently playing (e.g. an empty music queue)
|
|
for (const member of party.members.values()) {
|
|
if (!member.connection || !member.embyToken) continue;
|
|
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
|
|
}
|
|
}
|
|
}, 20 * 1000);
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
|
});
|