'use strict'; import type { Request, Response } from 'express'; import { EMBY_SERVER_URL } from '../config'; import { EmbyClient } from '../emby/EmbyClient'; import { Logger } from '../Logger'; import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types'; // 'MusicVideo' is Emby's own item type for a musicvideos library's // contents (distinct from the generic 'Video', which is home videos). // 'Audio' is a music track. 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. const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']); // Emby's /Views can return all sorts of things beyond actual media // libraries - boxset collections, live TV, photos, books, a mixed-content // folder with no CollectionType at all - none of which this app has any // UI for. Only these five are ever shown, in the sidebar or anywhere // else: everything else is dropped here, at the source, so every // consumer (shell/sidebar, home's carousels, direct navigation to a // library route) is filtered the same way without each having to repeat // the same check. 'playlists' only ever shows up here at all once the // user has at least one - same as any other library, nothing special to // handle for "no playlists yet". const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos', 'playlists']); // Image/bitmap subtitle codecs - pre-rendered pictures (from a Blu-ray/DVD // rip), not text. This app forces SubtitleMethod=Encode whenever a // subtitle is picked (see applyPlaybackOverrides), which asks ffmpeg's // `subtitles` filter (libass) to burn it in - libass renders TEXT via a // font, it has no code path for compositing a bitmap at all. Confirmed // against a real server (see docs/architecture.md's player-track- // selection section, "Round 4/5"): picking one of these doesn't produce a // slow-but-working transcode, it makes Emby's own segment generation // throw outright. Used by getPlaybackOptions to flag these in the picker // instead of letting someone pick a track that's guaranteed to fail. const IMAGE_SUBTITLE_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb_subtitle']); /** * Emby allows mixing movies/episodes and songs in the same playlist; * this app's party model can't (a party is either 'video' or 'music' - * see docs/architecture.md). Decides which of the two a playlist launch * should become from its *first* item's type, then drops anything that * doesn't match - good enough for v1 (most playlists are single-media * anyway), logged by the caller so a mixed one isn't silently confusing * from `docker logs`. Pure - no Emby/session involved - so it's * unit-tested directly (see catalogService.test.ts) without needing to * exercise the actual /api/parties route that calls it (index.ts). */ /** * The image URL for a raw Emby item - every place that builds one from an * `EmbyItem` (toItemDto below, and the party-creation routes in index.ts * that hand-build one straight from a freshly-fetched item without going * through a DTO) funnels through here. Almost always just `item.Id` * itself, EXCEPT a music track: confirmed directly against a real server * that an individual Audio item essentially never carries its own Primary * image (every track sampled answered with a 500, not even a clean 404), * while its parent album usually does - so a track's own id would just * mean a permanently broken/missing thumbnail everywhere one is shown * (the listen-party queue, the home page's live-party card) instead of * the same cover art every other client shows next to it. Callers must * request `AlbumId` in their own `Fields` param for this to have * anything to fall back to - see getAlbumTracks/getPlaylistItemsRaw. */ export function imageUrlFor(item: EmbyItem): string { const imageItemId = item.Type === 'Audio' && item.AlbumId ? item.AlbumId : item.Id; return `/api/catalog/image/${imageItemId}`; } export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } { if (!items.length) return { mediaType: 'video', items: [] }; const isMusic = items[0]!.Type === 'Audio'; return { mediaType: isMusic ? 'music' : 'video', items: items.filter((i) => (i.Type === 'Audio') === isMusic) }; } interface EmbyItemsResponse { Items?: EmbyItem[]; } interface PlaybackInfoResponse { MediaSources?: Array<{ Id?: string; Container?: string; TranscodingUrl?: string; MediaStreams?: Array<{ Type: string; Index: number; DisplayTitle?: string; Language?: string; Codec?: string }>; }>; PlaySessionId?: string; } /** * Everything catalog-shaped: search, item detail, libraries, recently * added, Series -> Seasons -> Episodes, Library -> Artists -> Albums -> * Tracks, playback negotiation, and the actual image/HLS/audio proxying. * The single largest extraction from the old index.js - previously ~15 * route handlers' worth of logic inlined directly in the Express app. * * Stateless (no singleton instance state, unlike PartyService/ * MusicQueueService/AuthService) - kept as a class anyway for consistency * with the rest of server/src/services, and because it's still very much * a "feature" in the sense this app's architecture groups by (see * docs/architecture.md). */ class CatalogServiceClass { async search(session: EmbySession, query: string): Promise { const q = query.trim(); if (q.length < 2) return []; const params = new URLSearchParams({ searchTerm: q, IncludeItemTypes: 'Movie,Episode,Series', Recursive: 'true', Fields: 'Overview,ProductionYear', ImageTypeLimit: '1', Limit: '24' }); const data = await this.fetchJson(`/Users/${session.userId}/Items?${params}`, session, 'Recherche impossible côté Emby.'); return (data.Items || []).map((i) => this.toItemDto(i, session.serverId)); } /** Raw Emby item (Type, SeriesId, RunTimeTicks, ...) - used where a * caller needs more than the DTO exposes, e.g. deciding a new party's * mediaType from the clicked item's real Emby Type (see index.ts). * Unlike every other method here, this preserves Emby's own status * (typically a real 404) rather than a blanket 502 - "titre introuvable" * is a not-found, not an upstream failure. * * `Fields=AlbumId` - without it, Emby's default field set for a single * item omits it, so a track started directly (not via its album/ * artist - the one path that reaches imageUrlFor through *this* * method, in index.ts's POST /api/parties) always missed imageUrlFor's * own Audio -> AlbumId fallback and fell through to the track's own * id instead - which, confirmed directly against a real server (see * imageUrlFor's own comment), essentially never has a Primary image * of its own. Reported directly as "the app really struggles to find * artist/album/track images" - this is the one concrete, fixable * piece of that: a party started this way now gets the same album * art every other path already resolves correctly. Harmless for * every non-Audio item this also fetches (Movie/Series/Episode/...) - * they have no AlbumId to begin with, so Emby just omits the field. */ async getRawItem(session: EmbySession, itemId: string): Promise { return this.fetchJson(`/Users/${session.userId}/Items/${itemId}?Fields=AlbumId`, session, 'Titre introuvable.', { preserveEmbyStatus: true }); } async getItem(session: EmbySession, itemId: string): Promise { return this.toItemDto(await this.getRawItem(session, itemId), session.serverId); } async getLibraries(session: EmbySession): Promise { const data = await this.fetchJson(`/Users/${session.userId}/Views`, session, 'Impossible de récupérer les bibliothèques.'); return (data.Items || []) .filter((v) => VISIBLE_COLLECTION_TYPES.has((v as unknown as { CollectionType?: string }).CollectionType || '')) .map((v) => ({ id: v.Id, name: v.Name, // "movies" / "tvshows" / "music" / "musicvideos" - drives which // browsing experience client/src/views/library renders. collectionType: (v as unknown as { CollectionType?: string }).CollectionType || '' })); } /** Home page carousel: Emby's own "recently added" endpoint, one row per library. */ async getRecentItems(session: EmbySession, libraryId: string): Promise { const params = new URLSearchParams({ ParentId: libraryId, Limit: '16', Fields: 'ProductionYear,AlbumId' }); const data = await this.fetchJson(`/Users/${session.userId}/Items/Latest?${params}`, session, 'Impossible de récupérer les ajouts récents.'); // Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}. const items = Array.isArray(data) ? data : (data.Items || []); return items.map((i) => this.toItemDto(i, session.serverId)); } async getSeriesSeasons(session: EmbySession, seriesId: string): Promise { const data = await this.fetchJson(`/Shows/${seriesId}/Seasons?userId=${session.userId}`, session, 'Impossible de récupérer les saisons.'); return (data.Items || []).map((s) => ({ id: s.Id, name: s.Name, indexNumber: s.IndexNumber })); } async getSeriesEpisodes(session: EmbySession, seriesId: string, seasonId?: string): Promise { const params = new URLSearchParams({ userId: session.userId, Fields: 'Overview' }); if (seasonId) params.set('SeasonId', seasonId); const data = await this.fetchJson(`/Shows/${seriesId}/Episodes?${params}`, session, 'Impossible de récupérer les épisodes.'); return (data.Items || []).map((e) => this.toItemDto(e, session.serverId)); } /** Raw episodes (no season filter) - used by index.ts to build a new * party's fixed episode queue when starting from a specific episode. */ async getAllSeriesEpisodesRaw(session: EmbySession, seriesId: string): Promise { const data = await this.fetchJson(`/Shows/${seriesId}/Episodes?userId=${session.userId}&Fields=Overview`, session, 'Impossible de récupérer les épisodes.'); return data.Items || []; } /** A playlist's own contents, in order - Emby's standard `/Playlists` * endpoint, distinct from a generic library listing the same way * Shows/Episodes above is. `Fields` covers what either a video or a * music playlist's items need (see toItemDto) - a playlist can hold * either, see splitPlaylistByMediaType. Raw (not DTO-shaped), used by * index.ts to build a new party's queue when launching one - see * getPlaylistItems below for the DTO-shaped equivalent the client's * own playlist page reads instead. */ async getPlaylistItemsRaw(session: EmbySession, playlistId: string): Promise { const params = new URLSearchParams({ UserId: session.userId, Fields: 'Overview,ProductionYear,RunTimeTicks,IndexNumber,Album,AlbumArtist,AlbumId' }); const data = await this.fetchJson(`/Playlists/${playlistId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette playlist.'); return data.Items || []; } async getPlaylistItems(session: EmbySession, playlistId: string): Promise { const items = await this.getPlaylistItemsRaw(session, playlistId); return items.map((i) => this.toItemDto(i, session.serverId)); } // Library -> Artists -> Albums -> Tracks, for the "Musique" browsing // pages. Each its own Emby endpoint/query rather than guessing folder // structure, same reasoning as Shows -> Seasons -> Episodes above. async getArtists(session: EmbySession, libraryId: string): Promise { // No Limit, deliberately - see getLibraryItems's own comment below for // why one very nearly shipped here and got pulled: confirmed directly // against the real server that this library alone has 1229 artists, // so a 300 cap would have silently dropped 75% of them, not just // trimmed an edge case. Rendering that many is tiles.ts's // renderTilesInChunks's job, not this query's. const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName', Fields: 'SortName' }); const data = await this.fetchJson(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.'); return (data.Items || []).map((a) => this.toItemDto(a, session.serverId)); } async getArtistAlbums(session: EmbySession, artistId: string): Promise { const params = new URLSearchParams({ ArtistIds: artistId, UserId: session.userId, IncludeItemTypes: 'MusicAlbum', Recursive: 'true', SortBy: 'ProductionYear,SortName', Fields: 'ProductionYear' }); const data = await this.fetchJson(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer les albums.'); return (data.Items || []).map((a) => this.toItemDto(a, session.serverId)); } async getAlbumTracks(session: EmbySession, albumId: string): Promise { const params = new URLSearchParams({ ParentId: albumId, UserId: session.userId, IncludeItemTypes: 'Audio', SortBy: 'IndexNumber', Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist,AlbumId' }); const data = await this.fetchJson(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer les morceaux.'); return (data.Items || []).map((t) => this.toItemDto(t, session.serverId)); } async getLibraryItems(session: EmbySession, libraryId: string): Promise { // Fields deliberately excludes Overview - a library grid only ever // renders tileHtml's poster/name/year (views/library, tiles.ts), which // never reads it. Traced here after "the catalog API is too slow, the // front freezes while it loads" - Emby happily serializes a full // synopsis for every item anyway, and the client happily JSON.parses // all of it, entirely to be thrown away unread. Contrast // getSeriesEpisodes/getPlaylistItemsRaw, which keep Overview because // their own views (Series/Playlist) actually display it. // // No Limit - there used to be one here (300), presumably meant as the // same defensive cap getArtists briefly grew (see its own comment). // Confirmed directly against the real server that this app's own // "Films" library alone has 421 items - past that cap, so it was // quietly serving an incomplete grid with no error and no hint // anything was missing, reported as "the catalog is broken, not all // the data is there". Emby returns everything when Limit is omitted // (confirmed: TotalRecordCount matched Items.length exactly with no // Limit param, for both this library and the 1229-artist one above) - // rendering that many tiles without freezing the tab is // renderTilesInChunks's job (tiles.ts), not something this query // should be truncating data to work around. const params = new URLSearchParams({ ParentId: libraryId, Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode) SortBy: 'SortName', Fields: 'ProductionYear,AlbumId,SortName', ImageTypeLimit: '1' }); const data = await this.fetchJson(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette bibliothèque.'); return (data.Items || []).map((i) => this.toItemDto(i, session.serverId)); } /** Audio/subtitle tracks available for an item, read from Emby's own * playback negotiation (PlaybackInfo) - powers the player's * language/subtitle pickers. */ async getPlaybackOptions(session: EmbySession, itemId: string): Promise { const info = await this.fetchJson(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de lire les options de lecture.'); const source = (info.MediaSources || [])[0]; const streams = source?.MediaStreams || []; const label = (s: { Index: number; DisplayTitle?: string; Language?: string }) => s.DisplayTitle || s.Language || `Piste ${s.Index}`; return { audioTracks: streams.filter((s) => s.Type === 'Audio').map((s) => ({ index: s.Index, label: label(s) })), subtitleTracks: streams.filter((s) => s.Type === 'Subtitle').map((s) => ({ index: s.Index, label: label(s), isTextBased: !IMAGE_SUBTITLE_CODECS.has((s.Codec || '').toLowerCase()) })) }; } /** * Emby's own native intro/credits detection - see PlaybackMarkersDto's * own comment for what MarkerType actually looks like and where that * was confirmed. Read from the same Chapters field getRawItem/toItemDto * never request (they have no use for chapters at all) - a dedicated * fetch here rather than folding it into getItem, since a chapter list * is only ever needed by the player deciding whether to show a skip * button, nowhere a plain ItemDto is used. */ async getPlaybackMarkers(session: EmbySession, itemId: string): Promise { const data = await this.fetchJson<{ Chapters?: Array<{ StartPositionTicks: number; MarkerType?: string }> }>( `/Users/${session.userId}/Items/${itemId}?Fields=Chapters`, session, 'Impossible de récupérer les marqueurs de lecture.' ); const chapters = data.Chapters || []; const startOf = (markerType: string) => chapters.find((c) => c.MarkerType === markerType)?.StartPositionTicks ?? null; return { introStartTicks: startOf('IntroStart'), introEndTicks: startOf('IntroEnd'), creditsStartTicks: startOf('CreditsStart') }; } /** * The negotiated MediaSource's id/container/streams - used by * TranscodeService to build its own ffmpeg source URL and translate * Emby's absolute stream indices into ffmpeg's relative ones. A * separate method from proxyStream/proxySubtitle (which each do their * own PlaybackInfo fetch already, for reasons specific to what they * need back) rather than a forced consolidation - this is the one * caller that actually needs Container + the full MediaStreams array * together, and unlike proxyStream this hard-fails (throws) when * there's no usable source rather than falling back to a hand-built * URL, since there's no equivalent fallback for a raw ffmpeg input. */ async getMediaSource(session: EmbySession, itemId: string): Promise<{ id: string; container: string; streams: Array<{ Type: string; Index: number }> }> { const info = await this.fetchJson(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de préparer la lecture de ce titre.'); const source = (info.MediaSources || [])[0]; if (!source?.Id) { throw new CatalogFetchError('Aucune source de lecture disponible pour ce titre.', 502); } return { id: source.Id, container: source.Container || 'mkv', streams: source.MediaStreams || [] }; } /** * A fresh PlaybackInfo negotiation under `session`'s own account, * purely to get the MediaSourceId/PlaySessionId EmbySessionService * needs to open a real Emby Sessions-API entry for this member - see * its own class comment for why every party member needs their own, * rather than reusing the one PartyStreamService already negotiated * for the party's actual shared relay (that one was negotiated once, * under whichever member happened to trigger it first - never * guaranteed valid for every OTHER member's own account, since Emby * scopes PlaybackInfo per user). Null on any failure (an expired * token, Emby unreachable, no source at all) rather than throwing - * this is reported to an admin dashboard as a nice-to-have, never * something that should be allowed to disrupt real playback the way * getMediaSource's own hard failure is allowed to (that one feeds an * actual ffmpeg source). */ async negotiatePlaybackSession(session: EmbySession, itemId: string): Promise<{ mediaSourceId: string; playSessionId: string } | null> { try { const info = await this.fetchJson(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de négocier une session de lecture.'); const source = (info.MediaSources || [])[0]; if (!source?.Id || !info.PlaySessionId) return null; return { mediaSourceId: source.Id, playSessionId: info.PlaySessionId }; } catch { return null; } } // --- Direct proxying (images/video/audio) - these need the raw // Express req/res, unlike everything above which just returns JSON. --- async proxyImage(req: Request, res: Response, session: EmbySession, itemId: string): Promise { await EmbyClient.proxyResponse(req, res, `/Items/${itemId}/Images/Primary?maxWidth=400`, session.embyToken, itemId); } /** * Negotiates Emby's own transcode target for `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. * * Returns the target path - doesn't proxy it itself (contrast with * proxyImage/proxySubtitle below, which do their own proxying * directly) - so `PartyStreamService` can cache what this returns and * reuse it for every member of a party instead of each negotiating * (and getting their own separate PlaySessionId) independently - see * docs/architecture.md's "One shared Emby stream per party" section. * `isAudio` picks the hand-built fallback's path (/Audio/ vs /Videos/, * plus whether to force a VideoCodec at all) - 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 caller already * knows. * * Subtitles deliberately do NOT go through here (see proxySubtitle * instead, and its own comment for why): forcing SubtitleMethod=Encode * onto this negotiation used to be how this app handled them, until a * long real-server debugging session (docs/architecture.md's player- * track-selection section, Rounds 3-6) found it unreliable in ways * ranging from missing fonts to formats libass can't burn at all to * ffmpeg failing outright on otherwise-unremarkable files - none of * which this app's own request could ever have controlled or worked * around, since it's all inside Emby's own transcode. Rendering * subtitles client-side, from real subtitle data instead of an * encoding option this app has no visibility into, sidesteps the * entire class of failure. * */ async negotiateStreamTarget(session: EmbySession, itemId: string, isAudio: boolean, audioStreamIndex: string, maxStreamingBitrate: string): Promise { // Applied to the negotiation itself, not just the final URL - a pick // could otherwise 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. Still not trusted to // have taken effect on its own, though (this app never sends that // body) - applyPlaybackOverrides runs a second time below, on // whatever URL results, so a pick can't silently no-op regardless of // which path Emby's response takes. const infoParams = new URLSearchParams({ UserId: session.userId }); this.applyPlaybackOverrides(infoParams, audioStreamIndex, maxStreamingBitrate); const info = await this.fetchJson(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session, 'Impossible de préparer la lecture de ce titre.'); const source = (info.MediaSources || [])[0]; if (!source) { throw new CatalogFetchError('Aucune source de lecture disponible pour ce titre.', 502); } // 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. if (source.TranscodingUrl) { const [urlPath, urlQuery] = source.TranscodingUrl.split('?'); const params = new URLSearchParams(urlQuery || ''); this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate); this.logStreamRequest(itemId, 'negotiated', params); return `${urlPath}?${params}`; } const params = new URLSearchParams({ UserId: session.userId, MediaSourceId: source.Id || '', PlaySessionId: info.PlaySessionId || '', AudioCodec: 'aac', ...(isAudio ? {} : { VideoCodec: 'h264' }) }); this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate); this.logStreamRequest(itemId, 'fallback', params); return `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`; } /** * Forces this app's own audio/quality selection onto a transcode URL, * whether that URL came from Emby's own PlaybackInfo negotiation or was * hand-built as a fallback. 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. Subtitles used to be * forced here too (SubtitleStreamIndex/SubtitleMethod=Encode) - see * negotiateStreamTarget's own comment for why that moved to * proxySubtitle instead. */ private applyPlaybackOverrides(params: URLSearchParams, audioStreamIndex: string, maxStreamingBitrate: string): void { if (audioStreamIndex) params.set('AudioStreamIndex', audioStreamIndex); if (maxStreamingBitrate) params.set('MaxStreamingBitrate', maxStreamingBitrate); } // 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. Makes it // 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. private logStreamRequest(itemId: string, source: 'negotiated' | 'fallback', params: URLSearchParams): void { Logger.stream( `master.m3u8 itemId=${itemId} source=${source} ` + `audio=${params.get('AudioStreamIndex') || '(default)'} ` + `quality=${params.get('MaxStreamingBitrate') || '(auto)'}` ); } /** * A subtitle track's text, converted to WebVTT by Emby itself - not the * video stream at all, so this needs its own PlaybackInfo round trip * just for MediaSourceId (proxyStream already has one for the video * request, but that's a separate HTTP request from the browser's own * , with no way to share the result between them). Rendered * client-side as a native (see views/player/Player.ts) instead * of asking Emby to burn it into the video - see proxyStream's own * comment for the whole history of why. Image-based subtitles * (IMAGE_SUBTITLE_CODECS) have no text for Emby to convert here; the * player's own picker already disables those (getPlaybackOptions' * isTextBased), so this is never expected to be hit for one in normal * use - if it somehow is, Emby's own response (logged in full either * way - see EmbyClient.fetch) is the answer, not a guess made here. */ async proxySubtitle(req: Request, res: Response, session: EmbySession, itemId: string, subtitleIndex: string): Promise { const info = await this.fetchJson(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de préparer les sous-titres.'); const source = (info.MediaSources || [])[0]; if (!source?.Id) { throw new CatalogFetchError('Aucune source disponible pour ce titre.', 502); } await EmbyClient.proxyResponse(req, res, `/Videos/${itemId}/${source.Id}/Subtitles/${subtitleIndex}/Stream.vtt`, session.embyToken, itemId); } private toItemDto(item: EmbyItem, serverId: string): ItemDto { return { id: item.Id, name: item.Name, sortName: item.SortName || item.Name, overview: item.Overview, year: item.ProductionYear, type: item.Type, canWatchParty: WATCH_PARTY_TYPES.has(item.Type || ''), imageUrl: imageUrlFor(item), embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`, // Track-specific (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?.[0] || null, albumName: item.Album || null }; } /** * GETs an Emby endpoint as this session's user and parses the JSON body, * or throws a CatalogFetchError - never touches an Express response * directly. Every route in index.ts runs through asyncRoute(), which * catches this (and anything else) and turns it into the right HTTP * response in one place, rather than each of the ~15 routes below * needing its own try/catch around every await. */ private async fetchJson( pathAndQuery: string, session: EmbySession, errorMessage: string, options: { preserveEmbyStatus?: boolean } = {} ): Promise { const embyRes = await EmbyClient.fetch(pathAndQuery, session.embyToken); if (!embyRes.ok) { throw new CatalogFetchError(errorMessage, options.preserveEmbyStatus ? embyRes.status : 502); } return (await embyRes.json()) as T; } } /** * A known, user-facing catalog failure (Emby unreachable, item not found, * ...) - index.ts's asyncRoute() catches this specifically and responds * with `status`/`message` verbatim; anything else that throws is an * unexpected bug and gets a generic 500 instead (see index.ts's error * -handling middleware). */ export class CatalogFetchError extends Error { constructor(message: string, public status: number) { super(message); } } export const CatalogService = new CatalogServiceClass();