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`);
|
||||
}
|
||||
|
||||
/** `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 {
|
||||
const entries = Object.entries(options).filter(([, v]) => v !== '' && v != null) as [string, string][];
|
||||
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 {
|
||||
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;
|
||||
// Subtitle tracks only (undefined for audio) - false for an image/
|
||||
// bitmap subtitle (PGS/VobSub/DVB - a Blu-ray/DVD rip's own subtitles,
|
||||
// typically), which this player's forced burn-in (SubtitleMethod=Encode)
|
||||
// cannot actually render - see Player.ts's subtitle <select> population.
|
||||
supportsBurnIn?: boolean;
|
||||
// typically). The player renders subtitles as a native <track> (WebVTT,
|
||||
// converted server-side - see api/embyApi.ts's subtitleUrl and
|
||||
// Player.ts's applySubtitleTrack), which needs actual text; a bitmap
|
||||
// has none to extract.
|
||||
isTextBased?: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackOptionsDto {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import '../../styles/presence.css';
|
||||
import './Player.css';
|
||||
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 type { PartyConnection } from '../../api/party';
|
||||
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
|
||||
// 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
|
||||
// FRESH Emby transcode (see CatalogService.proxyStream/applyPlaybackOverrides).
|
||||
// Confirmed for real: picking a subtitle track forces SubtitleMethod=Encode
|
||||
// (a real re-encode, to actually burn the subtitle in - see
|
||||
// applyPlaybackOverrides's own comment), which measurably took Emby longer
|
||||
// than 10s to produce a first segment for, and hls.js gave up with a fatal
|
||||
// fragLoadTimeOut after its default 4 retries - not a hang, a real but slow
|
||||
// transcode start being mistaken for a dead server. Same config duplicated
|
||||
// in musicBar/MusicBar.ts (its own HLS_CONFIG) rather than shared - the two
|
||||
// players' hls.js setup is already independently duplicated for the same
|
||||
// reason (see that file's own loadTrack).
|
||||
// for this app, where every load/reload (audio track, quality, the
|
||||
// initial load itself) chains through our own proxy into a FRESH Emby
|
||||
// transcode (see CatalogService.proxyStream). Confirmed for real: a
|
||||
// forced re-encode (this used to be how subtitles worked too, before they
|
||||
// moved to a native <track> - see proxyStream's own comment for why)
|
||||
// measurably took Emby longer than 10s to produce a first segment for,
|
||||
// and hls.js gave up with a fatal fragLoadTimeOut after its default 4
|
||||
// retries - not a hang, a real but slow transcode start being mistaken
|
||||
// for a dead server. Same config duplicated in musicBar/MusicBar.ts (its
|
||||
// own HLS_CONFIG) rather than shared - the two players' hls.js setup is
|
||||
// already independently duplicated for the same reason (see that file's
|
||||
// own loadTrack).
|
||||
const HLS_CONFIG: HlsConfig = {
|
||||
manifestLoadingTimeOut: 30000,
|
||||
manifestLoadingMaxRetry: 2,
|
||||
|
|
@ -230,23 +231,52 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
audioSelect.innerHTML = options.audioTracks.length
|
||||
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
|
||||
: '<option value="">Piste par défaut</option>';
|
||||
// supportsBurnIn: false means this is an image/bitmap subtitle (PGS/
|
||||
// VobSub/DVB, typically a Blu-ray/DVD rip) - this player forces a
|
||||
// real burn-in (SubtitleMethod=Encode, see CatalogService) whenever
|
||||
// a subtitle is picked, which only ever works for text subtitles.
|
||||
// isTextBased: false means this is an image/bitmap subtitle (PGS/
|
||||
// VobSub/DVB, typically a Blu-ray/DVD rip) - rendered as a native
|
||||
// <track> (see applySubtitleTrack), which needs real text; a
|
||||
// bitmap has none for Emby to convert to WebVTT (proxySubtitle).
|
||||
// Disabled rather than hidden, so it's clear the track exists and
|
||||
// *why* it can't be picked, instead of silently missing from the
|
||||
// list - confirmed against a real server that picking one of these
|
||||
// doesn't just fail to show, it breaks playback outright (see
|
||||
// docs/architecture.md's player-track-selection section).
|
||||
// list.
|
||||
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) {
|
||||
console.error('[watchparty player] playback-options failed', err);
|
||||
// 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) {
|
||||
currentItemId = itemId;
|
||||
clearError();
|
||||
|
|
@ -257,9 +287,18 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
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, {
|
||||
AudioStreamIndex: audioSelect.value,
|
||||
SubtitleStreamIndex: subtitleSelect.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
|
||||
// the selection in server-side - see CatalogService) - reload at the
|
||||
// same position instead of restarting from zero.
|
||||
// Changing language/quality means a fresh transcode (Emby bakes the
|
||||
// selection in server-side - see CatalogService) - reload at the same
|
||||
// position instead of restarting from zero. Subtitles are NOT part of
|
||||
// this - see the subtitleSelect listener below, and applySubtitleTrack.
|
||||
function reloadWithCurrentOptions() {
|
||||
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
|
||||
}
|
||||
|
||||
[audioSelect, subtitleSelect, qualitySelect].forEach((sel) => {
|
||||
[audioSelect, qualitySelect].forEach((sel) => {
|
||||
sel.addEventListener('change', () => {
|
||||
settingsPanel.classList.add('hide');
|
||||
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', () => {
|
||||
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
|
||||
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
|
||||
|
||||
**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')) });
|
||||
}));
|
||||
|
||||
// Images/video/audio are requested by <img>/<video>/<audio> elements, which
|
||||
// send the session cookie (same-origin) but can't carry a bearer header -
|
||||
// so these three routes check the cookie directly instead of using the
|
||||
// requireSession middleware's JSON error response, which a browser media
|
||||
// element ignores.
|
||||
// Images/video/audio/subtitles are requested by <img>/<video>/<audio>/
|
||||
// <track> elements, which send the session cookie (same-origin) but can't
|
||||
// carry a bearer header - so these four routes check the cookie directly
|
||||
// instead of using the requireSession middleware's JSON error response,
|
||||
// which a browser media element ignores.
|
||||
|
||||
app.get('/api/catalog/image/:itemId', asyncRoute(async (req, res) => {
|
||||
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'));
|
||||
}));
|
||||
|
||||
// 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 ---------------------------------------------------------------
|
||||
// No REST "list parties" endpoint: the home page's "watch parties en cours"
|
||||
// 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) => ({
|
||||
index: s.Index,
|
||||
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).
|
||||
*/
|
||||
async proxyStream(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
|
||||
// AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate (and
|
||||
// SubtitleMethod=Encode, forced by applyPlaybackOverrides whenever a
|
||||
// subtitle is requested - see its own comment below) come from the
|
||||
// player's language/subtitle/quality pickers, applied to the
|
||||
// negotiation itself, not just the final URL. That distinction
|
||||
// matters: whether to actually re-encode (required to burn in a
|
||||
// subtitle) is a decision Emby makes *during* PlaybackInfo, not
|
||||
// something a later query-string tweak on the resulting URL can
|
||||
// retroactively change if Emby already set up a plain remux. Still
|
||||
// not trusted to have taken effect on its own, though (this app never
|
||||
// sends the POST-with-DeviceProfile body real Emby web clients use
|
||||
// for that negotiation) - applyPlaybackOverrides runs a second time
|
||||
// below, on whatever URL results, so a track pick can't silently
|
||||
// no-op regardless of which path Emby's response takes.
|
||||
// AudioStreamIndex/MaxStreamingBitrate come from the player's
|
||||
// language/quality pickers, 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.
|
||||
//
|
||||
// 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.
|
||||
const query = req.query as Record<string, string | undefined>;
|
||||
const infoParams = new URLSearchParams({ UserId: session.userId });
|
||||
this.applyPlaybackOverrides(infoParams, query);
|
||||
|
|
@ -297,34 +305,17 @@ class CatalogServiceClass {
|
|||
}
|
||||
|
||||
/**
|
||||
* Forces this app's own audio/subtitle/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.
|
||||
*
|
||||
* SubtitleMethod=Encode is forced whenever a subtitle track is
|
||||
* 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.
|
||||
* 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
|
||||
* proxyStream's own comment for why that moved to proxySubtitle instead.
|
||||
*/
|
||||
private applyPlaybackOverrides(params: URLSearchParams, query: Record<string, string | undefined>): void {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -338,12 +329,34 @@ class CatalogServiceClass {
|
|||
Logger.stream(
|
||||
`master.m3u8 itemId=${itemId} source=${source} ` +
|
||||
`audio=${params.get('AudioStreamIndex') || '(default)'} ` +
|
||||
`subtitle=${params.get('SubtitleStreamIndex') || '(none)'} ` +
|
||||
`subtitleMethod=${params.get('SubtitleMethod') || '(none)'} ` +
|
||||
`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,
|
||||
|
|
|
|||
|
|
@ -139,10 +139,12 @@ export interface PlaybackTrackOption {
|
|||
index: number;
|
||||
label: string;
|
||||
// Subtitle tracks only (undefined for audio - the concept doesn't apply).
|
||||
// false means this app's forced SubtitleMethod=Encode (see
|
||||
// CatalogService.applyPlaybackOverrides) cannot actually work for this
|
||||
// track - see IMAGE_SUBTITLE_CODECS's own comment for why.
|
||||
supportsBurnIn?: boolean;
|
||||
// false means this is an image/bitmap subtitle (PGS/VobSub/DVB, typically
|
||||
// a Blu-ray/DVD rip) - see IMAGE_SUBTITLE_CODECS's own comment. This app
|
||||
// renders subtitles as a native <track> (WebVTT, converted server-side
|
||||
// from whatever the source format is - see CatalogService.proxySubtitle),
|
||||
// which needs actual text; a bitmap has none to extract.
|
||||
isTextBased?: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackOptionsDto {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Exercises CatalogService's real logic (library-type filtering, the
|
||||
// audio/subtitle/quality override forcing) through its PUBLIC methods
|
||||
// against a mocked EmbyClient, not by calling private helpers directly.
|
||||
// audio/quality override forcing, subtitle proxying) through its PUBLIC
|
||||
// methods against a mocked EmbyClient, not by calling private helpers directly.
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Request, Response } from 'express';
|
||||
|
|
@ -41,7 +41,7 @@ describe('CatalogService', () => {
|
|||
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({
|
||||
MediaSources: [{ Id: 'src1', TranscodingUrl: '/videos/item1/master.m3u8?MediaSourceId=src1&AudioStreamIndex=1' }],
|
||||
PlaySessionId: 'sess1'
|
||||
|
|
@ -49,11 +49,10 @@ describe('CatalogService', () => {
|
|||
let capturedTarget = '';
|
||||
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('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) => {
|
||||
|
|
@ -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({
|
||||
MediaSources: [{
|
||||
Id: 'src1',
|
||||
|
|
@ -102,8 +101,27 @@ describe('CatalogService', () => {
|
|||
|
||||
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 === 3)?.supportsBurnIn, false, 'PGS is a bitmap - this player\'s forced Encode can\'t render it');
|
||||
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.supportsBurnIn, true, 'ass is text too');
|
||||
assert.equal(options.subtitleTracks.find((t) => t.index === 2)?.isTextBased, true, 'subrip is text');
|
||||
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)?.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