// 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 }; 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[] = []; 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[] = []; 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); }); });