Render subtitles client-side (<track>) instead of asking Emby to burn them in
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:
parent
1e4a82df16
commit
137977893a
8 changed files with 254 additions and 90 deletions
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue