watchparty/server/src/services/CatalogService.ts
Nicolas 5b2b832be7
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Fix a track started directly never falling back to its albums artwork
imageUrlFors own Audio -> AlbumId fallback was silently never
triggering for a track played directly (not via its album/artist),
because the raw item that decision runs against - CatalogService
getRawItem - never requested AlbumId from Emby. Its default field set
for a single-item GET omits it entirely, so item.AlbumId read
undefined regardless of whether the tracks actual album had one -
falling through to the tracks own id every time, which essentially
never has a Primary image of its own. Every other path that resolves a
tracks image already asks for AlbumId correctly (getAlbumTracks/
getPlaylistItemsRaw), which is why launching a whole album/artist/
playlist was never affected. Fixed the one gap: getRawItem now
requests Fields=AlbumId too.

Flagged directly as probably not the whole images-are-hard-to-find
story - a real Emby library can also legitimately have no Primary
image at all for a given artist/album, which is not a bug this app can
fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:26:33 +02:00

571 lines
30 KiB
TypeScript

'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<ItemDto[]> {
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<EmbyItemsResponse>(`/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<EmbyItem> {
return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}?Fields=AlbumId`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
}
async getItem(session: EmbySession, itemId: string): Promise<ItemDto> {
return this.toItemDto(await this.getRawItem(session, itemId), session.serverId);
}
async getLibraries(session: EmbySession): Promise<LibraryDto[]> {
const data = await this.fetchJson<EmbyItemsResponse>(`/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<ItemDto[]> {
const params = new URLSearchParams({ ParentId: libraryId, Limit: '16', Fields: 'ProductionYear,AlbumId' });
const data = await this.fetchJson<EmbyItemsResponse | EmbyItem[]>(`/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<SeasonDto[]> {
const data = await this.fetchJson<EmbyItemsResponse>(`/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<ItemDto[]> {
const params = new URLSearchParams({ userId: session.userId, Fields: 'Overview' });
if (seasonId) params.set('SeasonId', seasonId);
const data = await this.fetchJson<EmbyItemsResponse>(`/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<EmbyItem[]> {
const data = await this.fetchJson<EmbyItemsResponse>(`/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<EmbyItem[]> {
const params = new URLSearchParams({ UserId: session.userId, Fields: 'Overview,ProductionYear,RunTimeTicks,IndexNumber,Album,AlbumArtist,AlbumId' });
const data = await this.fetchJson<EmbyItemsResponse>(`/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<ItemDto[]> {
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<ItemDto[]> {
// 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<EmbyItemsResponse>(`/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<ItemDto[]> {
const params = new URLSearchParams({
ArtistIds: artistId,
UserId: session.userId,
IncludeItemTypes: 'MusicAlbum',
Recursive: 'true',
SortBy: 'ProductionYear,SortName',
Fields: 'ProductionYear'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/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<ItemDto[]> {
const params = new URLSearchParams({
ParentId: albumId,
UserId: session.userId,
IncludeItemTypes: 'Audio',
SortBy: 'IndexNumber',
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist,AlbumId'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/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<ItemDto[]> {
// 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<EmbyItemsResponse>(`/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<PlaybackOptionsDto> {
const info = await this.fetchJson<PlaybackInfoResponse>(`/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<PlaybackMarkersDto> {
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<PlaybackInfoResponse>(`/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<PlaybackInfoResponse>(`/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<void> {
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<string> {
// 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<PlaybackInfoResponse>(`/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
* <track src>, with no way to share the result between them). Rendered
* client-side as a native <track> (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<void> {
const info = await this.fetchJson<PlaybackInfoResponse>(`/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<T>(
pathAndQuery: string,
session: EmbySession,
errorMessage: string,
options: { preserveEmbyStatus?: boolean } = {}
): Promise<T> {
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();