'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
/