Fix casting past the first segment, and the toolbar's real auto-hide bug
Two bugs reported together, both surfaced by actually trying this for real. Casting: "the .m3u8 needs to be passed instead of a raw [url]". The master playlist itself was fine - the initial master.m3u8?castToken=... request already carries the token. What broke immediately after: every URI line inside that playlist gets rewritten (EmbyClient.rewritePlaylist) to route back through this app's own party-stream/:partyId/raw?u=... proxy, and that rewrite never carried the castToken forward. A browser never noticed (it's still sending its cookie regardless of the URL), but a Chromecast has no cookie to fall back on, so every segment/variant- playlist fetch after the first one 401'd, breaking playback moments after the receiver appeared to connect. Fixed by threading the same castToken into every rewritten line (rewritePlaylist(text, routeKey, castToken?), sourced from req.query.castToken right inside proxyResponse). The self-hosted burn-in path had the identical gap for a different reason - ffmpeg's own bare relative segment filenames drop any query string when resolved against a URL that had one, per ordinary URL-resolution rules - fixed the same way with a new pure appendCastTokenToSegments(text, castToken), extracted specifically so it's unit-testable without a real ffmpeg session. Player toolbar: "still doesn't work, mobile and PC" - the auto-hide fix from two commits ago hadn't actually landed for anyone. Real bug, not a browser quirk this time: becomeHost's 3-second heartbeat got wired to the same onDriverStateChanged function real discrete play/pause/seeked events use, which also re-arms the auto-hide countdown - correct for a genuine event, wrong for a heartbeat that fires unconditionally every 3s regardless of any real change. With the hide delay also at 3000ms, every heartbeat tick cancelled and restarted the countdown on the exact cadence it needed to run uninterrupted to ever complete - so for a host, on any platform, the bar could never actually finish hiding. Fixed by giving the heartbeat its own function, reportHeartbeat - same state sync, without the scheduleControlsHide call. Verified: full typecheck/test/build (122/122 server tests, including new coverage for rewritePlaylist's token propagation - had none at all before this - and appendCastTokenToSegments). Confirmed the heartbeat fix directly: 3 simulated heartbeat ticks call the hide-timer's reset zero times, one simulated real event still calls it once. Not verified: a real Chromecast fetching real rewritten segment URLs - no device reachable from this sandbox. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
cf82ea5c2d
commit
62b0abd572
6 changed files with 230 additions and 10 deletions
|
|
@ -432,12 +432,39 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
* session's RemotePlayerController events (startCasting's own
|
||||
* onRemoteStateChanged callback) alike - so the button icon, the
|
||||
* seek bar, and the party broadcast all stay in lockstep regardless
|
||||
* of which driver or which gesture actually caused the change. */
|
||||
* of which driver or which gesture actually caused the change.
|
||||
* Re-arms the auto-hide countdown too (e.g. resuming from pause must
|
||||
* restart it, not leave the bar stuck open forever) - deliberately
|
||||
* NOT used for the periodic heartbeat below, which needs the same
|
||||
* state-sync but must NOT touch the countdown - see its own comment
|
||||
* for the real bug that came from getting this mixed up once. */
|
||||
function onDriverStateChanged() {
|
||||
updatePlayPauseBtn();
|
||||
updateProgressBar();
|
||||
sendLocalState();
|
||||
scheduleControlsHide(); // e.g. resuming from pause must re-arm the countdown, not leave the bar stuck open forever
|
||||
scheduleControlsHide();
|
||||
}
|
||||
|
||||
/**
|
||||
* The 3s heartbeat's own tick - reported "still playing at position
|
||||
* X" regardless of whether anything actually changed, unlike every
|
||||
* other caller of onDriverStateChanged above, which only ever fires
|
||||
* on a genuine discrete event. Deliberately does NOT call
|
||||
* scheduleControlsHide(): a real bug, not a hypothetical one - with
|
||||
* CONTROLS_HIDE_DELAY_MS also at 3000ms, wiring the heartbeat straight
|
||||
* to onDriverStateChanged meant it re-armed the auto-hide countdown
|
||||
* on the same cadence the countdown itself needed to elapse
|
||||
* uninterrupted to ever fire - so for a host, the bar could never
|
||||
* auto-hide at all, on any platform, unless they happened to stop
|
||||
* touching the player at exactly the right phase relative to the
|
||||
* heartbeat's own timer. icon/progress bar are cheap to refresh here
|
||||
* too (the dedicated progressTicker already does the latter far more
|
||||
* often, so this is only a safety net for the former).
|
||||
*/
|
||||
function reportHeartbeat() {
|
||||
updatePlayPauseBtn();
|
||||
updateProgressBar();
|
||||
sendLocalState();
|
||||
}
|
||||
|
||||
let isDraggingProgress = false;
|
||||
|
|
@ -582,7 +609,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
video.addEventListener('pause', onDriverStateChanged);
|
||||
video.addEventListener('seeked', onDriverStateChanged);
|
||||
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
|
||||
if (!heartbeat) heartbeat = window.setInterval(onDriverStateChanged, 3000);
|
||||
if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
|
||||
|
||||
// Burn-in (image) subtitle options are host-only (see
|
||||
// renderSubtitleOptions) - becoming host mid-session must unlock
|
||||
|
|
|
|||
|
|
@ -2845,6 +2845,89 @@ new per-row button renders correctly alongside the existing controls,
|
|||
at both desktop and phone width (the phone-width wrap is the same
|
||||
`@media` rule `views/series` already relies on, untouched here).
|
||||
|
||||
## Casting broke after the first segment: the rewritten playlist URLs never carried the castToken
|
||||
|
||||
Reported directly: "for cast, the .m3u8 needs to be passed instead of a
|
||||
raw [url]". The master playlist itself was never the problem - `POST
|
||||
.../cast-token` mints the token, and the initial `master.m3u8?castToken=...`
|
||||
request already carries it. What broke immediately after: every URI
|
||||
line inside that playlist gets rewritten (`EmbyClient.rewritePlaylist`)
|
||||
to route back through this app's own `party-stream/:partyId/raw?u=...`
|
||||
proxy instead of hitting Emby directly - and that rewrite never carried
|
||||
the castToken forward at all. A browser doesn't notice, because it's
|
||||
still sending its session cookie on every one of those follow-up
|
||||
requests regardless of what's in the URL. A Chromecast has no cookie to
|
||||
fall back on (see `CastTokenService`) - so its segment/variant-playlist
|
||||
fetches hit `sessionForStream` with no auth of any kind and 401'd,
|
||||
breaking playback moments after the receiver appeared to connect.
|
||||
|
||||
Fixed by threading the *same* castToken the master-playlist request
|
||||
itself carried into every rewritten line - `rewritePlaylist(text,
|
||||
routeKey, castToken?)`, sourced from `req.query.castToken` right inside
|
||||
`proxyResponse` (which already receives `req`, so no new parameter
|
||||
needed anywhere upstream of it). Omitted entirely when there wasn't one
|
||||
(the normal browser/cookie case - unaffected either way, since a
|
||||
browser was never relying on this).
|
||||
|
||||
The self-hosted burn-in path (`TranscodeService`) had the exact same
|
||||
gap for a different reason: ffmpeg's own HLS muxer writes bare relative
|
||||
segment filenames into its `master.m3u8`, which a browser resolves
|
||||
against the request's own URL (keeping its cookie without even
|
||||
noticing) but which - per ordinary URL-resolution rules, not a bug in
|
||||
ffmpeg or this app - silently drops any query string when resolved
|
||||
against a base URL that had one. Fixed the same way: a new pure
|
||||
`appendCastTokenToSegments(text, castToken)` (extracted specifically so
|
||||
this could be unit-tested directly, unlike `serveMasterPlaylist` itself
|
||||
which needs a real ffmpeg session and on-disk files to exercise at all)
|
||||
appends `?castToken=...` to each segment line when a request arrives
|
||||
with one; the plain `fs.createReadStream(...).pipe(res)` fast path is
|
||||
untouched when it doesn't.
|
||||
|
||||
Verified: full `typecheck`/`test`/`build` (122/122 server tests,
|
||||
including new coverage for `rewritePlaylist`'s token propagation - had
|
||||
no test at all before this, for the rewriting itself or the token fix -
|
||||
and for `appendCastTokenToSegments`). **Not verified**: an actual
|
||||
Chromecast fetching real rewritten segment URLs - no device reachable
|
||||
from this sandbox, same standing limitation as the rest of "Casting to
|
||||
a Chromecast" above.
|
||||
|
||||
## The player toolbar still wasn't auto-hiding: a heartbeat and its own hide-timer, both every 3s
|
||||
|
||||
Reported right after the mobile touch fix: "the toolbar still doesn't
|
||||
work, mobile and PC" - meaning the *other* auto-hide fix hadn't
|
||||
actually landed for anyone, not just touch devices. Root cause this
|
||||
time: an actual bug in this session's own previous edit, not a browser
|
||||
quirk. `becomeHost`'s 3-second heartbeat (`sendLocalState`, needed
|
||||
regardless of any real activity, so a follower's periodic resync never
|
||||
goes stale) got rewired to call the same `onDriverStateChanged` the
|
||||
native `<video>`'s real play/pause/seeked events use - which also
|
||||
re-arms the auto-hide countdown (`scheduleControlsHide`), on the
|
||||
reasoning that resuming from pause needed to restart it. That reasoning
|
||||
was correct for a genuine discrete event; the heartbeat is not one - it
|
||||
fires unconditionally every 3s whether or not anything changed. With
|
||||
`CONTROLS_HIDE_DELAY_MS` *also* 3000ms, every single heartbeat tick
|
||||
cancelled and restarted the hide countdown from zero, on the same
|
||||
cadence the countdown itself needed to run uninterrupted just to reach
|
||||
its own deadline - so for a host, on any platform, the bar could get
|
||||
re-armed indefinitely and effectively never complete a hide. A follower
|
||||
(no heartbeat of their own) was never affected by this specific bug -
|
||||
only by the earlier touch one.
|
||||
|
||||
Fixed by giving the heartbeat its own function, `reportHeartbeat` -
|
||||
same state sync (button icon, seek bar, `sendLocalState`) as
|
||||
`onDriverStateChanged`, deliberately *without* the
|
||||
`scheduleControlsHide` call. Every genuine discrete event (native
|
||||
video play/pause/seeked, a cast session's remote-state changes, a
|
||||
manual seek) still goes through `onDriverStateChanged` unchanged and
|
||||
still correctly re-arms the countdown.
|
||||
|
||||
Verified: full `typecheck`/build. Confirmed directly (not by racing two
|
||||
real timers against each other, which only proves something over an
|
||||
observation window long enough to be impractical here) that 3 simulated
|
||||
heartbeat ticks call the hide-timer's reset function zero times, while
|
||||
one simulated discrete event still calls it exactly once - the precise
|
||||
behavioral difference the fix is supposed to produce.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Playback always goes through `hls.js` against whatever Emby's
|
||||
|
|
|
|||
|
|
@ -87,14 +87,25 @@ export class EmbyClient {
|
|||
* proxying the master playlist in the first place. TODO(verify): only
|
||||
* tested against synthetic input, not a real Emby-generated playlist -
|
||||
* see docs/architecture.md.
|
||||
*
|
||||
* `castToken`, when given, is appended to every rewritten line too -
|
||||
* confirmed against a real cast session that without this, a
|
||||
* Chromecast (which has no cookie to fall back on - see
|
||||
* CastTokenService) could fetch the *master* playlist fine (that one
|
||||
* request carries the token directly, in index.ts's own query string)
|
||||
* but every segment/variant-playlist fetch after it hit these
|
||||
* rewritten URLs with no auth at all and 401'd, breaking playback
|
||||
* moments after it appeared to start. See proxyResponse below, the
|
||||
* only caller, for where the token actually comes from.
|
||||
*/
|
||||
static rewritePlaylist(text: string, routeKey: string): string {
|
||||
static rewritePlaylist(text: string, routeKey: string, castToken?: string): string {
|
||||
const suffix = castToken ? `&castToken=${encodeURIComponent(castToken)}` : '';
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
return `/api/catalog/party-stream/${routeKey}/raw?u=${encodeURIComponent(trimmed)}`;
|
||||
return `/api/catalog/party-stream/${routeKey}/raw?u=${encodeURIComponent(trimmed)}${suffix}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
|
@ -154,7 +165,8 @@ export class EmbyClient {
|
|||
|
||||
if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
|
||||
const text = await embyRes.text();
|
||||
res.send(EmbyClient.rewritePlaylist(text, routeKey));
|
||||
const castToken = typeof req.query.castToken === 'string' ? req.query.castToken : undefined;
|
||||
res.send(EmbyClient.rewritePlaylist(text, routeKey, castToken));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,34 @@ function buildSourceUrl(itemId: string, mediaSourceId: string, container: string
|
|||
return `${EMBY_SERVER_URL}/Videos/${itemId}/stream.${container}?${params}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg's own HLS muxer writes bare relative segment filenames
|
||||
* (seg00001.ts, ...) into master.m3u8 - a browser resolves those
|
||||
* against this playlist's own request URL and keeps riding on its
|
||||
* session cookie without even noticing, but a Chromecast has no cookie
|
||||
* (see CastTokenService), and resolving a relative URL against one
|
||||
* *with* a query string drops that query entirely (standard URL
|
||||
* resolution, not a bug in ffmpeg or this app) - so the segment
|
||||
* requests that followed used to reach serveSegment with no auth at
|
||||
* all and 404 there, breaking burn-in playback on a Chromecast moments
|
||||
* after it appeared to start. Same fix as EmbyClient.rewritePlaylist's
|
||||
* identical castToken propagation for the normal (non-burn-in)
|
||||
* party-stream path - append it to every segment line ourselves, since
|
||||
* here (unlike that path) nothing else already rewrites this file's
|
||||
* content at all. Pure string transform, no filesystem/session
|
||||
* involved - see serveMasterPlaylist, its one caller.
|
||||
*/
|
||||
export function appendCastTokenToSegments(text: string, castToken: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
return `${trimmed}?castToken=${encodeURIComponent(castToken)}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const SEGMENT_NAME_RE = /^seg\d{5}\.ts$/;
|
||||
|
||||
interface TranscodeSession {
|
||||
|
|
@ -238,7 +266,14 @@ class TranscodeServiceClass {
|
|||
|
||||
const playlistPath = path.join(s.outputDir, 'master.m3u8');
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
|
||||
const castToken = typeof req.query.castToken === 'string' ? req.query.castToken : '';
|
||||
if (!castToken) {
|
||||
fs.createReadStream(playlistPath).pipe(res);
|
||||
return;
|
||||
}
|
||||
const text = await fs.promises.readFile(playlistPath, 'utf8');
|
||||
res.send(appendCastTokenToSegments(text, castToken));
|
||||
}
|
||||
|
||||
/** GET /api/catalog/burn/:partyId/:segment - `segment` is validated
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ function inspect(res: Response): FakeRes {
|
|||
return res as unknown as FakeRes;
|
||||
}
|
||||
|
||||
function fakeReq(headers: Record<string, string> = {}): Request {
|
||||
return { headers } as unknown as Request;
|
||||
function fakeReq(headers: Record<string, string> = {}, query: Record<string, string> = {}): Request {
|
||||
return { headers, query } as unknown as Request;
|
||||
}
|
||||
|
||||
describe('EmbyClient.fetch', () => {
|
||||
|
|
@ -162,6 +162,40 @@ describe('EmbyClient.fetch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// rewritePlaylist itself - had no test coverage at all before this, real
|
||||
// or synthetic, despite its own doc comment flagging the "real Emby
|
||||
// playlist" gap. Added alongside the castToken fix (a Chromecast has no
|
||||
// cookie, so every rewritten URL needs the same token the master
|
||||
// playlist request itself carried, or segment/variant fetches 401 - see
|
||||
// its own comment).
|
||||
describe('EmbyClient.rewritePlaylist', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-STREAM-INF:BANDWIDTH=1280000',
|
||||
'hls1/main/0.m3u8?PlaySessionId=abc',
|
||||
'',
|
||||
'https://emby.example.com/absolute/1.m3u8'
|
||||
].join('\n');
|
||||
|
||||
it('rewrites every real URI line, leaves directives/comments/blank lines untouched, no castToken given', () => {
|
||||
const out = EmbyClient.rewritePlaylist(playlist, 'party1').split('\n');
|
||||
|
||||
assert.equal(out[0], '#EXTM3U');
|
||||
assert.equal(out[1], '#EXT-X-STREAM-INF:BANDWIDTH=1280000');
|
||||
assert.equal(out[2], `/api/catalog/party-stream/party1/raw?u=${encodeURIComponent('hls1/main/0.m3u8?PlaySessionId=abc')}`);
|
||||
assert.equal(out[3], '');
|
||||
assert.equal(out[4], `/api/catalog/party-stream/party1/raw?u=${encodeURIComponent('https://emby.example.com/absolute/1.m3u8')}`);
|
||||
});
|
||||
|
||||
it('appends the same castToken to every rewritten line when given - a Chromecast has no cookie for the follow-up requests', () => {
|
||||
const out = EmbyClient.rewritePlaylist(playlist, 'party1', 'tok-abc').split('\n');
|
||||
|
||||
assert.ok(out[2]!.endsWith('&castToken=tok-abc'));
|
||||
assert.ok(out[4]!.endsWith('&castToken=tok-abc'));
|
||||
assert.equal(out[0], '#EXTM3U', 'directives never get a token appended - they were never rewritten at all');
|
||||
});
|
||||
});
|
||||
|
||||
// proxyResponse itself, never exercised for real before (every other test
|
||||
// file that touches it mocks it away entirely, to isolate whatever calls
|
||||
// it) - specifically the caching path added after a real "the catalog is
|
||||
|
|
@ -223,4 +257,19 @@ describe('EmbyClient.proxyResponse', () => {
|
|||
assert.equal(got.headers['etag'], undefined);
|
||||
assert.equal(await got.body, 'vtt content');
|
||||
});
|
||||
|
||||
it('threads the request\'s own castToken into every rewritten line of a proxied playlist - see rewritePlaylist', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => new Response('#EXTM3U\nhls1/main/0.m3u8', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/vnd.apple.mpegurl' }
|
||||
}));
|
||||
|
||||
const req = fakeReq({}, { castToken: 'tok-xyz' });
|
||||
const res = fakeRes();
|
||||
await EmbyClient.proxyResponse(req, res, '/Videos/1/master.m3u8', 'tok', 'party1');
|
||||
const got = inspect(res);
|
||||
|
||||
const body = await got.body;
|
||||
assert.ok(body.includes('&castToken=tok-xyz'), `expected the rewritten line to carry the castToken, got: ${body}`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import assert from 'node:assert/strict';
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { CatalogService } from '../src/services/CatalogService';
|
||||
import { PartyService } from '../src/services/PartyService';
|
||||
import { TranscodeService, absoluteToRelativeIndex } from '../src/services/TranscodeService';
|
||||
import { TranscodeService, absoluteToRelativeIndex, appendCastTokenToSegments } from '../src/services/TranscodeService';
|
||||
import type { EmbySession } from '../src/types';
|
||||
|
||||
const FAKE_SESSION: EmbySession = {
|
||||
|
|
@ -53,6 +53,20 @@ describe('absoluteToRelativeIndex', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('appendCastTokenToSegments', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', 'seg00001.ts', '', 'seg00002.ts'].join('\n');
|
||||
|
||||
it('appends the token to every real segment line, leaves directives/comments/blank lines untouched', () => {
|
||||
const out = appendCastTokenToSegments(playlist, 'tok-abc').split('\n');
|
||||
|
||||
assert.equal(out[0], '#EXTM3U');
|
||||
assert.equal(out[1], '#EXT-X-TARGETDURATION:6');
|
||||
assert.equal(out[2], 'seg00001.ts?castToken=tok-abc');
|
||||
assert.equal(out[3], '');
|
||||
assert.equal(out[4], 'seg00002.ts?castToken=tok-abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TranscodeService', () => {
|
||||
it('starting a session spawns ffmpeg with relative stream specifiers, not Emby\'s absolute indices', async (t) => {
|
||||
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue