All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
The last deferred piece from the original spec. Chosen permission model (asked, not assumed): anyone can add a track to the shared queue any time; anyone can pause/resume directly; skipping the current track needs a strict majority vote of currently connected members. No host for playback - a genuinely different shape of shared state than video's "host drives, everyone follows", which is why it's a separate service (server/src/musicQueueService.js, MusicQueueService) rather than more branches in partyManager.js/socketHandler.js's existing video-shaped logic. Server: - musicQueueService.js: owns a music party's queue/current-track/pause/ vote-skip state. Server-authoritative timing (no client is "the host" reporting position) - a wall-clock startedUtc plus the track's known duration is the only state; auto-advance is one setTimeout scheduled for exactly when that runs out, not any client reporting "it ended" (which could race across several players finishing near-simultaneously with no host to arbitrate). attachIo(io, onTrackChanged), called once at startup like PartyManager's own wiring, lets it broadcast "queueUpdated" from inside that timer's callback, which has no socket/request in scope at all. - The timer is the one new leak surface this adds, given how much this project has already had to fix real leaks - destroy(party) clears it, wired into both of partyManager.js's existing destruction paths (leave's last-member case, removeIdleParties' sweep). - Party gained mediaType: 'video'|'music' (inferred server-side from the clicked item's Emby Type - the client never has to know or say which). - Three new catalog endpoints (Artists -> Albums -> Tracks), same reasoning as the existing Shows -> Seasons -> Episodes: a real Emby endpoint per level, not generic recursive /Items guessing. - Audio streams through the *same* /master.m3u8 route as video (Emby's PlaybackInfo negotiation isn't video-specific) - the one difference is the hand-built-fallback path prefix (/Audio/ vs /Videos/), which the client tags via a mediaType=audio query hint rather than costing an extra Emby round trip to detect server-side. - Adding a track is REST (POST /api/parties/:partyId/tracks), not a socket event like voteSkip/musicPlaystate - it needs to work from the player's inline browse-and-add panel regardless of realtime-connection specifics, and MusicQueueService's own broadcast reaches everyone connected either way. Client: - player.js's renderPartyPlayer is now a small dispatcher: fetches the party once, then delegates to renderVideoPlayer or the new renderMusicPlayer (views/musicPlayer.js) based on mediaType. Can't be async despite awaiting that fetch - route()'s contract needs its cleanup function back synchronously (router.js's `typeof cleanup === 'function'` check would silently skip a Promise, reintroducing the exact leak class already fixed once) - solved with a mutable box returned immediately, filled in once the fetch resolves. - musicPlayer.js: not full-viewport/theater-mode like the video player (no image to declutter around, sidebar stays up) - which is what makes its own inline Artist -> Album -> Track browser work for "add tracks while listening" without navigating away and losing player state or threading party context through query params across three route levels. - New views/artist.js, views/album.js (library.js's music branch renders an artist grid instead of the old placeholder). tiles.js's DRILL_DOWN table generalizes the old single seriesHref special-case now that there are three drill-down targets, not two. - party.js: addTrackToQueue (REST) alongside voteSkip/setMusicPaused (socket) on the existing connectParty() connection object. Real bug found while testing, not just the mock's fault: applyMusicState used to update the queue list/vote button *after* the current-track branch, which calls loadTrack() - and loadTrack()'s hls.js setup can throw. Since JS doesn't roll back earlier statements when a later one throws but does abort everything after it, an exception there took the unrelated queue list and vote tally down with it, and on the first render escaped all the way to the dispatcher's catch, which alerts and bounces back to /home - discarding the whole view over what's really just "this one track's audio failed to load". Fixed by reordering (queue/vote/icon updates - which can't fail - before the track-loading branch, mirroring how player.js's onItemChanged already updates title/next-button before calling loadVideo) and wrapping loadTrack's hls.js setup in its own try/catch so a failure there logs and stops instead of propagating. Verified server-side directly against the real shipped modules (not reimplemented mocks): 18 checks against musicQueueService.js alone (auto -start, queueing, vote-skip majority, pause/resume elapsed-time math, a real setTimeout auto-advance, timer cleanup on destroy - instrumented setTimeout/clearTimeout counting, same technique as the earlier client -side leak tests) and 7 more through socketHandler.js + partyManager.js together (no-ops on a video party, a non-host pausing/resuming, a two-vote majority skip, the resulting broadcast really reaching the room). Client-side against the real local server with mocked fetch/Hls/ io: the full browsing chain, party creation, the dispatcher choosing the right player (and still choosing video when mediaType is absent - back -compat), now-playing/vote-tally/queue rendering and updates including a live track change, play/pause and vote-skip emitting the right events, the inline browse-and-add panel POSTing the right payload, and the crash fix re-confirmed fixed via the same broken scenario that first found it. Not verified: the real Emby round trip for any of this (Artists/albums -tracks response shapes, audio PlaybackInfo/TranscodingUrl behavior, actual playback quality) - same limit as everything else in this project tested without live Emby access. docs/architecture.md has the full writeup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
623 lines
27 KiB
JavaScript
623 lines
27 KiB
JavaScript
'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 <img>/<video> elements, which send the
|
|
// session cookie (same-origin) but can't carry a bearer header - so these
|
|
// two 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', async (req, res) => {
|
|
const session = sessionFromRequest(req);
|
|
if (!session) { res.status(401).end(); return; }
|
|
await proxyEmbyResponse(req, res, `/Items/${req.params.itemId}/Images/Primary?maxWidth=400`, session.embyToken, req.params.itemId);
|
|
});
|
|
|
|
app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
|
|
const session = sessionFromRequest(req);
|
|
if (!session) { res.status(401).end(); return; }
|
|
const itemId = req.params.itemId;
|
|
|
|
// Ask Emby how it wants this played rather than hand-building a
|
|
// /Videos/{id}/master.m3u8 URL ourselves: the first version did that with
|
|
// only VideoCodec/AudioCodec, and Emby's server threw "Value cannot be
|
|
// null (Parameter 'key')" - almost certainly an internal dictionary
|
|
// lookup (MediaSourceId/PlaySessionId/DeviceId) that URL never supplied.
|
|
// PlaybackInfo returns a MediaSource with its own ready-to-use
|
|
// TranscodingUrl carrying whatever Emby actually needs.
|
|
//
|
|
// AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate (and
|
|
// SubtitleMethod=Encode, forced by applyPlaybackOverrides whenever a
|
|
// subtitle is requested - see its own comment below) come from the
|
|
// player's language/subtitle/quality pickers, applied to the negotiation
|
|
// itself, not just the final URL. That distinction matters here: whether
|
|
// to actually re-encode the video (required to burn in a subtitle) is a
|
|
// decision Emby makes *during* PlaybackInfo, not something a later
|
|
// query-string tweak on the resulting URL can retroactively change if
|
|
// Emby already set up a plain remux. Still not trusted to have taken
|
|
// effect on its own, though (this app never sends the
|
|
// POST-with-DeviceProfile body real Emby web clients use for that
|
|
// negotiation, so there's no guarantee a plain GET applies it the same
|
|
// way) - applyPlaybackOverrides is applied a second time below, to
|
|
// whatever URL we end up with, so a track pick can't silently no-op
|
|
// regardless of which path Emby's response takes.
|
|
const infoParams = new URLSearchParams({ UserId: session.userId });
|
|
applyPlaybackOverrides(infoParams, req.query);
|
|
const infoRes = await embyFetch(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session.embyToken);
|
|
if (!infoRes.ok) {
|
|
res.status(502).json({ error: 'Impossible de préparer la lecture de ce titre.' });
|
|
return;
|
|
}
|
|
const info = await infoRes.json();
|
|
const source = (info.MediaSources || [])[0];
|
|
if (!source) {
|
|
res.status(502).json({ error: 'Aucune source de lecture disponible pour ce titre.' });
|
|
return;
|
|
}
|
|
|
|
// TODO(verify): TranscodingUrl not confirmed present for every source
|
|
// type/Emby version (e.g. a source Emby considers direct-playable may not
|
|
// have one at all) - the hand-built fallback below now at least supplies
|
|
// the parameters PlaybackInfo reveals (MediaSourceId, PlaySessionId,
|
|
// UserId) instead of guessing, but is still a fallback, not the primary path.
|
|
// Music tracks stream through this exact route too (same PlaybackInfo ->
|
|
// TranscodingUrl dance - Emby's negotiation isn't video-specific) - the
|
|
// only place that actually differs is the hand-built fallback path,
|
|
// which is genuinely different between the two (/Videos/ vs /Audio/) and
|
|
// has no way to know which without being told: PlaybackInfo's own
|
|
// response doesn't reliably carry the item's Type back, and looking it
|
|
// up would be an extra Emby round trip for what the client already
|
|
// knows. So the client passes mediaType=audio (see streamUrl() in
|
|
// embyApi.js) - cosmetic in every other sense, this is the one place it
|
|
// actually changes what gets requested.
|
|
const isAudio = req.query.mediaType === 'audio';
|
|
let target;
|
|
if (source.TranscodingUrl) {
|
|
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
|
|
const params = new URLSearchParams(urlQuery || '');
|
|
applyPlaybackOverrides(params, req.query);
|
|
target = `${urlPath}?${params}`;
|
|
logStreamRequest(itemId, 'negotiated', params);
|
|
} else {
|
|
const params = new URLSearchParams({
|
|
UserId: session.userId,
|
|
MediaSourceId: source.Id || '',
|
|
PlaySessionId: info.PlaySessionId || '',
|
|
AudioCodec: 'aac',
|
|
...(isAudio ? {} : { VideoCodec: 'h264' })
|
|
});
|
|
applyPlaybackOverrides(params, req.query);
|
|
target = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
|
|
logStreamRequest(itemId, 'fallback', params);
|
|
}
|
|
|
|
await proxyEmbyResponse(req, res, target, session.embyToken, itemId);
|
|
});
|
|
|
|
// Deliberately logs only these specific override values, never the full
|
|
// resolved URL/params blob - Emby's own TranscodingUrl can carry an
|
|
// api_key, and that has no business ending up in a log file. Added
|
|
// alongside the language/subtitle fix above so it's actually possible to
|
|
// confirm from docker logs/journalctl whether a requested override made it
|
|
// into the request Emby received, without a devtools session on whatever
|
|
// machine is running the server.
|
|
function logStreamRequest(itemId, source, params) {
|
|
streamLog(
|
|
`master.m3u8 itemId=${itemId} source=${source} ` +
|
|
`audio=${params.get('AudioStreamIndex') || '(default)'} ` +
|
|
`subtitle=${params.get('SubtitleStreamIndex') || '(none)'} ` +
|
|
`subtitleMethod=${params.get('SubtitleMethod') || '(none)'} ` +
|
|
`quality=${params.get('MaxStreamingBitrate') || '(auto)'}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Forces this app's own audio/subtitle/quality selection onto a transcode
|
|
* URL, whether that URL came from Emby's own PlaybackInfo negotiation or
|
|
* was hand-built as a fallback - see the route above. Without this, a pick
|
|
* from the player's settings menu could silently do nothing if Emby's
|
|
* negotiation didn't apply the query-string override the same way its own
|
|
* web client's POST-with-DeviceProfile request would have.
|
|
*
|
|
* SubtitleMethod=Encode is forced whenever a subtitle track is requested:
|
|
* left to its own default, Emby may instead deliver text subtitles as a
|
|
* separate selectable HLS rendition (SubtitleMethod=Hls) rather than
|
|
* burning them into the video - and this player never wires up hls.js's
|
|
* subtitleTrack API to pick one, so a rendition-based subtitle would be
|
|
* present in the manifest but never actually shown. Encode guarantees the
|
|
* subtitle is part of the one video stream this player already renders.
|
|
* Picking "Aucun" removes any subtitle Emby's own negotiation added, rather
|
|
* than just not adding a new one - otherwise turning subtitles off from a
|
|
* state where Emby had already picked a default would be a no-op too.
|
|
*/
|
|
function applyPlaybackOverrides(params, query) {
|
|
if (query.AudioStreamIndex) params.set('AudioStreamIndex', query.AudioStreamIndex);
|
|
if (query.SubtitleStreamIndex) {
|
|
params.set('SubtitleStreamIndex', query.SubtitleStreamIndex);
|
|
params.set('SubtitleMethod', 'Encode');
|
|
} else {
|
|
params.delete('SubtitleStreamIndex');
|
|
params.delete('SubtitleMethod');
|
|
}
|
|
if (query.MaxStreamingBitrate) params.set('MaxStreamingBitrate', query.MaxStreamingBitrate);
|
|
}
|
|
|
|
// Everything an HLS playlist references (variant playlists, segments) comes
|
|
// back through here - see embyProxy.rewritePlaylist.
|
|
app.get('/api/catalog/stream/:itemId/raw', async (req, res) => {
|
|
const session = sessionFromRequest(req);
|
|
if (!session) { res.status(401).end(); return; }
|
|
|
|
const target = req.query.u || '';
|
|
const itemId = req.params.itemId;
|
|
let pathAndQuery;
|
|
if (/^https?:\/\//i.test(target)) {
|
|
if (!target.startsWith(EMBY_SERVER_URL)) {
|
|
res.status(400).end(); // never proxy an arbitrary URL, only our own Emby server
|
|
return;
|
|
}
|
|
pathAndQuery = target;
|
|
} else {
|
|
pathAndQuery = `/Videos/${itemId}/${target}`;
|
|
}
|
|
await proxyEmbyResponse(req, res, pathAndQuery, session.embyToken, itemId);
|
|
});
|
|
|
|
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
|
// contents (distinct from the generic 'Video', which is home videos) -
|
|
// needed now that musicvideos is one of the visible library types (see
|
|
// VISIBLE_COLLECTION_TYPES above), otherwise every tile in it would render
|
|
// with no "Lancer une watch party" button at all. 'Audio' is a music track.
|
|
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
|
|
|
|
function toItemDto(item, serverId) {
|
|
return {
|
|
id: item.Id,
|
|
name: item.Name,
|
|
overview: item.Overview,
|
|
year: item.ProductionYear,
|
|
type: item.Type,
|
|
// Series/Season/MusicArtist/MusicAlbum aren't directly playable - only
|
|
// show "start a party" for things Emby can actually stream. "Voir sur
|
|
// Emby" always works though.
|
|
canWatchParty: WATCH_PARTY_TYPES.has(item.Type),
|
|
imageUrl: `/api/catalog/image/${item.Id}`,
|
|
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
|
|
// Track-specific (undefined/null for anything else - harmless, every
|
|
// consumer that cares about these only reads them for an Audio item).
|
|
durationTicks: item.RunTimeTicks || null,
|
|
indexNumber: item.IndexNumber != null ? item.IndexNumber : null,
|
|
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
|
|
albumName: item.Album || null
|
|
};
|
|
}
|
|
|
|
// --- Parties ---------------------------------------------------------------
|
|
// No REST "list parties" endpoint: the home page's "watch parties en cours"
|
|
// list is live, over Socket.IO's "lobby" room (socketHandler.js) - see
|
|
// docs/architecture.md. No join codes either - see partyManager.js's class
|
|
// comment for why a live list makes one redundant.
|
|
|
|
app.post('/api/parties', requireSession, async (req, res) => {
|
|
const itemId = req.body && req.body.itemId;
|
|
if (!itemId) {
|
|
res.status(400).json({ error: 'itemId is required.' });
|
|
return;
|
|
}
|
|
|
|
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${itemId}`, req.embySession.embyToken);
|
|
if (!embyRes.ok) {
|
|
res.status(404).json({ error: 'Titre introuvable.' });
|
|
return;
|
|
}
|
|
const item = await embyRes.json();
|
|
const 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 socketHandler.js) 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 = null;
|
|
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
|
|
const epsRes = await embyFetch(
|
|
`/Shows/${item.SeriesId}/Episodes?userId=${req.embySession.userId}&Fields=Overview`,
|
|
req.embySession.embyToken
|
|
);
|
|
if (epsRes.ok) {
|
|
const epsData = await epsRes.json();
|
|
const all = epsData.Items || [];
|
|
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 = PartyManager.create(
|
|
req.embySession.userId,
|
|
req.embySession.userName,
|
|
itemId,
|
|
queue ? queue[0].itemName : item.Name,
|
|
`/api/catalog/image/${itemId}`,
|
|
queue,
|
|
mediaType
|
|
);
|
|
|
|
if (mediaType === 'music') {
|
|
MusicQueueService.init(party, {
|
|
itemId,
|
|
itemName: item.Name,
|
|
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
|
|
albumName: item.Album || null,
|
|
itemImageUrl: `/api/catalog/image/${itemId}`,
|
|
durationTicks: item.RunTimeTicks || null
|
|
}, req.embySession.userId, req.embySession.userName);
|
|
}
|
|
|
|
broadcastLobby(io); // the new party shows up on everyone's home page immediately
|
|
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
|
});
|
|
|
|
function formatEpisodeName(episode) {
|
|
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;
|
|
}
|
|
|
|
// Adding a track to a music party's shared queue is REST, not a socket
|
|
// event (see socketHandler.js's top comment): the album-browsing page
|
|
// (client/js/views/album.js) that this is actually called from has no live
|
|
// connection to the party it's adding into - only the player view
|
|
// (musicPlayer.js) 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 = PartyManager.getById(req.params.partyId);
|
|
if (!party || party.mediaType !== 'music') {
|
|
res.status(404).json({ error: 'Watch party musique introuvable.' });
|
|
return;
|
|
}
|
|
if (!party.members.has(req.embySession.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, req.embySession.userId, req.embySession.userName);
|
|
broadcastLobby(io); // the queue/now-playing preview may have just changed
|
|
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
|
});
|
|
|
|
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
|
const party = PartyManager.getById(req.params.partyId);
|
|
if (!party) {
|
|
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
|
|
return;
|
|
}
|
|
PartyManager.join(party, req.embySession.userId, req.embySession.userName);
|
|
broadcastLobby(io); // member count changed - reflect it on the home page immediately
|
|
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
|
});
|
|
|
|
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
|
|
const party = PartyManager.getById(req.params.partyId);
|
|
if (party) {
|
|
// Shared with the socket "leave"/"disconnect" handlers so the host-
|
|
// handoff notification and the lobby refresh only live in one place.
|
|
applyLeave(io, party, req.embySession.userId);
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/parties/:partyId', requireSession, (req, res) => {
|
|
const party = PartyManager.getById(req.params.partyId);
|
|
if (!party) {
|
|
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
|
|
return;
|
|
}
|
|
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
|
});
|
|
|
|
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
|
|
// socketHandler.js's top comment for why that matters.
|
|
const io = new SocketIoServer(server);
|
|
attachSocketServer(io, { parseCookies, getSession });
|
|
|
|
// 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
|
|
// required directly from socketHandler.js/musicQueueService.js to avoid a
|
|
// circular require between the two.
|
|
MusicQueueService.attachIo(io, () => broadcastLobby(io));
|
|
|
|
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
|
|
|
|
const port = process.env.PORT || 3000;
|
|
server.listen(port, () => {
|
|
console.log(`[watchparty] listening on :${port} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
|
});
|