Asked for directly, right after the client-side <track> redesign closed
every *text* subtitle case: PGS/VobSub/DVB subtitles (Blu-ray/DVD rips)
are bitmaps, not text - there's nothing for Emby's Stream.vtt conversion
to extract, so a native <track> fundamentally can't render one. Rather
than keep asking Emby's own transcode to burn these in (the exact thing
that kept failing in earlier sessions - missing fontconfig, then an
unrelated "Error starting ffmpeg"), this runs ffmpeg ourselves: a PGS
stream decodes like a second video track, and ffmpeg's `overlay` filter
composites it directly - no text rendering, no libass, no fontconfig
anywhere in this path.
One shared stream per PARTY, not per viewer - explicitly asked for after
the initial plan: every member plugs into the exact same server-emitted
stream, mirroring "host drives, everyone follows" rather than a
per-viewer model. Picking a burn-in subtitle is therefore a host-only,
party-wide choice, like play/pause/next already are.
- server/src/services/TranscodeService.ts (new): one ffmpeg session per
partyId. getOrStartSession negotiates PlaybackInfo (via the new
CatalogService.getMediaSource), converts Emby's absolute
MediaStreams[].Index into ffmpeg's type-relative 0:s:N/0🅰️N
(absoluteToRelativeIndex - pure, unit-tested), and spawns ffmpeg
against Emby's own direct/static file endpoint (TODO(verify) - no
Emby server reachable from this dev environment, see the function's
own comment). Output is real progressive HLS to a temp dir, served by
two new routes that poll briefly for the next file. endSession/
reapIdleSessions handle teardown; PartyService's destroy path and
advanceQueue both end a party's session immediately, the idle sweep
is the backstop.
- server/src/services/PartyService.ts: Party/PartyPublicState gain
burnInSubtitleIndex/burnInAudioIndex (null together = no burn-in);
advanceQueue clears them (a new item's indices mean nothing on the
old one).
- server/src/realtime/SocketGateway.ts: new host-only "setBurnInSubtitle"
handler broadcasting "burnInChanged" - same shape as "next"/
"itemChanged".
- server/src/services/CatalogService.ts: new getMediaSource() (id/
container/streams together - what TranscodeService needs); extended
the local PlaybackInfoResponse type with Container.
- server/src/Logger.ts: new Logger.transcode().
- server/src/index.ts: GET /api/catalog/burn/:partyId/master.m3u8 and
.../:segment (membership-checked, same pattern as the party REST
routes); a dedicated idle-reap interval for TranscodeService.
- server/Dockerfile: `apk add ffmpeg` in the runtime stage - no font
packages needed, unlike Emby's own text-subtitle burn-in.
- client/src/api/party.ts, client/src/api/embyApi.ts: setBurnInSubtitle/
onBurnInChanged on PartyConnection, burnInStreamUrl().
- client/src/views/player/Player.{html,css,ts}: the subtitle picker's
image-track options go from unconditionally disabled to host-only
selectable, labeled to say picking one restarts the stream for
everyone. Every member (host included) only ever reacts to the
resulting "burnInChanged" broadcast - never applies a pick
optimistically, same discipline the rest of the player already uses.
Audio/quality are disabled while a burn-in is active (one fixed
encode); a follower's subtitle select is disabled too (nothing left
for them to meaningfully pick).
- server/test/transcodeService.test.ts (new): absoluteToRelativeIndex,
session start/reuse/replace, endSession, reapIdleSessions -
child_process.spawn mocked via a small explicit test seam
(setSpawnForTesting), never invoking a real ffmpeg binary.
- server/test/socketGateway.test.ts, partyService.test.ts,
catalogService.test.ts: host-only gating + broadcast for
setBurnInSubtitle, advanceQueue's reset, toPublicState exposing the
new fields, getMediaSource.
- docs/architecture.md, README.md: design + an explicit "assumptions to
verify" note (the direct-source URL shape, first and foremost).
Verified: full typecheck+test+build on both projects (56/56 server
tests). No Emby server or ffmpeg binary is reachable from this dev
environment, so the actual command against a real PGS file is
unverified - flagged explicitly in the docs, same "TODO(verify)" spirit
already used elsewhere in this codebase for an Emby response shape that
can't be checked locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
193 lines
9 KiB
TypeScript
193 lines
9 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 } 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('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);
|
|
});
|
|
});
|