Fix casting past the first segment, and the toolbar's real auto-hide bug
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 12s

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:
Nicolas 2026-09-15 20:18:29 +02:00
parent cf82ea5c2d
commit 62b0abd572
6 changed files with 230 additions and 10 deletions

View file

@ -432,12 +432,39 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
* session's RemotePlayerController events (startCasting's own * session's RemotePlayerController events (startCasting's own
* onRemoteStateChanged callback) alike - so the button icon, the * onRemoteStateChanged callback) alike - so the button icon, the
* seek bar, and the party broadcast all stay in lockstep regardless * 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() { function onDriverStateChanged() {
updatePlayPauseBtn(); updatePlayPauseBtn();
updateProgressBar(); updateProgressBar();
sendLocalState(); 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; let isDraggingProgress = false;
@ -582,7 +609,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
video.addEventListener('pause', onDriverStateChanged); video.addEventListener('pause', onDriverStateChanged);
video.addEventListener('seeked', onDriverStateChanged); video.addEventListener('seeked', onDriverStateChanged);
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); }); 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 // Burn-in (image) subtitle options are host-only (see
// renderSubtitleOptions) - becoming host mid-session must unlock // renderSubtitleOptions) - becoming host mid-session must unlock

View file

@ -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 at both desktop and phone width (the phone-width wrap is the same
`@media` rule `views/series` already relies on, untouched here). `@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 ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's

View file

@ -87,14 +87,25 @@ export class EmbyClient {
* proxying the master playlist in the first place. TODO(verify): only * proxying the master playlist in the first place. TODO(verify): only
* tested against synthetic input, not a real Emby-generated playlist - * tested against synthetic input, not a real Emby-generated playlist -
* see docs/architecture.md. * 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 return text
.split('\n') .split('\n')
.map((line) => { .map((line) => {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line; 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'); .join('\n');
} }
@ -154,7 +165,8 @@ export class EmbyClient {
if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) { if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
const text = await embyRes.text(); 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; return;
} }

View file

@ -54,6 +54,34 @@ function buildSourceUrl(itemId: string, mediaSourceId: string, container: string
return `${EMBY_SERVER_URL}/Videos/${itemId}/stream.${container}?${params}`; 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$/; const SEGMENT_NAME_RE = /^seg\d{5}\.ts$/;
interface TranscodeSession { interface TranscodeSession {
@ -238,7 +266,14 @@ class TranscodeServiceClass {
const playlistPath = path.join(s.outputDir, 'master.m3u8'); const playlistPath = path.join(s.outputDir, 'master.m3u8');
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); 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); 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 /** GET /api/catalog/burn/:partyId/:segment - `segment` is validated

View file

@ -51,8 +51,8 @@ function inspect(res: Response): FakeRes {
return res as unknown as FakeRes; return res as unknown as FakeRes;
} }
function fakeReq(headers: Record<string, string> = {}): Request { function fakeReq(headers: Record<string, string> = {}, query: Record<string, string> = {}): Request {
return { headers } as unknown as Request; return { headers, query } as unknown as Request;
} }
describe('EmbyClient.fetch', () => { 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 // proxyResponse itself, never exercised for real before (every other test
// file that touches it mocks it away entirely, to isolate whatever calls // file that touches it mocks it away entirely, to isolate whatever calls
// it) - specifically the caching path added after a real "the catalog is // 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(got.headers['etag'], undefined);
assert.equal(await got.body, 'vtt content'); 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}`);
});
}); });

View file

@ -8,7 +8,7 @@ import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { CatalogService } from '../src/services/CatalogService'; import { CatalogService } from '../src/services/CatalogService';
import { PartyService } from '../src/services/PartyService'; 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'; import type { EmbySession } from '../src/types';
const FAKE_SESSION: EmbySession = { 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', () => { describe('TranscodeService', () => {
it('starting a session spawns ffmpeg with relative stream specifiers, not Emby\'s absolute indices', async (t) => { 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 })); t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));