'use strict'; const path = require('path'); const http = require('http'); const express = require('express'); const { Server: SocketIoServer } = require('socket.io'); const { EMBY_SERVER_URL, SESSION_COOKIE, SESSION_TTL_MS, login, getSession, endSession, parseCookies, sessionFromRequest } = require('./embyAuth'); const { embyFetch, proxyEmbyResponse } = require('./embyProxy'); const { PartyManager } = require('./partyManager'); const { MusicQueueService } = require('./musicQueueService'); const { attachSocketServer, broadcastLobby, applyLeave } = require('./socketHandler'); const { streamLog } = require('./logger'); 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. app.use(express.static(path.join(__dirname, '..', '..', 'client'))); function requireSession(req, res, next) { const session = sessionFromRequest(req); if (!session) { res.status(401).json({ error: 'Session expirée, reconnecte-toi.' }); return; } req.embySession = session; next(); } // --- 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', 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 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: getSession(sessionId).userName }); }); app.post('/api/auth/logout', (req, res) => { const cookies = parseCookies(req.headers.cookie); if (cookies[SESSION_COOKIE]) { 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) -------- app.get('/api/catalog/search', requireSession, async (req, res) => { const q = (req.query.q || '').trim(); if (q.length < 2) { res.json({ items: [] }); return; } const params = new URLSearchParams({ searchTerm: q, IncludeItemTypes: 'Movie,Episode,Series', Recursive: 'true', Fields: 'Overview,ProductionYear', ImageTypeLimit: '1', Limit: '24' }); const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken); if (!embyRes.ok) { res.status(502).json({ error: 'Recherche impossible côté Emby.' }); return; } const data = await embyRes.json(); res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) }); }); app.get('/api/catalog/item/:itemId', requireSession, async (req, res) => { const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${req.params.itemId}`, req.embySession.embyToken); if (!embyRes.ok) { res.status(embyRes.status).json({ error: 'Titre introuvable.' }); return; } res.json(toItemDto(await embyRes.json(), req.embySession.serverId)); }); // Emby's /Views can return all sorts of things beyond actual media // libraries - boxset collections, playlists, live TV, photos, books, a // mixed-content folder with no CollectionType at all - none of which this // app has any UI for. Only these four are ever shown, in the sidebar or // anywhere else: everything else is dropped here, at the source, so every // consumer (shell.js's sidebar, home.js's carousels, direct navigation to // /library/:id) is filtered the same way without each having to repeat the // same check. const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos']); // One entry per library, shown in the sidebar - see client/js/shell.js. app.get('/api/catalog/libraries', requireSession, async (req, res) => { const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Views`, req.embySession.embyToken); if (!embyRes.ok) { res.status(502).json({ error: 'Impossible de récupérer les bibliothèques.' }); return; } const data = await embyRes.json(); res.json({ libraries: (data.Items || []) .filter((v) => VISIBLE_COLLECTION_TYPES.has(v.CollectionType)) .map((v) => ({ id: v.Id, name: v.Name, // "movies" / "tvshows" / "music" / "musicvideos" - drives which // browsing experience client/js/views/library.js renders. See // docs/architecture.md. collectionType: v.CollectionType })) }); }); // Home page carousel: Emby's own "recently added" endpoint, one row per library. app.get('/api/catalog/libraries/:libraryId/recent', requireSession, async (req, res) => { const params = new URLSearchParams({ ParentId: req.params.libraryId, Limit: '16', Fields: 'ProductionYear' }); const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/Latest?${params}`, req.embySession.embyToken); if (!embyRes.ok) { res.status(502).json({ error: 'Impossible de récupérer les ajouts récents.' }); return; } const data = await embyRes.json(); // Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}. const items = Array.isArray(data) ? data : (data.Items || []); res.json({ items: items.map((i) => toItemDto(i, req.embySession.serverId)) }); }); // Series -> Seasons -> Episodes, for the "Contenus vidéo en série" browsing page. app.get('/api/catalog/series/:seriesId/seasons', requireSession, async (req, res) => { const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Seasons?userId=${req.embySession.userId}`, req.embySession.embyToken); if (!embyRes.ok) { res.status(502).json({ error: 'Impossible de récupérer les saisons.' }); return; } const data = await embyRes.json(); res.json({ seasons: (data.Items || []).map((s) => ({ id: s.Id, name: s.Name, indexNumber: s.IndexNumber })) }); }); app.get('/api/catalog/series/:seriesId/episodes', requireSession, async (req, res) => { const params = new URLSearchParams({ userId: req.embySession.userId, Fields: 'Overview' }); if (req.query.seasonId) params.set('SeasonId', req.query.seasonId); const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Episodes?${params}`, req.embySession.embyToken); if (!embyRes.ok) { res.status(502).json({ error: 'Impossible de récupérer les épisodes.' }); return; } const data = await embyRes.json(); res.json({ episodes: (data.Items || []).map((e) => toItemDto(e, req.embySession.serverId)) }); }); // Library -> Artists -> Albums -> Tracks, for the "Musique" browsing pages // (client/js/views/library.js, artist.js, album.js). Each its own Emby // endpoint/query rather than guessing folder structure, same reasoning as // Shows -> Seasons -> Episodes above. app.get('/api/catalog/libraries/:libraryId/artists', requireSession, async (req, res) => { const params = new URLSearchParams({ ParentId: req.params.libraryId, UserId: req.embySession.userId, SortBy: 'SortName' }); const embyRes = await embyFetch(`/Artists?${params}`, req.embySession.embyToken); if (!embyRes.ok) { res.status(502).json({ error: 'Impossible de récupérer les artistes.' }); return; } const data = await embyRes.json(); res.json({ artists: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) }); }); app.get('/api/catalog/artists/:artistId/albums', requireSession, async (req, res) => { const params = new URLSearchParams({ ArtistIds: req.params.artistId, UserId: req.embySession.userId, IncludeItemTypes: 'MusicAlbum', Recursive: 'true', SortBy: 'ProductionYear,SortName', Fields: 'ProductionYear' }); const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken); if (!embyRes.ok) { res.status(502).json({ error: 'Impossible de récupérer les albums.' }); return; } const data = await embyRes.json(); res.json({ albums: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) }); }); app.get('/api/catalog/albums/:albumId/tracks', requireSession, async (req, res) => { const params = new URLSearchParams({ ParentId: req.params.albumId, UserId: req.embySession.userId, IncludeItemTypes: 'Audio', SortBy: 'IndexNumber', Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist' }); const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken); if (!embyRes.ok) { res.status(502).json({ error: 'Impossible de récupérer les morceaux.' }); return; } const data = await embyRes.json(); res.json({ tracks: (data.Items || []).map((t) => toItemDto(t, req.embySession.serverId)) }); }); // 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, async (req, res) => { const infoRes = await embyFetch(`/Items/${req.params.itemId}/PlaybackInfo?UserId=${req.embySession.userId}`, req.embySession.embyToken); if (!infoRes.ok) { res.status(502).json({ error: 'Impossible de lire les options de lecture.' }); return; } const info = await infoRes.json(); const source = (info.MediaSources || [])[0]; const streams = (source && source.MediaStreams) || []; res.json({ audioTracks: streams .filter((s) => s.Type === 'Audio') .map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` })), subtitleTracks: streams .filter((s) => s.Type === 'Subtitle') .map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` })) }); }); app.get('/api/catalog/libraries/:libraryId/items', requireSession, async (req, res) => { const params = new URLSearchParams({ ParentId: req.params.libraryId, Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode) SortBy: 'SortName', Fields: 'Overview,ProductionYear', ImageTypeLimit: '1', Limit: '300' }); const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken); if (!embyRes.ok) { res.status(502).json({ error: 'Impossible de récupérer le contenu de cette bibliothèque.' }); return; } const data = await embyRes.json(); res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) }); }); // Images/video are requested by /