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>
207 lines
9.6 KiB
TypeScript
207 lines
9.6 KiB
TypeScript
// Exercises TranscodeService's own pure logic without ever invoking a real
|
|
// ffmpeg binary - CatalogService.getMediaSource and child_process.spawn are
|
|
// both mocked (see setSpawnForTesting's own comment), same "mock the one
|
|
// real dependency, run everything else for real" approach as
|
|
// embyClient.test.ts/socketGateway.test.ts elsewhere in this suite.
|
|
import { describe, it, mock } from 'node:test';
|
|
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, appendCastTokenToSegments } from '../src/services/TranscodeService';
|
|
import type { EmbySession } from '../src/types';
|
|
|
|
const FAKE_SESSION: EmbySession = {
|
|
embyToken: 'tok-123',
|
|
userId: 'u-alice',
|
|
userName: 'Alice',
|
|
serverId: 'server1',
|
|
createdUtc: Date.now()
|
|
};
|
|
|
|
// Mirrors the shape this app's own earlier subtitle-debugging session ran
|
|
// into for real: a flat, absolute index across video+audio+subtitle
|
|
// together (see absoluteToRelativeIndex's own comment).
|
|
const FAKE_STREAMS = [
|
|
{ Type: 'Video', Index: 0 },
|
|
{ Type: 'Audio', Index: 1 },
|
|
{ Type: 'Audio', Index: 2 },
|
|
{ Type: 'Subtitle', Index: 3 },
|
|
{ Type: 'Subtitle', Index: 4 }
|
|
];
|
|
|
|
/** A minimal stand-in for Node's ChildProcess - just enough of the real
|
|
* EventEmitter-based shape for TranscodeService to attach its
|
|
* stderr/exit/error listeners without throwing. */
|
|
function fakeChildProcess() {
|
|
const proc = new EventEmitter() as unknown as EventEmitter & { stderr: EventEmitter; kill: ReturnType<typeof mock.fn> };
|
|
proc.stderr = new EventEmitter();
|
|
proc.kill = mock.fn();
|
|
return proc;
|
|
}
|
|
|
|
describe('absoluteToRelativeIndex', () => {
|
|
it('counts only among streams of the same type, in source order', () => {
|
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Subtitle', 3), 0, 'first subtitle stream');
|
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Subtitle', 4), 1, 'second subtitle stream');
|
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Audio', 1), 0, 'first audio stream');
|
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Audio', 2), 1, 'second audio stream');
|
|
});
|
|
|
|
it('falls back to 0 for an index that is not present, rather than throwing', () => {
|
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Subtitle', 99), 0);
|
|
});
|
|
});
|
|
|
|
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 }));
|
|
const spawned: { command: string; args: string[] }[] = [];
|
|
TranscodeService.setSpawnForTesting((command, args) => {
|
|
spawned.push({ command, args });
|
|
return fakeChildProcess() as never;
|
|
});
|
|
t.after(() => TranscodeService.setSpawnForTesting());
|
|
|
|
const party = PartyService.create('u-alice', 'Alice', 'movie1', 'Un Film', '/img/movie1', null, 'video');
|
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
|
|
|
assert.equal(spawned.length, 1);
|
|
assert.equal(spawned[0]!.command, 'ffmpeg');
|
|
assert.ok(spawned[0]!.args.includes('[0:v][0:s:1]overlay[v]'), 'absolute subtitle index 4 -> relative 1');
|
|
assert.ok(spawned[0]!.args.includes('0:a:1'), 'absolute audio index 2 -> relative 1');
|
|
|
|
TranscodeService.endSession(party.id);
|
|
});
|
|
|
|
it('an empty audioIndex (no override) maps to the first audio stream', async (t) => {
|
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
|
const spawned: { args: string[] }[] = [];
|
|
TranscodeService.setSpawnForTesting((_command, args) => {
|
|
spawned.push({ args });
|
|
return fakeChildProcess() as never;
|
|
});
|
|
t.after(() => TranscodeService.setSpawnForTesting());
|
|
|
|
const party = PartyService.create('u-alice', 'Alice', 'movie1b', 'Un Film', '/img/movie1b', null, 'video');
|
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '', '3');
|
|
|
|
assert.ok(spawned[0]!.args.includes('0:a:0'));
|
|
TranscodeService.endSession(party.id);
|
|
});
|
|
|
|
it('a repeat call for the same audio/subtitle pair reuses the running session instead of spawning again', async (t) => {
|
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
|
let spawnCount = 0;
|
|
TranscodeService.setSpawnForTesting(() => {
|
|
spawnCount += 1;
|
|
return fakeChildProcess() as never;
|
|
});
|
|
t.after(() => TranscodeService.setSpawnForTesting());
|
|
|
|
const party = PartyService.create('u-bob', 'Bob', 'movie2', 'Un Film 2', '/img/movie2', null, 'video');
|
|
const first = await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
|
const second = await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
|
|
|
assert.equal(spawnCount, 1);
|
|
assert.equal(first, second, 'must be the exact same session object, not a fresh one');
|
|
|
|
TranscodeService.endSession(party.id);
|
|
});
|
|
|
|
it('a different pick for the same party kills the old process before starting a new one', async (t) => {
|
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
|
const procs: ReturnType<typeof fakeChildProcess>[] = [];
|
|
TranscodeService.setSpawnForTesting(() => {
|
|
const proc = fakeChildProcess();
|
|
procs.push(proc);
|
|
return proc as never;
|
|
});
|
|
t.after(() => TranscodeService.setSpawnForTesting());
|
|
|
|
const party = PartyService.create('u-carl', 'Carl', 'movie3', 'Un Film 3', '/img/movie3', null, 'video');
|
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '', '3');
|
|
|
|
assert.equal(procs.length, 2, 'the second pick must spawn a fresh process, not reuse the first');
|
|
assert.equal(procs[0]!.kill.mock.calls.length, 1, 'the superseded process must be killed');
|
|
|
|
TranscodeService.endSession(party.id);
|
|
});
|
|
|
|
it('endSession kills the process, and a later call for the same party starts fresh (not "already running")', async (t) => {
|
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
|
let spawnCount = 0;
|
|
const procs: ReturnType<typeof fakeChildProcess>[] = [];
|
|
TranscodeService.setSpawnForTesting(() => {
|
|
spawnCount += 1;
|
|
const proc = fakeChildProcess();
|
|
procs.push(proc);
|
|
return proc as never;
|
|
});
|
|
t.after(() => TranscodeService.setSpawnForTesting());
|
|
|
|
const party = PartyService.create('u-dave', 'Dave', 'movie4', 'Un Film 4', '/img/movie4', null, 'video');
|
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
|
TranscodeService.endSession(party.id);
|
|
|
|
assert.equal(procs[0]!.kill.mock.calls.length, 1);
|
|
|
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
|
assert.equal(spawnCount, 2, 'ending the session must not leave it "still running" for the next call');
|
|
|
|
TranscodeService.endSession(party.id);
|
|
});
|
|
|
|
it('endSession is a harmless no-op for a party that never had a session', () => {
|
|
assert.doesNotThrow(() => TranscodeService.endSession('no-such-party'));
|
|
});
|
|
|
|
it('reapIdleSessions ends only sessions whose last access is past the cutoff', async (t) => {
|
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
|
TranscodeService.setSpawnForTesting(() => fakeChildProcess() as never);
|
|
t.after(() => TranscodeService.setSpawnForTesting());
|
|
|
|
const fresh = PartyService.create('u-eve', 'Eve', 'movie5', 'Un Film 5', '/img/movie5', null, 'video');
|
|
const stale = PartyService.create('u-frank', 'Frank', 'movie6', 'Un Film 6', '/img/movie6', null, 'video');
|
|
const freshSession = await TranscodeService.getOrStartSession(fresh, FAKE_SESSION, '', '3');
|
|
const staleSession = await TranscodeService.getOrStartSession(stale, FAKE_SESSION, '', '3');
|
|
|
|
// Simulate "stale" having gone untouched for a long time, without
|
|
// waiting for real wall-clock time to pass.
|
|
staleSession.lastAccessUtc = Date.now() - 60 * 60 * 1000;
|
|
|
|
TranscodeService.reapIdleSessions(30 * 60 * 1000);
|
|
|
|
// The stale party's session should be gone (a fresh call spawns
|
|
// again); the fresh one should still be the exact same session.
|
|
let spawnCountAfterReap = 0;
|
|
TranscodeService.setSpawnForTesting(() => {
|
|
spawnCountAfterReap += 1;
|
|
return fakeChildProcess() as never;
|
|
});
|
|
const stillFresh = await TranscodeService.getOrStartSession(fresh, FAKE_SESSION, '', '3');
|
|
assert.equal(spawnCountAfterReap, 0, 'the fresh session must not have been reaped');
|
|
assert.equal(stillFresh, freshSession);
|
|
|
|
await TranscodeService.getOrStartSession(stale, FAKE_SESSION, '', '3');
|
|
assert.equal(spawnCountAfterReap, 1, 'the stale session must have been reaped and started fresh');
|
|
|
|
TranscodeService.endSession(fresh.id);
|
|
TranscodeService.endSession(stale.id);
|
|
});
|
|
});
|