'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, 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 { 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; } // --- 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 /