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>
571 lines
30 KiB
TypeScript
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();
|