'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 { EmbySessionService } from './services/EmbySessionService'; 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) { 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 /