A watch party built from a whole series or video playlist that remembers the group's progress and each member's, so someone coming back or joining late sees the items they missed and can catch up (solo, as a catch-up watch party, or by marking items watched). - StaticPartyService + StaticPartyStore: first on-disk state (JSON file under DATA_DIR, atomic debounced writes, flushed on SIGTERM) - each session is an ordinary Party linked via party.staticLink; group position is saved by the 20s sweep and resumed on the next session - PartyService.completeCurrentItem centralizes "item done" (Emby Played + static progress); new host-only "itemEnded" socket event covers the last queued item, which was never marked Played before - missed = group-completed minus watched in-app minus Emby Played - /static/:id view, create buttons on series/playlist pages, home section - Docker volume for /data, spec/static-watch-party.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1005 lines
47 KiB
TypeScript
1005 lines
47 KiB
TypeScript
'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 { DATA_DIR, 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 { StaticPartyService } from './services/StaticPartyService';
|
|
import { StaticPartyStore } from './services/StaticPartyStore';
|
|
import { SocketGateway } from './realtime/SocketGateway';
|
|
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents, StaticParty } from './types';
|
|
|
|
StaticPartyStore.configure(DATA_DIR);
|
|
StaticPartyService.init();
|
|
|
|
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<void>) {
|
|
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 <img>/<video>/<audio>/
|
|
// <track> elements, which send the session cookie (same-origin) but can't
|
|
// carry a bearer header - so these four 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', asyncRoute(async (req, res) => {
|
|
const session = AuthService.sessionFromRequest(req);
|
|
if (!session) { res.status(401).end(); return; }
|
|
await CatalogService.proxyImage(req, res, session, param(req, 'itemId'));
|
|
}));
|
|
|
|
// The party's own shared Emby stream - one negotiated PlaybackInfo
|
|
// session reused by every member, instead of each viewer's own
|
|
// <video>/<audio> negotiating (and getting its own PlaySessionId)
|
|
// independently - see PartyStreamService and docs/architecture.md's
|
|
// "One shared Emby stream per party" section. Keyed by partyId, not
|
|
// itemId - the party object itself is the source of truth for what
|
|
// it's currently streaming and with which audio/quality choice, so
|
|
// there's nothing else a client could usefully put in this URL.
|
|
// requireSession's JSON error doesn't fit here either, same reasoning
|
|
// as the routes above (a <video>/<audio> element can't carry a bearer
|
|
// header).
|
|
app.get('/api/catalog/party-stream/:partyId/master.m3u8', allowCastCors, asyncRoute(async (req, res) => {
|
|
const partyId = param(req, 'partyId');
|
|
const session = sessionForStream(req, partyId);
|
|
if (!session) { res.status(401).end(); return; }
|
|
const party = PartyService.getById(partyId);
|
|
if (!party || !party.members.has(session.userId)) {
|
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
|
return;
|
|
}
|
|
await PartyStreamService.serveMasterPlaylist(req, res, party, session);
|
|
}));
|
|
|
|
// Everything an HLS playlist references (variant playlists, segments)
|
|
// comes back through here - see EmbyClient.rewritePlaylist.
|
|
app.get('/api/catalog/party-stream/:partyId/raw', allowCastCors, asyncRoute(async (req, res) => {
|
|
const partyId = param(req, 'partyId');
|
|
const session = sessionForStream(req, partyId);
|
|
if (!session) { res.status(401).end(); return; }
|
|
const party = PartyService.getById(partyId);
|
|
if (!party || !party.members.has(session.userId)) {
|
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
|
return;
|
|
}
|
|
await PartyStreamService.serveRawAsset(req, res, party, session);
|
|
}));
|
|
|
|
// A subtitle track's own text (WebVTT), for the player's native <track> -
|
|
// see CatalogService.proxySubtitle's own comment for why subtitles are
|
|
// rendered this way instead of asking Emby to burn them into the video.
|
|
app.get('/api/catalog/subtitle/:itemId/:subtitleIndex', asyncRoute(async (req, res) => {
|
|
const session = AuthService.sessionFromRequest(req);
|
|
if (!session) { res.status(401).end(); return; }
|
|
await CatalogService.proxySubtitle(req, res, session, param(req, 'itemId'), param(req, 'subtitleIndex'));
|
|
}));
|
|
|
|
// The party's own self-hosted-ffmpeg burn-in stream (PGS/image subtitles
|
|
// only - see TranscodeService and Party.burnInSubtitleIndex's own
|
|
// comment); every other party keeps streaming through the party-stream
|
|
// routes above instead - PartyStreamService, not our own ffmpeg. Keyed
|
|
// by partyId, not itemId, same reasoning as those routes: the party
|
|
// object itself is the source of truth for which audio/subtitle
|
|
// combination is currently burned in, so there's nothing else a client
|
|
// could usefully put in this URL. requireSession's JSON error doesn't
|
|
// fit here either, same reasoning as the routes above.
|
|
app.get('/api/catalog/burn/:partyId/master.m3u8', allowCastCors, asyncRoute(async (req, res) => {
|
|
const partyId = param(req, 'partyId');
|
|
const session = sessionForStream(req, partyId);
|
|
if (!session) { res.status(401).end(); return; }
|
|
const party = PartyService.getById(partyId);
|
|
if (!party || !party.members.has(session.userId)) {
|
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
|
return;
|
|
}
|
|
if (!party.burnInSubtitleIndex) {
|
|
res.status(409).json({ error: "Cette watch party n'a pas de flux incrusté actif." });
|
|
return;
|
|
}
|
|
await TranscodeService.serveMasterPlaylist(req, res, party, session);
|
|
}));
|
|
|
|
app.get('/api/catalog/burn/:partyId/:segment', allowCastCors, asyncRoute(async (req, res) => {
|
|
const partyId = param(req, 'partyId');
|
|
const session = sessionForStream(req, partyId);
|
|
if (!session) { res.status(401).end(); return; }
|
|
const party = PartyService.getById(partyId);
|
|
if (!party || !party.members.has(session.userId)) {
|
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
|
return;
|
|
}
|
|
await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
|
|
}));
|
|
|
|
// --- Parties ---------------------------------------------------------------
|
|
// No REST "list parties" endpoint: the home page's "watch parties en cours"
|
|
// list is live, over Socket.IO's "lobby" room (SocketGateway) - see
|
|
// docs/architecture.md. No join codes either - see PartyService's class
|
|
// comment for why a live list makes one redundant.
|
|
|
|
app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
|
const itemId = req.body?.itemId;
|
|
if (!itemId) {
|
|
res.status(400).json({ error: 'itemId is required.' });
|
|
return;
|
|
}
|
|
|
|
const session = req.embySession!;
|
|
const item = await CatalogService.getRawItem(session, itemId);
|
|
|
|
// A playlist itself was never something Emby could stream - launching
|
|
// one means queuing everything inside it instead, a separate enough
|
|
// shape (video queue vs. seeding the participative music queue several
|
|
// tracks at once) that it's its own function rather than folded into
|
|
// the single-item path below. See docs/architecture.md.
|
|
if (item.Type === 'Playlist') {
|
|
await createPartyFromPlaylist(res, session, itemId, req.body?.startAtItemId);
|
|
return;
|
|
}
|
|
|
|
// Neither is itself streamable (same reason a Playlist above isn't) -
|
|
// launching one queues every track inside it instead: every album for
|
|
// an artist, every track for an album, in Emby's own sort order (see
|
|
// resolveMusicTracksForItem). Asked for directly, alongside the same
|
|
// action on the party's own existing queue - see the "queue" route
|
|
// below - both share this resolution.
|
|
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
|
|
const tracks = await resolveMusicTracksForItem(session, item);
|
|
createMusicPartyFromTracks(res, session, tracks);
|
|
return;
|
|
}
|
|
|
|
const mediaType: 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 SocketGateway) 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: QueueItem[] | null = null;
|
|
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
|
|
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
|
|
const startIndex = all.findIndex((e) => e.Id === itemId);
|
|
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
|
|
queue = remaining.map(toVideoQueueItem);
|
|
}
|
|
|
|
const party = PartyService.create(
|
|
session.userId,
|
|
session.userName,
|
|
itemId,
|
|
queue ? queue[0]!.itemName : item.Name,
|
|
imageUrlFor(item),
|
|
queue,
|
|
mediaType
|
|
);
|
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
|
|
|
if (mediaType === 'music') {
|
|
MusicQueueService.init(party, {
|
|
itemId,
|
|
itemName: item.Name,
|
|
artistName: item.AlbumArtist || item.Artists?.[0] || null,
|
|
albumName: item.Album || null,
|
|
itemImageUrl: imageUrlFor(item),
|
|
durationTicks: item.RunTimeTicks || null
|
|
}, session.userId, session.userName);
|
|
}
|
|
|
|
SocketGateway.broadcastLobby(io); // the new party shows up on everyone's home page immediately
|
|
res.json(PartyService.toPublicState(party, session.userId));
|
|
}));
|
|
|
|
function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?: number; SeriesName?: string; Name: string }): string {
|
|
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;
|
|
}
|
|
|
|
/** One video item (episode or movie) as a queue entry - shared by every
|
|
* path that builds a video queue: an episode's series, a playlist, a
|
|
* static watch party. */
|
|
function toVideoQueueItem(item: EmbyItem): QueueItem {
|
|
return {
|
|
itemId: item.Id,
|
|
itemName: item.Type === 'Episode' ? formatEpisodeName(item) : item.Name,
|
|
itemImageUrl: `/api/catalog/image/${item.Id}`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The "launch this playlist" half of POST /api/parties above - kept
|
|
* separate rather than merged into the single-item path, which is
|
|
* already working/relied-upon and doesn't need touching for this.
|
|
* splitPlaylistByMediaType (CatalogService) decides video vs music from
|
|
* the playlist's first item and drops anything that doesn't match (a
|
|
* mixed playlist - not something this app's party model can represent).
|
|
*
|
|
* `startAtItemId` is views/playlist's "Lire à partir d'ici" - optional,
|
|
* and only meaningful once `items` (the playlist's own contents, already
|
|
* fetched here) is in hand: unlike an Episode's `SeriesId`, a playlist
|
|
* item carries no back-reference to whichever playlist it came from, so
|
|
* this can only ever be resolved against a specific, already-known
|
|
* playlist's own item list - never from a bare itemId alone the way
|
|
* views/series's per-episode buttons resolve straight through the
|
|
* single-item path above (`item.SeriesId`). Same slice-not-wrap
|
|
* semantics as that path too: everything before the chosen item is
|
|
* simply left out of this party, not queued for later.
|
|
*/
|
|
async function createPartyFromPlaylist(res: Response, session: EmbySession, playlistId: string, startAtItemId?: string): Promise<void> {
|
|
const raw = await CatalogService.getPlaylistItemsRaw(session, playlistId);
|
|
const { mediaType, items: allItems } = splitPlaylistByMediaType(raw);
|
|
if (!allItems.length) {
|
|
res.status(400).json({ error: 'Cette playlist est vide.' });
|
|
return;
|
|
}
|
|
if (allItems.length !== raw.length) {
|
|
Logger.party(`playlist ${playlistId} is mixed-media - keeping only the ${allItems.length}/${raw.length} ${mediaType} item(s)`);
|
|
}
|
|
|
|
const startIndex = startAtItemId ? allItems.findIndex((i) => i.Id === startAtItemId) : -1;
|
|
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
|
|
|
|
if (mediaType === 'music') {
|
|
const trackInputs: MusicTrackInput[] = items.map((track) => ({
|
|
itemId: track.Id,
|
|
itemName: track.Name,
|
|
artistName: track.AlbumArtist || track.Artists?.[0] || null,
|
|
albumName: track.Album || null,
|
|
itemImageUrl: imageUrlFor(track),
|
|
durationTicks: track.RunTimeTicks || null
|
|
}));
|
|
createMusicPartyFromTracks(res, session, trackInputs);
|
|
return;
|
|
}
|
|
|
|
const queue: QueueItem[] = items.map(toVideoQueueItem);
|
|
const party = PartyService.create(
|
|
session.userId, session.userName,
|
|
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
|
|
queue, mediaType
|
|
);
|
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
|
SocketGateway.broadcastLobby(io);
|
|
res.json(PartyService.toPublicState(party, session.userId));
|
|
}
|
|
|
|
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
|
|
* or MusicArtist - the three POST /api/parties and the "queue" route
|
|
* below ever call this for) resolved into a flat, ordered track list -
|
|
* a single track for Audio, every track in Emby's own IndexNumber order
|
|
* for an album, every album's tracks in Emby's own ProductionYear/
|
|
* SortName order for an artist's whole discography. Two Emby round
|
|
* trips for an artist's own albums+tracks would be one call each if
|
|
* this app ever needed to page through a huge discography - not
|
|
* attempted speculatively, since Emby has yet to show any sign of
|
|
* needing it for a personal library's worth of artists. */
|
|
async function resolveMusicTracksForItem(session: EmbySession, item: EmbyItem): Promise<MusicTrackInput[]> {
|
|
if (item.Type === 'MusicAlbum') {
|
|
const tracks = await CatalogService.getAlbumTracks(session, item.Id);
|
|
return tracks.map(itemDtoToTrackInput);
|
|
}
|
|
if (item.Type === 'MusicArtist') {
|
|
const albums = await CatalogService.getArtistAlbums(session, item.Id);
|
|
const perAlbum = await Promise.all(albums.map((album) => CatalogService.getAlbumTracks(session, album.id)));
|
|
return perAlbum.flat().map(itemDtoToTrackInput);
|
|
}
|
|
// A single Audio track - the only other type this is ever called for.
|
|
return [{
|
|
itemId: item.Id,
|
|
itemName: item.Name,
|
|
artistName: item.AlbumArtist || item.Artists?.[0] || null,
|
|
albumName: item.Album || null,
|
|
itemImageUrl: imageUrlFor(item),
|
|
durationTicks: item.RunTimeTicks || null
|
|
}];
|
|
}
|
|
|
|
function itemDtoToTrackInput(item: ItemDto): MusicTrackInput {
|
|
return {
|
|
itemId: item.id,
|
|
itemName: item.name,
|
|
artistName: item.artistName,
|
|
albumName: item.albumName,
|
|
itemImageUrl: item.imageUrl,
|
|
durationTicks: item.durationTicks
|
|
};
|
|
}
|
|
|
|
/** Shared by every "launch this as a listen party" path that can produce
|
|
* more than one track up front (an album, an artist's discography, a
|
|
* music playlist - see createPartyFromPlaylist above and the
|
|
* MusicAlbum/MusicArtist branch of POST /api/parties below) - seeds
|
|
* party.music with `tracks`, in order: the first auto-starts,
|
|
* everything else queues behind it via MusicQueueService.addTracks
|
|
* (one broadcast for however many tracks that is, not one per track). */
|
|
function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks: MusicTrackInput[]): void {
|
|
if (!tracks.length) {
|
|
res.status(400).json({ error: 'Aucun morceau à lire.' });
|
|
return;
|
|
}
|
|
const [first, ...rest] = tracks;
|
|
const party = PartyService.create(
|
|
session.userId, session.userName,
|
|
first!.itemId, first!.itemName, first!.itemImageUrl,
|
|
null, 'music'
|
|
);
|
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
|
MusicQueueService.init(party, first!, session.userId, session.userName);
|
|
MusicQueueService.addTracks(party, rest, session.userId, session.userName);
|
|
SocketGateway.broadcastLobby(io);
|
|
res.json(PartyService.toPublicState(party, session.userId));
|
|
}
|
|
|
|
// Adding a track to a music party's shared queue is REST, not a socket
|
|
// event (see SocketGateway's top comment): the album-browsing page that
|
|
// this is actually called from has no live connection to the party it's
|
|
// adding into - only the player view 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 = PartyService.getById(param(req, 'partyId'));
|
|
if (!party || party.mediaType !== 'music') {
|
|
res.status(404).json({ error: 'Watch party musique introuvable.' });
|
|
return;
|
|
}
|
|
const session = req.embySession!;
|
|
if (!party.members.has(session.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, session.userId, session.userName);
|
|
SocketGateway.broadcastLobby(io); // the queue/now-playing preview may have just changed
|
|
res.json(PartyService.toPublicState(party, session.userId));
|
|
});
|
|
|
|
// Same "queue this item" resolution as the MusicAlbum/MusicArtist branch
|
|
// of POST /api/parties above (a track queues itself; an album/artist
|
|
// expands into every track inside it - see resolveMusicTracksForItem),
|
|
// but adding into an ALREADY-LIVE party's queue instead of creating a
|
|
// new one - views/album's and views/artist's own "add to queue" buttons.
|
|
// Kept as its own route rather than overloading POST .../tracks above:
|
|
// that one takes an already-built track object the caller already has
|
|
// in hand (views/album's per-track buttons) - making it also guess
|
|
// whether it received that or a bare id-to-resolve would be more
|
|
// surprising than a second, clearly-named route.
|
|
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
|
|
const party = PartyService.getById(param(req, 'partyId'));
|
|
if (!party || party.mediaType !== 'music') {
|
|
res.status(404).json({ error: 'Watch party musique introuvable.' });
|
|
return;
|
|
}
|
|
const session = req.embySession!;
|
|
if (!party.members.has(session.userId)) {
|
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
|
return;
|
|
}
|
|
const itemId = req.body?.itemId;
|
|
if (!itemId) {
|
|
res.status(400).json({ error: 'itemId is required.' });
|
|
return;
|
|
}
|
|
const item = await CatalogService.getRawItem(session, itemId);
|
|
const tracks = await resolveMusicTracksForItem(session, item);
|
|
MusicQueueService.addTracks(party, tracks, session.userId, session.userName);
|
|
SocketGateway.broadcastLobby(io); // the queue/now-playing preview may have just changed
|
|
res.json(PartyService.toPublicState(party, session.userId));
|
|
}));
|
|
|
|
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
|
const party = PartyService.getById(param(req, 'partyId'));
|
|
if (!party) {
|
|
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
|
|
return;
|
|
}
|
|
const session = req.embySession!;
|
|
PartyService.join(party, session.userId, session.userName);
|
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
|
// Joining a static watch party's group session from the lobby makes you
|
|
// a member of that static party too - its progress is now yours.
|
|
if (party.staticLink?.mode === 'group') {
|
|
const sp = StaticPartyService.getById(party.staticLink.staticPartyId);
|
|
if (sp) StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
|
|
}
|
|
SocketGateway.broadcastLobby(io); // member count changed - reflect it on the home page immediately
|
|
res.json(PartyService.toPublicState(party, session.userId));
|
|
});
|
|
|
|
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
|
|
const party = PartyService.getById(param(req, 'partyId'));
|
|
if (party) {
|
|
const member = party.members.get(req.embySession!.userId);
|
|
const deviceId = typeof req.body?.deviceId === 'string' ? req.body.deviceId : null;
|
|
// See PartyService.isStaleDevice's own comment for why this check
|
|
// exists at all - a REST call has no live connection to guard this
|
|
// with, unlike the socket "leave"/"disconnect" handlers.
|
|
if (!PartyService.isStaleDevice(member, deviceId)) {
|
|
// Shared with the socket "leave"/"disconnect" handlers so the host-
|
|
// handoff notification and the lobby refresh only live in one place.
|
|
SocketGateway.applyLeave(io, party, req.embySession!.userId);
|
|
}
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/parties/:partyId', requireSession, (req, res) => {
|
|
const party = PartyService.getById(param(req, 'partyId'));
|
|
if (!party) {
|
|
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
|
|
return;
|
|
}
|
|
res.json(PartyService.toPublicState(party, req.embySession!.userId));
|
|
});
|
|
|
|
// Lets a member's Chromecast into the party's existing shared stream
|
|
// without ever handing it the sender's own session cookie (which a
|
|
// Chromecast can't carry) or the real Emby token - see CastTokenService
|
|
// and docs/architecture.md's "Casting to a Chromecast" section. Normal
|
|
// cookie auth here (requireSession) since this is called from the
|
|
// sender's own page, not the Chromecast itself.
|
|
app.post('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
|
|
const party = PartyService.getById(param(req, 'partyId'));
|
|
const session = req.embySession!;
|
|
if (!party || !party.members.has(session.userId)) {
|
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
|
return;
|
|
}
|
|
res.json({ token: CastTokenService.issue(party.id, session) });
|
|
});
|
|
|
|
// Called when the sender stops casting - see CastTokenService
|
|
// .revokeForMember's own comment for why this isn't the only way a
|
|
// token gets invalidated (leaving the party revokes it too, unconditionally).
|
|
app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
|
|
CastTokenService.revokeForMember(param(req, 'partyId'), req.embySession!.userId);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// --- Static (persistent) watch parties ------------------------------------
|
|
// A whole series/video playlist watched by a group over several sessions,
|
|
// remembering the group's progress and each member's - see
|
|
// StaticPartyService and spec/static-watch-party.md. Each session is an
|
|
// ordinary live party (PartyService) linked back through party.staticLink.
|
|
|
|
/** Fetches a static party's source (every episode of the series, or the
|
|
* playlist's video items) under `session`'s own account - so each item
|
|
* carries *their* Emby UserData (Played). */
|
|
async function fetchStaticSourceItems(session: EmbySession, source: StaticParty['source']): Promise<EmbyItem[]> {
|
|
if (source.type === 'series') {
|
|
return CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId);
|
|
}
|
|
const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId));
|
|
return mediaType === 'video' ? items : [];
|
|
}
|
|
|
|
/**
|
|
* Re-fetches `sp`'s source for `session`: merges any new item into it
|
|
* (StaticPartyService.refreshItems) and returns the ids Emby already has
|
|
* as Played for this user. Best-effort - Emby being unreachable must not
|
|
* make the page unusable, so a failure is logged and treated as "no
|
|
* Played info, no new items" (the in-app progress still works on its own).
|
|
*/
|
|
async function syncStaticSource(session: EmbySession, sp: StaticParty): Promise<Set<string>> {
|
|
try {
|
|
const raw = await fetchStaticSourceItems(session, sp.source);
|
|
StaticPartyService.refreshItems(sp, raw.map(toVideoQueueItem));
|
|
return new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
|
|
} catch (err) {
|
|
Logger.staticParty(`source sync failed id=${sp.id} user=${Logger.who(session.userId, session.userName)}: ${(err as Error).message}`);
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
/** `sp`'s live group session id, if that party is still running - clears a
|
|
* stale one (a party can end without its static party having heard). */
|
|
function liveGroupPartyId(sp: StaticParty): string | null {
|
|
if (sp.livePartyId && !PartyService.getById(sp.livePartyId)) StaticPartyService.setLiveParty(sp, null);
|
|
return sp.livePartyId;
|
|
}
|
|
|
|
/** Finds `:staticId` or answers 404 - null means the response is sent. */
|
|
function staticPartyOr404(req: Request, res: Response): StaticParty | null {
|
|
const sp = StaticPartyService.getById(param(req, 'staticId'));
|
|
if (!sp) res.status(404).json({ error: 'Watch party permanente introuvable.' });
|
|
return sp;
|
|
}
|
|
|
|
app.get('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
|
|
const session = req.embySession!;
|
|
const parties = StaticPartyService.list();
|
|
const played = await Promise.all(parties.map((sp) => syncStaticSource(session, sp)));
|
|
res.json({
|
|
staticParties: parties.map((sp, i) => StaticPartyService.toSummary(sp, session.userId, played[i]!, liveGroupPartyId(sp)))
|
|
});
|
|
}));
|
|
|
|
app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
|
|
const sourceId = req.body?.sourceId;
|
|
if (!sourceId || typeof sourceId !== 'string') {
|
|
res.status(400).json({ error: 'sourceId is required.' });
|
|
return;
|
|
}
|
|
const session = req.embySession!;
|
|
const item = await CatalogService.getRawItem(session, sourceId);
|
|
|
|
// An episode stands for its whole series - the natural entry point from
|
|
// views/series is the series itself, but nothing's lost accepting one.
|
|
let source: StaticParty['source'];
|
|
let sourceItem = item;
|
|
if (item.Type === 'Series') {
|
|
source = { type: 'series', sourceId: item.Id };
|
|
} else if (item.Type === 'Episode' && item.SeriesId) {
|
|
source = { type: 'series', sourceId: item.SeriesId };
|
|
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
|
|
} else if (item.Type === 'Playlist') {
|
|
source = { type: 'playlist', sourceId: item.Id };
|
|
} else {
|
|
res.status(400).json({ error: 'Une watch party permanente se crée depuis une série ou une playlist vidéo.' });
|
|
return;
|
|
}
|
|
|
|
const raw = await fetchStaticSourceItems(session, source);
|
|
if (!raw.length) {
|
|
res.status(400).json({ error: source.type === 'playlist' ? 'Cette playlist ne contient aucune vidéo.' : "Cette série n'a aucun épisode." });
|
|
return;
|
|
}
|
|
const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : sourceItem.Name;
|
|
const sp = StaticPartyService.create(
|
|
{ userId: session.userId, userName: session.userName },
|
|
source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem)
|
|
);
|
|
const played = new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
|
|
res.json(StaticPartyService.toDetail(sp, session.userId, played, null));
|
|
}));
|
|
|
|
app.get('/api/static-parties/:staticId', requireSession, asyncRoute(async (req, res) => {
|
|
const sp = staticPartyOr404(req, res);
|
|
if (!sp) return;
|
|
const session = req.embySession!;
|
|
const played = await syncStaticSource(session, sp);
|
|
res.json(StaticPartyService.toDetail(sp, session.userId, played, liveGroupPartyId(sp)));
|
|
}));
|
|
|
|
app.post('/api/static-parties/:staticId/join', requireSession, (req, res) => {
|
|
const sp = staticPartyOr404(req, res);
|
|
if (!sp) return;
|
|
const session = req.embySession!;
|
|
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/static-parties/:staticId/leave', requireSession, (req, res) => {
|
|
const sp = staticPartyOr404(req, res);
|
|
if (!sp) return;
|
|
StaticPartyService.leave(sp, req.embySession!.userId);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/static-parties/:staticId', requireSession, (req, res) => {
|
|
const sp = staticPartyOr404(req, res);
|
|
if (!sp) return;
|
|
if (!StaticPartyService.delete(sp, req.embySession!.userId)) {
|
|
res.status(403).json({ error: 'Seule la personne qui a créé cette watch party peut la supprimer.' });
|
|
return;
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/static-parties/:staticId/items/:itemId/watched', requireSession, (req, res) => {
|
|
const sp = staticPartyOr404(req, res);
|
|
if (!sp) return;
|
|
const session = req.embySession!;
|
|
if (!StaticPartyService.markWatched(sp, { userId: session.userId, userName: session.userName }, param(req, 'itemId'))) {
|
|
res.status(404).json({ error: 'Cet élément ne fait pas partie de cette watch party.' });
|
|
return;
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Launches the group's next session - or joins it, if one's already
|
|
// running. Whoever launches it hosts it (see PartyService.create), and it
|
|
// resumes exactly where the group stopped last time: the host's starting
|
|
// position is seeded from the saved one, which is what Player.ts already
|
|
// reads to place every member's first frame (see its init()).
|
|
app.post('/api/static-parties/:staticId/session', requireSession, asyncRoute(async (req, res) => {
|
|
const sp = staticPartyOr404(req, res);
|
|
if (!sp) return;
|
|
const session = req.embySession!;
|
|
const who = { userId: session.userId, userName: session.userName };
|
|
|
|
const existingId = liveGroupPartyId(sp);
|
|
if (!existingId) await syncStaticSource(session, sp); // pick up new episodes before building the queue
|
|
// Checked again after the await: someone else may have launched it meanwhile.
|
|
const liveId = liveGroupPartyId(sp);
|
|
const live = liveId ? PartyService.getById(liveId) : null;
|
|
if (live) {
|
|
PartyService.join(live, session.userId, session.userName);
|
|
PartyService.setMemberEmbyToken(live, session.userId, session.embyToken);
|
|
StaticPartyService.join(sp, who);
|
|
SocketGateway.broadcastLobby(io);
|
|
res.json({ partyId: live.id });
|
|
return;
|
|
}
|
|
|
|
const queue = StaticPartyService.groupQueue(sp);
|
|
if (!queue.length) {
|
|
res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette watch party.' });
|
|
return;
|
|
}
|
|
const first = queue[0]!;
|
|
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
|
|
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
|
|
party.members.get(session.userId)!.positionTicks = sp.group.resumePositionTicks;
|
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
|
StaticPartyService.join(sp, who);
|
|
StaticPartyService.setLiveParty(sp, party.id);
|
|
SocketGateway.broadcastLobby(io);
|
|
res.json({ partyId: party.id });
|
|
}));
|
|
|
|
// Catching up on missed items - alone (a party hidden from the lobby) or as
|
|
// an ordinary watch party others can join. Only the participants' own
|
|
// progress changes; the group's doesn't (see StaticPartyService.onItemCompleted).
|
|
app.post('/api/static-parties/:staticId/catch-up', requireSession, (req, res) => {
|
|
const sp = staticPartyOr404(req, res);
|
|
if (!sp) return;
|
|
const session = req.embySession!;
|
|
const mode = req.body?.mode === 'party' ? 'party' : 'solo';
|
|
const requested = new Set(Array.isArray(req.body?.itemIds) ? req.body.itemIds.filter((id: unknown) => typeof id === 'string') : []);
|
|
const queue = sp.items.filter((i) => requested.has(i.itemId)); // the static party's own order, never the caller's
|
|
if (!queue.length) {
|
|
res.status(400).json({ error: 'Aucun élément à rattraper.' });
|
|
return;
|
|
}
|
|
const first = queue[0]!;
|
|
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
|
|
party.staticLink = { staticPartyId: sp.id, mode: 'catchUp' };
|
|
party.hiddenFromLobby = mode === 'solo';
|
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
|
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
|
|
Logger.staticParty(`catch-up started id=${sp.id} mode=${mode} partyId=${party.id} items=${queue.length} by=${Logger.who(session.userId, session.userName)}`);
|
|
if (!party.hiddenFromLobby) SocketGateway.broadcastLobby(io);
|
|
res.json({ partyId: party.id });
|
|
});
|
|
|
|
// Catches whatever asyncRoute() forwarded (or a synchronous throw) -
|
|
// CatalogFetchError carries its own status/message; anything else is an
|
|
// unexpected bug, logged in full server-side but never leaked to the
|
|
// client beyond a generic message.
|
|
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
if (err instanceof CatalogFetchError) {
|
|
res.status(err.status).json({ error: err.message });
|
|
return;
|
|
}
|
|
console.error('[watchparty] unexpected error', err);
|
|
res.status(500).json({ error: 'Erreur serveur.' });
|
|
});
|
|
|
|
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
|
|
// SocketGateway's top comment for why that matters.
|
|
const io = new SocketIoServer<ClientToServerEvents, ServerToClientEvents>(server);
|
|
SocketGateway.attach(io, (h) => AuthService.parseCookies(h), (id) => AuthService.getSession(id));
|
|
|
|
// 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
|
|
// imported directly from SocketGateway/MusicQueueService to avoid a
|
|
// circular import between the two.
|
|
MusicQueueService.attachIo(io, () => SocketGateway.broadcastLobby(io));
|
|
|
|
setInterval(() => PartyService.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
|
|
// Shorter idle window than parties themselves: a burn-in session holds a
|
|
// real ffmpeg process + disk, not just an in-memory object, so a paused/
|
|
// abandoned one is worth reclaiming sooner. Mostly a backstop - the
|
|
// normal path is PartyService.leave's destroy branch ending it immediately.
|
|
setInterval(() => TranscodeService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
|
|
// Same reasoning as TranscodeService's own reaper just above - a cached
|
|
// negotiation is mostly harmless to keep around, but there's no reason
|
|
// to hold one for a party nobody's actively streaming from any more.
|
|
// Mostly a backstop too - the normal path is PartyService.leave's
|
|
// destroy branch (and an item/track change) ending it immediately.
|
|
setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
|
|
|
|
// Pushes every connected member's own resume position to Emby (their own
|
|
// UserData for the item, under their own account) - see
|
|
// docs/architecture.md's "Sync watch-party progress back to Emby"
|
|
// section for the full history of why this writes UserData directly
|
|
// rather than Emby's PlayingItems/Progress check-in endpoint. Best-effort:
|
|
// PlaystateService.updatePosition swallows its own failures, so one bad
|
|
// token or a transient Emby hiccup never stops the rest of this sweep.
|
|
// Marking an item Played (once the party moves past it) is handled
|
|
// separately, at the source of that transition - see
|
|
// PartyService.advanceQueue and MusicQueueService.playNext.
|
|
//
|
|
// Also where EmbySessionService's own start/progress/stop lifecycle is
|
|
// driven from, rather than scattered across every queue-advance/track-
|
|
// skip call site the way markPlayed needed to be - this loop already
|
|
// visits every connected member on every tick, so it's already in
|
|
// exactly the right place to notice "this member's current item just
|
|
// changed" (member.embySession.itemId no longer matches) and react:
|
|
// stop the old session if there was one, negotiate a fresh
|
|
// MediaSourceId/PlaySessionId under their own account, and start the
|
|
// new one. Same item as last tick just reports Progress with the
|
|
// already-cached ids - no renegotiation needed for that, the common case.
|
|
setInterval(() => {
|
|
for (const party of PartyService.listActive()) {
|
|
const state = PartyService.getCurrentPlaybackState(party);
|
|
if (!state) continue; // nothing currently playing (e.g. an empty music queue)
|
|
// A static watch party's group session: remember where the group is,
|
|
// so the next session resumes there (no-op for any other party).
|
|
if (party.staticLink) StaticPartyService.recordPosition(party.staticLink, state.itemId, state.positionTicks);
|
|
for (const member of party.members.values()) {
|
|
if (!member.connection || !member.embyToken) continue;
|
|
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
|
|
|
|
if (member.embySession && member.embySession.itemId === state.itemId) {
|
|
const s = member.embySession;
|
|
EmbySessionService.progress(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, state.positionTicks, state.isPaused);
|
|
continue;
|
|
}
|
|
|
|
// The item just changed for them (or this is the first tick since
|
|
// they started watching/listening) - stop whatever they had open
|
|
// before, if anything, then negotiate and start a fresh one.
|
|
const previous = member.embySession;
|
|
const embyToken = member.embyToken;
|
|
member.embySession = null;
|
|
if (previous) {
|
|
EmbySessionService.stop(embyToken, previous.itemId, previous.mediaSourceId, previous.playSessionId, member.positionTicks);
|
|
}
|
|
// CatalogService's own methods all take a full EmbySession, for
|
|
// consistency with every live user-facing route that already has
|
|
// one from requireSession - this sweep only ever has a member's
|
|
// bare stored token, same situation PlaystateService/
|
|
// EmbySessionService are already in. serverId/createdUtc are safe
|
|
// to fake: negotiatePlaybackSession (like every per-member Emby
|
|
// write in this app) never reads either, only embyToken/userId.
|
|
const fakeSession: EmbySession = { embyToken, userId: member.userId, userName: member.userName, serverId: '', createdUtc: 0 };
|
|
CatalogService.negotiatePlaybackSession(fakeSession, state.itemId).then((negotiated) => {
|
|
if (!negotiated) return;
|
|
member.embySession = { itemId: state.itemId, mediaSourceId: negotiated.mediaSourceId, playSessionId: negotiated.playSessionId };
|
|
EmbySessionService.start(embyToken, state.itemId, negotiated.mediaSourceId, negotiated.playSessionId, state.positionTicks, state.isPaused);
|
|
});
|
|
}
|
|
}
|
|
}, 20 * 1000);
|
|
|
|
// A pending (debounced) static-party save must reach disk before the
|
|
// process exits - `docker stop` sends SIGTERM, Ctrl+C sends SIGINT.
|
|
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
process.once(signal, () => {
|
|
Logger.staticParty(`${signal} received - flushing before exit`);
|
|
StaticPartyStore.flushSync();
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
|
});
|