watchparty/server/test/transcodeService.test.ts
Nicolas 26da8fc5dc
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 30s
Self-hosted ffmpeg pipeline for PGS (image) subtitle burn-in
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>
2026-09-14 18:05:08 +02:00

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);
});
});