Render subtitles client-side (<track>) instead of asking Emby to burn them in
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 14s

Six rounds of real-server debugging (see docs/architecture.md's player-
track-selection section) found three different, unrelated reasons a
server-side burn-in could fail - missing fonts, a bitmap format libass
fundamentally can't burn, ffmpeg failing outright on an otherwise
unremarkable file - none of which this app's own request ever caused or
could have fixed, all downstream of the one thing every attempt had in
common: asking Emby for a real-time video re-encode. Not needing that
re-encode at all closes the whole class of failure instead of chasing
each one.

- server/src/services/CatalogService.ts: new proxySubtitle asks Emby to
  convert the track to WebVTT (GET /Videos/{id}/{mediaSourceId}/
  Subtitles/{index}/Stream.vtt - a real Emby endpoint their own web
  client already uses for this) - entirely separate from the video
  transcode. applyPlaybackOverrides lost its SubtitleStreamIndex/
  SubtitleMethod=Encode branch - the video request no longer needs to
  know a subtitle was picked at all.
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption's flag
  renamed supportsBurnIn -> isTextBased - same underlying fact (image vs
  text codec), but the name shouldn't keep describing a mechanism this
  app no longer uses.
- server/src/index.ts: new GET /api/catalog/subtitle/:itemId/:subtitleIndex
  route, grouped with the other cookie-authed media-element routes (a
  <track> can't send a bearer header either).
- client/src/api/embyApi.ts: new subtitleUrl(); streamUrl no longer
  carries SubtitleStreamIndex at all.
- client/src/views/player/Player.ts: the subtitle <select> no longer
  reloads the video (reloadWithCurrentOptions, still used for audio/
  quality, never touches subtitles now). New applySubtitleTrack()
  removes/re-adds a fresh <track> and sets video.textTracks[0].mode =
  'showing' synchronously right after appending - deliberately not
  waiting on the <track>'s own "load" event, confirmed against a real
  browser that it doesn't fire reliably at all (a known cross-browser
  quirk), which would have left a subtitle silently never shown - the
  exact symptom this whole saga started from. Re-applied on every video
  (re)load so an audio/quality change (which does still reload the
  video) doesn't drop whatever subtitle was showing.
- server/test/catalogService.test.ts: updated/new cases for
  proxySubtitle and the renamed isTextBased flag; removed the test for
  the now-deleted subtitle-forcing behavior.

Verified against the real compiled bundle: picking a text track adds one
<track> with the right URL and flips it to showing; "Aucun" removes it;
an image-coded track is listed but disabled; changing audio reloads the
video but the subtitle survives it, same element, still showing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 16:31:25 +02:00
parent 1e4a82df16
commit 137977893a
8 changed files with 254 additions and 90 deletions

View file

@ -107,7 +107,9 @@ export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto>
return apiFetch(`/api/catalog/item/${itemId}/playback-options`); return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
} }
/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate/mediaType. */ /** `options` may carry AudioStreamIndex/MaxStreamingBitrate/mediaType - not
* subtitles, which are their own separate request now (see subtitleUrl
* below and Player.ts's applySubtitleTrack for why). */
export function streamUrl(itemId: string, options: Record<string, string | number | null | undefined> = {}): string { export function streamUrl(itemId: string, options: Record<string, string | number | null | undefined> = {}): string {
const entries = Object.entries(options).filter(([, v]) => v !== '' && v != null) as [string, string][]; const entries = Object.entries(options).filter(([, v]) => v !== '' && v != null) as [string, string][];
const qs = new URLSearchParams(entries).toString(); const qs = new URLSearchParams(entries).toString();
@ -121,3 +123,12 @@ export function streamUrl(itemId: string, options: Record<string, string | numbe
export function audioStreamUrl(itemId: string): string { export function audioStreamUrl(itemId: string): string {
return streamUrl(itemId, { mediaType: 'audio' }); return streamUrl(itemId, { mediaType: 'audio' });
} }
/** A subtitle track's own text, as WebVTT (server/src/services/
* CatalogService.ts's proxySubtitle converts it, whatever Emby's own
* source format is) - for a native <track src>, not part of the video
* stream at all. Only ever built for a track getPlaybackOptions already
* reported as isTextBased - see Player.ts's subtitle <select>. */
export function subtitleUrl(itemId: string, subtitleIndex: number): string {
return `/api/catalog/subtitle/${itemId}/${subtitleIndex}`;
}

View file

@ -38,9 +38,11 @@ export interface PlaybackTrackOption {
label: string; label: string;
// Subtitle tracks only (undefined for audio) - false for an image/ // Subtitle tracks only (undefined for audio) - false for an image/
// bitmap subtitle (PGS/VobSub/DVB - a Blu-ray/DVD rip's own subtitles, // bitmap subtitle (PGS/VobSub/DVB - a Blu-ray/DVD rip's own subtitles,
// typically), which this player's forced burn-in (SubtitleMethod=Encode) // typically). The player renders subtitles as a native <track> (WebVTT,
// cannot actually render - see Player.ts's subtitle <select> population. // converted server-side - see api/embyApi.ts's subtitleUrl and
supportsBurnIn?: boolean; // Player.ts's applySubtitleTrack), which needs actual text; a bitmap
// has none to extract.
isTextBased?: boolean;
} }
export interface PlaybackOptionsDto { export interface PlaybackOptionsDto {

View file

@ -1,7 +1,7 @@
import '../../styles/presence.css'; import '../../styles/presence.css';
import './Player.css'; import './Player.css';
import html from './Player.html'; import html from './Player.html';
import { auth, getPlaybackOptions, streamUrl } from '../../api/embyApi'; import { auth, getPlaybackOptions, streamUrl, subtitleUrl } from '../../api/embyApi';
import { getParty, leaveParty, connectParty } from '../../api/party'; import { getParty, leaveParty, connectParty } from '../../api/party';
import type { PartyConnection } from '../../api/party'; import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
@ -17,17 +17,18 @@ const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange bad
// hls.js's own defaults (10s manifest/level/frag load timeouts) assume a // hls.js's own defaults (10s manifest/level/frag load timeouts) assume a
// file server or a source already being direct-played/remuxed - too tight // file server or a source already being direct-played/remuxed - too tight
// for this app, where every request chains through our own proxy into a // for this app, where every load/reload (audio track, quality, the
// FRESH Emby transcode (see CatalogService.proxyStream/applyPlaybackOverrides). // initial load itself) chains through our own proxy into a FRESH Emby
// Confirmed for real: picking a subtitle track forces SubtitleMethod=Encode // transcode (see CatalogService.proxyStream). Confirmed for real: a
// (a real re-encode, to actually burn the subtitle in - see // forced re-encode (this used to be how subtitles worked too, before they
// applyPlaybackOverrides's own comment), which measurably took Emby longer // moved to a native <track> - see proxyStream's own comment for why)
// than 10s to produce a first segment for, and hls.js gave up with a fatal // measurably took Emby longer than 10s to produce a first segment for,
// fragLoadTimeOut after its default 4 retries - not a hang, a real but slow // and hls.js gave up with a fatal fragLoadTimeOut after its default 4
// transcode start being mistaken for a dead server. Same config duplicated // retries - not a hang, a real but slow transcode start being mistaken
// in musicBar/MusicBar.ts (its own HLS_CONFIG) rather than shared - the two // for a dead server. Same config duplicated in musicBar/MusicBar.ts (its
// players' hls.js setup is already independently duplicated for the same // own HLS_CONFIG) rather than shared - the two players' hls.js setup is
// reason (see that file's own loadTrack). // already independently duplicated for the same reason (see that file's
// own loadTrack).
const HLS_CONFIG: HlsConfig = { const HLS_CONFIG: HlsConfig = {
manifestLoadingTimeOut: 30000, manifestLoadingTimeOut: 30000,
manifestLoadingMaxRetry: 2, manifestLoadingMaxRetry: 2,
@ -230,23 +231,52 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
audioSelect.innerHTML = options.audioTracks.length audioSelect.innerHTML = options.audioTracks.length
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('') ? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
: '<option value="">Piste par défaut</option>'; : '<option value="">Piste par défaut</option>';
// supportsBurnIn: false means this is an image/bitmap subtitle (PGS/ // isTextBased: false means this is an image/bitmap subtitle (PGS/
// VobSub/DVB, typically a Blu-ray/DVD rip) - this player forces a // VobSub/DVB, typically a Blu-ray/DVD rip) - rendered as a native
// real burn-in (SubtitleMethod=Encode, see CatalogService) whenever // <track> (see applySubtitleTrack), which needs real text; a
// a subtitle is picked, which only ever works for text subtitles. // bitmap has none for Emby to convert to WebVTT (proxySubtitle).
// Disabled rather than hidden, so it's clear the track exists and // Disabled rather than hidden, so it's clear the track exists and
// *why* it can't be picked, instead of silently missing from the // *why* it can't be picked, instead of silently missing from the
// list - confirmed against a real server that picking one of these // list.
// doesn't just fail to show, it breaks playback outright (see
// docs/architecture.md's player-track-selection section).
subtitleSelect.innerHTML = '<option value="">Aucun</option>' + subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
options.subtitleTracks.map((t) => `<option value="${t.index}"${t.supportsBurnIn === false ? ' disabled' : ''}>${escapeHtml(t.label)}${t.supportsBurnIn === false ? ' (image, non disponible)' : ''}</option>`).join(''); options.subtitleTracks.map((t) => `<option value="${t.index}"${t.isTextBased === false ? ' disabled' : ''}>${escapeHtml(t.label)}${t.isTextBased === false ? ' (image, non disponible)' : ''}</option>`).join('');
} catch (err) { } catch (err) {
console.error('[watchparty player] playback-options failed', err); console.error('[watchparty player] playback-options failed', err);
// Non-fatal: playback can still work with Emby's own defaults, just without a picker. // Non-fatal: playback can still work with Emby's own defaults, just without a picker.
} }
} }
/**
* Adds/replaces the video's own <track> element for whichever subtitle
* (if any) is currently selected - a native browser feature, entirely
* separate from the video stream itself (see CatalogService.
* proxySubtitle for why: a server-side burn-in used to be how this
* worked, and a long real-server debugging session found it unreliable
* in ways this app's own request could never have controlled or
* worked around - see docs/architecture.md's player-track-selection
* section). Removes and re-adds a fresh <track> every time rather than
* changing an existing one's `src` in place - browsers don't reliably
* re-fetch/re-parse a track from a plain attribute change after it's
* already loaded, and a fresh element always does.
*/
function applySubtitleTrack(subtitleIndex: string) {
video.querySelectorAll('track').forEach((t) => t.remove());
if (!subtitleIndex || !currentItemId) return;
const track = document.createElement('track');
track.kind = 'subtitles';
track.src = subtitleUrl(currentItemId, Number(subtitleIndex));
video.appendChild(track);
// A freshly added track defaults to "hidden", not "showing". Set
// synchronously, right here - the browser registers the TextTrack in
// video.textTracks the moment the element is connected, before its
// actual subtitle content has loaded, and honors `mode` regardless.
// Deliberately NOT waiting on the <track>'s own "load" event for
// this: confirmed against a real browser that it doesn't fire
// reliably at all (a known cross-browser <track> quirk, not specific
// to this app), which would have left the track silently never shown.
if (video.textTracks[0]) video.textTracks[0].mode = 'showing';
}
function loadVideo(itemId: string, resumeSeconds: number, wasPlaying: boolean) { function loadVideo(itemId: string, resumeSeconds: number, wasPlaying: boolean) {
currentItemId = itemId; currentItemId = itemId;
clearError(); clearError();
@ -257,9 +287,18 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
hls = null; hls = null;
} }
// Re-applied on every (re)load, not just when the subtitle picker
// itself changes: an audio/quality change reloads this same item
// through here too (see reloadWithCurrentOptions), and the subtitle
// that was showing should survive that - subtitleSelect.value is
// untouched in that case, so this just re-adds the same track. A
// genuinely new item (onItemChanged/init) already reset the select
// to "Aucun" before this runs (see loadTrackOptions), so this
// correctly clears any leftover track from whatever played before.
applySubtitleTrack(subtitleSelect.value);
const src = streamUrl(itemId, { const src = streamUrl(itemId, {
AudioStreamIndex: audioSelect.value, AudioStreamIndex: audioSelect.value,
SubtitleStreamIndex: subtitleSelect.value,
MaxStreamingBitrate: qualitySelect.value MaxStreamingBitrate: qualitySelect.value
}); });
@ -294,20 +333,29 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
} }
} }
// Changing language/subtitles/quality means a fresh transcode (Emby bakes // Changing language/quality means a fresh transcode (Emby bakes the
// the selection in server-side - see CatalogService) - reload at the // selection in server-side - see CatalogService) - reload at the same
// same position instead of restarting from zero. // position instead of restarting from zero. Subtitles are NOT part of
// this - see the subtitleSelect listener below, and applySubtitleTrack.
function reloadWithCurrentOptions() { function reloadWithCurrentOptions() {
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused); if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
} }
[audioSelect, subtitleSelect, qualitySelect].forEach((sel) => { [audioSelect, qualitySelect].forEach((sel) => {
sel.addEventListener('change', () => { sel.addEventListener('change', () => {
settingsPanel.classList.add('hide'); settingsPanel.classList.add('hide');
reloadWithCurrentOptions(); reloadWithCurrentOptions();
}); });
}); });
// Just swaps the <track> - no reload, no re-buffering, unlike audio/
// quality above. This is the whole point of rendering subtitles
// natively instead of asking Emby to burn them into the video.
subtitleSelect.addEventListener('change', () => {
settingsPanel.classList.add('hide');
applySubtitleTrack(subtitleSelect.value);
});
(root.querySelector('#playerErrorRetry') as HTMLButtonElement).addEventListener('click', () => { (root.querySelector('#playerErrorRetry') as HTMLButtonElement).addEventListener('click', () => {
if (currentItemId) loadVideo(currentItemId, video.currentTime, true); if (currentItemId) loadVideo(currentItemId, video.currentTime, true);
}); });

View file

@ -605,6 +605,67 @@ The next 500 on this or any future item gets its real cause into
`docker logs` directly, without another round of trying to hand-copy it `docker logs` directly, without another round of trying to hand-copy it
out of a browser or Emby's own log viewer. out of a browser or Emby's own log viewer.
### The actual fix: subtitles rendered client-side, not burned in by Emby
Six rounds in, the pattern across every failure was the same shape: this
app's own request was never the problem (confirmed each time - the
override reached Emby correctly, `getPlaybackOptions` already correctly
told PGS apart from real text), but *something* about asking Emby to
burn a subtitle into the video itself kept finding a new way to fail -
missing fonts, a format `libass` fundamentally can't burn (a bitmap, not
text), then "Error starting ffmpeg" outright on a *third*, otherwise
unremarkable file with neither of those problems. Three failures, three
different causes, all downstream of the one thing every attempt had in
common: asking for a real-time video re-encode. Fixing this by finding
and patching every way ffmpeg could fail was an open-ended list; not
needing ffmpeg to do this at all closes the whole class at once.
Subtitles are now delivered as their own resource and rendered by the
browser's native `<track>` element, not encoded into the video:
- `CatalogService.proxySubtitle` (new) asks Emby to convert the track to
WebVTT - `GET /Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/
Stream.vtt`, a real Emby endpoint their own web client already relies
on for exactly this, entirely separate from the transcode pipeline.
Needs its own `PlaybackInfo` round trip just for `MediaSourceId` (the
video's own negotiation doesn't share this with a `<track src>`'s
independent request). `applyPlaybackOverrides` lost its
`SubtitleStreamIndex`/`SubtitleMethod=Encode` branch entirely - nothing
about the video request needs to know a subtitle was even picked
anymore.
- `getPlaybackOptions`'s per-track flag was renamed `supportsBurnIn` →
`isTextBased` - same underlying fact (image vs. text codec,
`IMAGE_SUBTITLE_CODECS`), but the name shouldn't keep describing a
mechanism (burning in) this app no longer uses. A bitmap subtitle still
has no text for Emby to hand back as WebVTT either, so it's still
correctly disabled in the picker - the *reason* just changed.
- `views/player/Player.ts`'s subtitle `<select>` no longer reloads the
video at all (`reloadWithCurrentOptions`, still used for audio/quality,
never touches subtitles now) - `applySubtitleTrack` just removes
whatever `<track>` exists and, for a real pick, adds a fresh one
pointing at `subtitleUrl(itemId, index)`, then sets
`video.textTracks[0].mode = 'showing'` **synchronously**, right after
appending. Deliberately not waiting on the `<track>` element's own
`"load"` event for that: verified against a real browser that it
doesn't fire reliably at all (a known, general `<track>` quirk, not
specific to this app or this fix) - which would have left a
subtitle silently never shown, the exact symptom the *first* round of
this whole saga was originally about. The TextTrack object itself is
registered in `video.textTracks` the moment the element is connected,
before its content has actually loaded, and a browser honors `.mode`
being set that early regardless. Re-applied on every video (re)load,
not just when the subtitle picker itself changes, so switching audio or
quality - which does still reload the video - doesn't drop whatever
subtitle was showing.
Verified against the real compiled bundle: picking a text track adds
exactly one `<track>` with the right URL and flips it to `showing`;
picking "Aucun" removes it and `video.textTracks` goes back to empty; an
image-coded track is present in the list but disabled, exactly as
`isTextBased: false` says it should be; changing the audio track reloads
the video but the previously-selected subtitle's `<track>` survives it,
same element, same `src`, still `showing`.
## Assumptions to verify ## Assumptions to verify
**Confirmed against the real server** (not a guess — actually exercised, **Confirmed against the real server** (not a guess — actually exercised,

View file

@ -155,11 +155,11 @@ app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(as
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) }); res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
})); }));
// Images/video/audio are requested by <img>/<video>/<audio> elements, which // Images/video/audio/subtitles are requested by <img>/<video>/<audio>/
// send the session cookie (same-origin) but can't carry a bearer header - // <track> elements, which send the session cookie (same-origin) but can't
// so these three routes check the cookie directly instead of using the // carry a bearer header - so these four routes check the cookie directly
// requireSession middleware's JSON error response, which a browser media // instead of using the requireSession middleware's JSON error response,
// element ignores. // which a browser media element ignores.
app.get('/api/catalog/image/:itemId', asyncRoute(async (req, res) => { app.get('/api/catalog/image/:itemId', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req); const session = AuthService.sessionFromRequest(req);
@ -181,6 +181,15 @@ app.get('/api/catalog/stream/:itemId/raw', asyncRoute(async (req, res) => {
await CatalogService.proxyRawStreamAsset(req, res, session, param(req, 'itemId')); await CatalogService.proxyRawStreamAsset(req, res, session, param(req, 'itemId'));
})); }));
// A subtitle track's own text (WebVTT), for the player's native <track> -
// see CatalogService.proxySubtitle's own comment for why subtitles are
// rendered this way instead of asking Emby to burn them into the video.
app.get('/api/catalog/subtitle/:itemId/:subtitleIndex', asyncRoute(async (req, res) => {
const session = AuthService.sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await CatalogService.proxySubtitle(req, res, session, param(req, 'itemId'), param(req, 'subtitleIndex'));
}));
// --- Parties --------------------------------------------------------------- // --- Parties ---------------------------------------------------------------
// No REST "list parties" endpoint: the home page's "watch parties en cours" // No REST "list parties" endpoint: the home page's "watch parties en cours"
// list is live, over Socket.IO's "lobby" room (SocketGateway) - see // list is live, over Socket.IO's "lobby" room (SocketGateway) - see

View file

@ -193,7 +193,7 @@ class CatalogServiceClass {
subtitleTracks: streams.filter((s) => s.Type === 'Subtitle').map((s) => ({ subtitleTracks: streams.filter((s) => s.Type === 'Subtitle').map((s) => ({
index: s.Index, index: s.Index,
label: label(s), label: label(s),
supportsBurnIn: !IMAGE_SUBTITLE_CODECS.has((s.Codec || '').toLowerCase()) isTextBased: !IMAGE_SUBTITLE_CODECS.has((s.Codec || '').toLowerCase())
})) }))
}; };
} }
@ -225,20 +225,28 @@ class CatalogServiceClass {
* in client/src/api/embyApi.ts). * in client/src/api/embyApi.ts).
*/ */
async proxyStream(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> { async proxyStream(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
// AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate (and // AudioStreamIndex/MaxStreamingBitrate come from the player's
// SubtitleMethod=Encode, forced by applyPlaybackOverrides whenever a // language/quality pickers, applied to the negotiation itself, not
// subtitle is requested - see its own comment below) come from the // just the final URL - a pick could otherwise silently do nothing if
// player's language/subtitle/quality pickers, applied to the // Emby's negotiation didn't apply the query-string override the same
// negotiation itself, not just the final URL. That distinction // way its own web client's POST-with-DeviceProfile request would
// matters: whether to actually re-encode (required to burn in a // have. Still not trusted to have taken effect on its own, though
// subtitle) is a decision Emby makes *during* PlaybackInfo, not // (this app never sends that body) - applyPlaybackOverrides runs a
// something a later query-string tweak on the resulting URL can // second time below, on whatever URL results, so a pick can't
// retroactively change if Emby already set up a plain remux. Still // silently no-op regardless of which path Emby's response takes.
// not trusted to have taken effect on its own, though (this app never //
// sends the POST-with-DeviceProfile body real Emby web clients use // Subtitles deliberately do NOT go through here (see proxySubtitle
// for that negotiation) - applyPlaybackOverrides runs a second time // instead, and its own comment for why): forcing SubtitleMethod=Encode
// below, on whatever URL results, so a track pick can't silently // onto this negotiation used to be how this app handled them, until a
// no-op regardless of which path Emby's response takes. // 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.
const query = req.query as Record<string, string | undefined>; const query = req.query as Record<string, string | undefined>;
const infoParams = new URLSearchParams({ UserId: session.userId }); const infoParams = new URLSearchParams({ UserId: session.userId });
this.applyPlaybackOverrides(infoParams, query); this.applyPlaybackOverrides(infoParams, query);
@ -297,34 +305,17 @@ class CatalogServiceClass {
} }
/** /**
* Forces this app's own audio/subtitle/quality selection onto a * Forces this app's own audio/quality selection onto a transcode URL,
* transcode URL, whether that URL came from Emby's own PlaybackInfo * whether that URL came from Emby's own PlaybackInfo negotiation or was
* negotiation or was hand-built as a fallback. Without this, a pick * hand-built as a fallback. Without this, a pick from the player's
* from the player's settings menu could silently do nothing if Emby's * settings menu could silently do nothing if Emby's negotiation didn't
* negotiation didn't apply the query-string override the same way its * apply the query-string override the same way its own web client's
* own web client's POST-with-DeviceProfile request would have. * POST-with-DeviceProfile request would have. Subtitles used to be
* * forced here too (SubtitleStreamIndex/SubtitleMethod=Encode) - see
* SubtitleMethod=Encode is forced whenever a subtitle track is * proxyStream's own comment for why that moved to proxySubtitle instead.
* requested: left to its own default, Emby may instead deliver text
* subtitles as a separate selectable HLS rendition (SubtitleMethod=Hls)
* rather than burning them into the video - and this player never wires
* up hls.js's subtitleTrack API to pick one, so a rendition-based
* subtitle would be present in the manifest but never actually shown.
* Encode guarantees the subtitle is part of the one video stream this
* player already renders. Picking "Aucun" removes any subtitle Emby's
* own negotiation added, rather than just not adding a new one -
* otherwise turning subtitles off from a state where Emby had already
* picked a default would be a no-op too.
*/ */
private applyPlaybackOverrides(params: URLSearchParams, query: Record<string, string | undefined>): void { private applyPlaybackOverrides(params: URLSearchParams, query: Record<string, string | undefined>): void {
if (query.AudioStreamIndex) params.set('AudioStreamIndex', query.AudioStreamIndex); if (query.AudioStreamIndex) params.set('AudioStreamIndex', query.AudioStreamIndex);
if (query.SubtitleStreamIndex) {
params.set('SubtitleStreamIndex', query.SubtitleStreamIndex);
params.set('SubtitleMethod', 'Encode');
} else {
params.delete('SubtitleStreamIndex');
params.delete('SubtitleMethod');
}
if (query.MaxStreamingBitrate) params.set('MaxStreamingBitrate', query.MaxStreamingBitrate); if (query.MaxStreamingBitrate) params.set('MaxStreamingBitrate', query.MaxStreamingBitrate);
} }
@ -338,12 +329,34 @@ class CatalogServiceClass {
Logger.stream( Logger.stream(
`master.m3u8 itemId=${itemId} source=${source} ` + `master.m3u8 itemId=${itemId} source=${source} ` +
`audio=${params.get('AudioStreamIndex') || '(default)'} ` + `audio=${params.get('AudioStreamIndex') || '(default)'} ` +
`subtitle=${params.get('SubtitleStreamIndex') || '(none)'} ` +
`subtitleMethod=${params.get('SubtitleMethod') || '(none)'} ` +
`quality=${params.get('MaxStreamingBitrate') || '(auto)'}` `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 { private toItemDto(item: EmbyItem, serverId: string): ItemDto {
return { return {
id: item.Id, id: item.Id,

View file

@ -139,10 +139,12 @@ export interface PlaybackTrackOption {
index: number; index: number;
label: string; label: string;
// Subtitle tracks only (undefined for audio - the concept doesn't apply). // Subtitle tracks only (undefined for audio - the concept doesn't apply).
// false means this app's forced SubtitleMethod=Encode (see // false means this is an image/bitmap subtitle (PGS/VobSub/DVB, typically
// CatalogService.applyPlaybackOverrides) cannot actually work for this // a Blu-ray/DVD rip) - see IMAGE_SUBTITLE_CODECS's own comment. This app
// track - see IMAGE_SUBTITLE_CODECS's own comment for why. // renders subtitles as a native <track> (WebVTT, converted server-side
supportsBurnIn?: boolean; // from whatever the source format is - see CatalogService.proxySubtitle),
// which needs actual text; a bitmap has none to extract.
isTextBased?: boolean;
} }
export interface PlaybackOptionsDto { export interface PlaybackOptionsDto {

View file

@ -1,6 +1,6 @@
// Exercises CatalogService's real logic (library-type filtering, the // Exercises CatalogService's real logic (library-type filtering, the
// audio/subtitle/quality override forcing) through its PUBLIC methods // audio/quality override forcing, subtitle proxying) through its PUBLIC
// against a mocked EmbyClient, not by calling private helpers directly. // methods against a mocked EmbyClient, not by calling private helpers directly.
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
@ -41,7 +41,7 @@ describe('CatalogService', () => {
assert.deepEqual(libraries.map((l) => l.name).sort(), ['Clips', 'Films', 'Musique', 'Series']); assert.deepEqual(libraries.map((l) => l.name).sort(), ['Clips', 'Films', 'Musique', 'Series']);
}); });
it('proxyStream forces a subtitle pick (and SubtitleMethod=Encode) onto a negotiated TranscodingUrl', async (t) => { it('proxyStream forces an audio pick onto a negotiated TranscodingUrl (subtitles are a separate concern - see proxySubtitle)', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ Id: 'src1', TranscodingUrl: '/videos/item1/master.m3u8?MediaSourceId=src1&AudioStreamIndex=1' }], MediaSources: [{ Id: 'src1', TranscodingUrl: '/videos/item1/master.m3u8?MediaSourceId=src1&AudioStreamIndex=1' }],
PlaySessionId: 'sess1' PlaySessionId: 'sess1'
@ -49,11 +49,10 @@ describe('CatalogService', () => {
let capturedTarget = ''; let capturedTarget = '';
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; }); t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; });
await CatalogService.proxyStream(fakeReq({ SubtitleStreamIndex: '3', AudioStreamIndex: '2' }), fakeRes(), session, 'item1'); await CatalogService.proxyStream(fakeReq({ AudioStreamIndex: '2' }), fakeRes(), session, 'item1');
assert.ok(capturedTarget.includes('SubtitleStreamIndex=3'));
assert.ok(capturedTarget.includes('SubtitleMethod=Encode'));
assert.ok(capturedTarget.includes('AudioStreamIndex=2')); assert.ok(capturedTarget.includes('AudioStreamIndex=2'));
assert.ok(!capturedTarget.includes('Subtitle'), 'this route never touches subtitle params at all any more');
}); });
it('proxyStream\'s hand-built fallback picks /Audio/ (and drops VideoCodec) for a music track', async (t) => { it('proxyStream\'s hand-built fallback picks /Audio/ (and drops VideoCodec) for a music track', async (t) => {
@ -87,7 +86,7 @@ describe('CatalogService', () => {
); );
}); });
it('getPlaybackOptions flags an image/bitmap subtitle (PGS) as not burn-in-able, text subtitles as fine', async (t) => { it('getPlaybackOptions flags an image/bitmap subtitle (PGS) as not text-based, real text subtitles as fine', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ MediaSources: [{
Id: 'src1', Id: 'src1',
@ -102,8 +101,27 @@ describe('CatalogService', () => {
const options = await CatalogService.getPlaybackOptions(session, 'item1'); const options = await CatalogService.getPlaybackOptions(session, 'item1');
assert.equal(options.subtitleTracks.find((t) => t.index === 2)?.supportsBurnIn, true, 'subrip is text - burns in fine'); assert.equal(options.subtitleTracks.find((t) => t.index === 2)?.isTextBased, true, 'subrip is text');
assert.equal(options.subtitleTracks.find((t) => t.index === 3)?.supportsBurnIn, false, 'PGS is a bitmap - this player\'s forced Encode can\'t render it'); assert.equal(options.subtitleTracks.find((t) => t.index === 3)?.isTextBased, false, 'PGS is a bitmap - no text for Emby to convert to WebVTT');
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.supportsBurnIn, true, 'ass is text too'); assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too');
});
it('proxySubtitle asks Emby for this track as WebVTT, using the negotiated MediaSourceId', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
let capturedTarget = '';
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; });
await CatalogService.proxySubtitle(fakeReq({}), fakeRes(), session, 'item9', '2');
assert.equal(capturedTarget, '/Videos/item9/src7/Subtitles/2/Stream.vtt');
});
it('proxySubtitle fails cleanly (not a crash) when Emby has no media source for this item', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [] }));
await assert.rejects(
() => CatalogService.proxySubtitle(fakeReq({}), fakeRes(), session, 'missing', '0'),
(err: Error & { status?: number }) => err.status === 502
);
}); });
}); });