/** * Subtitle Extractor Plugin Tests * * Pure helpers run anywhere; extraction needs ffmpeg and the generated fixtures * (./test/fixtures/generate-test-fixtures.sh, run by ./test.sh). */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { execSync } from 'child_process'; import { existsSync, mkdirSync, readFileSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { manifest, configure, process as processFile, computeMidHash256FromBuffer, sanitizeFilename, readStreamEntries, parseSubtitleStreams, classifySubtitleStreams, outputFor, subtitleFilename, buildExtractionArgs, extractTracks, extractionStats, hasCues, hasExtractedKeySet, videoSubtitleKeys, subtitleRecordKeys, legacySubtitleMigration, linkSubtitles, type ExtractJob, type TextSubtitleStream, } from '../src/plugin.js'; import { toLang3, languageToken } from '../src/langs.js'; import { flattenMeta, onlyNewKeys } from '../src/meta-shape.js'; import { MetaCoreClient } from '../src/meta-core-client.js'; import type { CallbackPayload } from '../src/types.js'; import { startFakeCore, writesTo, type FakeCore } from './fake-core.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURES = path.join(__dirname, 'fixtures'); const TEMP = '/tmp/subtitle-extractor-test'; const fixture = (name: string) => path.join(FIXTURES, name); function hasFfmpeg(): boolean { try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; } catch { return false; } } const ffmpegReady = hasFfmpeg() && existsSync(fixture('with-subs.mkv')) && existsSync(fixture('mov-text.mp4')); /** The nested `stream` array exactly as meta-sort's /meta serves it (JSON strings). */ function nestedStreamsFor(file: string): string[] { const probe = JSON.parse( execSync(`ffprobe -v quiet -print_format json -show_streams "${file}"`, { encoding: 'utf-8' }) ) as { streams: Array> }; return probe.streams.map((s) => { const entry: Record = { type: s.codec_type, codec: s.codec_name, index: s.index, duration: 'N/A', forced: s.disposition?.forced === 1, default: s.disposition?.default === 1, }; if (s.tags?.language) entry.language = s.tags.language; if (s.tags?.title) entry.title = s.tags.title; return JSON.stringify(entry); }); } const SUB_ENG = { type: 'subtitle', codec: 'ass', index: 3, language: 'eng', title: 'English (Full)', forced: false }; const SUB_ENG_SIGNS = { type: 'subtitle', codec: 'ass', index: 4, language: 'eng', title: 'Signs', forced: true }; const SUB_FRE = { type: 'subtitle', codec: 'subrip', index: 10, language: 'fre' }; const VIDEO = { type: 'video', codec: 'av1', index: 0 }; beforeEach(() => configure({})); describe('manifest', () => { it('declares dependencies, queue and the keys it writes', () => { expect(manifest.id).toBe('subtitle-extractor'); expect(manifest.dependencies).toEqual(['file-info', 'ffmpeg']); expect(manifest.defaultQueue).toBe('background'); expect(Object.keys(manifest.schema ?? {})).toEqual(['subtitles', 'extractedSubtitles', 'subtitleLanguages']); }); }); describe('computeMidHash256FromBuffer', () => { it('matches meta-hash for a known buffer', () => { expect(computeMidHash256FromBuffer(Buffer.from('metamesh-still-extractor-cid-vector'))) .toBe('bagacbabaecybg7wcyxl7su3dvjleuwgwil5tgeoybrwc35jasqtywb6bnjzk4'); }); }); describe('language normalisation (ISO 639-2/B, `und` when unknown)', () => { it('folds 639-1, 639-2/T and BCP-47 onto 639-2/B', () => { expect(toLang3('fr')).toBe('fre'); expect(toLang3('fra')).toBe('fre'); expect(toLang3('fre')).toBe('fre'); expect(toLang3('deu')).toBe('ger'); expect(toLang3('zho')).toBe('chi'); expect(toLang3('pt-BR')).toBe('por'); expect(toLang3('en')).toBe('eng'); expect(toLang3('ENG')).toBe('eng'); expect(toLang3('English')).toBe('eng'); }); it('maps every "no language" spelling, and junk, to und', () => { for (const v of [undefined, null, '', 'und', 'zxx', 'mis', 'xx', '12', 'toolong']) { expect(toLang3(v)).toBe('und'); } }); it('passes an unlisted but well-formed 3-letter code through', () => { expect(toLang3('tlh')).toBe('tlh'); }); it('is strict about filename tokens', () => { expect(languageToken('fr')).toBe('fre'); expect(languageToken('S01')).toBeNull(); expect(languageToken('x264')).toBeNull(); expect(languageToken('Extended')).toBeNull(); }); }); describe('readStreamEntries — every payload shape', () => { const expectOrder = (meta: Record) => expect(readStreamEntries(meta).map((s) => s.index)).toEqual([0, 3, 10]); it('nested array of JSON strings (meta-sort /meta)', () => { expectOrder({ stream: [VIDEO, SUB_ENG, SUB_FRE].map((s) => JSON.stringify(s)) }); }); it('nested array of objects', () => { expectOrder({ stream: [VIDEO, SUB_ENG, SUB_FRE] }); }); it('nested object keyed by index, ordered numerically', () => { expectOrder({ stream: { '10': JSON.stringify(SUB_FRE), '0': JSON.stringify(VIDEO), '2': JSON.stringify(SUB_ENG) } }); }); it('a JSON string of the array', () => { expectOrder({ stream: JSON.stringify([VIDEO, SUB_ENG, SUB_FRE].map((s) => JSON.stringify(s))) }); }); it('flat meta-core `stream/{n}` keys, stream/10 after stream/2', () => { expectOrder({ 'stream/10': JSON.stringify(SUB_FRE), 'stream/2': JSON.stringify(SUB_ENG), 'stream/0': JSON.stringify(VIDEO), }); }); it('skips an unparseable entry and keeps the rest', () => { expect(readStreamEntries({ stream: ['not json', JSON.stringify(SUB_FRE)] }).length).toBe(1); expect(readStreamEntries(undefined)).toEqual([]); }); }); describe('parseSubtitleStreams', () => { it('keeps the global index, per-type ordinal, lang3 and forced flag', () => { const streams = parseSubtitleStreams({ stream: [VIDEO, SUB_ENG, SUB_ENG_SIGNS, { ...SUB_FRE, language: 'fra' }] }); expect(streams.map((s) => [s.ordinal, s.streamIndex, s.lang3, s.forced])).toEqual([ [0, 3, 'eng', false], [1, 4, 'eng', true], [2, 10, 'fre', false], ]); }); it('uses und when a stream declares no language', () => { expect(parseSubtitleStreams({ 'stream/0': JSON.stringify({ type: 'subtitle', codec: 'subrip', index: 0 }) })[0].lang3).toBe('und'); }); it('falls back to the legacy subtitle_{i}_* fields', () => { const streams = parseSubtitleStreams({ subtitle_0_codec: 'subrip', subtitle_0_language: 'ger' }); expect(streams).toHaveLength(1); expect(streams[0].lang3).toBe('ger'); expect(streams[0].streamIndex).toBeUndefined(); }); }); describe('classifySubtitleStreams / outputFor', () => { const s = (codec: string, i: number) => ({ ordinal: i, streamIndex: i, codec, lang3: 'eng', forced: false }); it('skips image codecs explicitly and keeps unknown ones apart', () => { const { text, image, unsupported } = classifySubtitleStreams( [s('hdmv_pgs_subtitle', 0), s('dvd_subtitle', 1), s('dvb_subtitle', 2), s('ass', 3), s('eia_608', 4)], 'native', ); expect(image.map((x) => x.codec)).toEqual(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb_subtitle']); expect(text.map((x) => x.codec)).toEqual(['ass']); expect(unsupported.map((x) => x.codec)).toEqual(['eia_608']); }); it('native keeps ASS as ASS by stream copy, converts mov_text to SRT', () => { expect(outputFor('ass', 'native')).toEqual({ ext: 'ass', encoder: 'copy' }); expect(outputFor('subrip', 'native')).toEqual({ ext: 'srt', encoder: 'copy' }); expect(outputFor('webvtt', 'native')).toEqual({ ext: 'vtt', encoder: 'copy' }); expect(outputFor('mov_text', 'native')).toEqual({ ext: 'srt', encoder: 'srt' }); }); it('an explicit format converts', () => { expect(outputFor('ass', 'srt')).toEqual({ ext: 'srt', encoder: 'srt' }); expect(outputFor('subrip', 'vtt')).toEqual({ ext: 'vtt', encoder: 'webvtt' }); expect(outputFor('hdmv_pgs_subtitle', 'srt')).toBeNull(); }); it('configure rejects an unknown outputFormat', () => { configure({ outputFormat: 'pgs' }); expect(classifySubtitleStreams([s('ass', 0)]).text[0].ext).toBe('ass'); }); }); describe('subtitleFilename', () => { const meta = { title: 'Dead Dead Demons', movieYear: 2024 }; const stream = (streamIndex: number, lang3: string, forced = false) => ({ ordinal: 0, streamIndex, codec: 'ass', lang3, forced }); it('two tracks in one language never share a file', () => { const a = subtitleFilename(meta, 'bagvideo', stream(3, 'eng'), 'ass'); const b = subtitleFilename(meta, 'bagvideo', stream(4, 'eng', true), 'ass'); expect(a).not.toBe(b); expect(a).toBe('Dead Dead Demons (2024)[bagvideo]_subtitle.s3.eng.ass'); expect(b).toBe('Dead Dead Demons (2024)[bagvideo]_subtitle.s4.eng.forced.ass'); }); it('falls back to the file name and strips URL-hostile characters', () => { expect(subtitleFilename({ fileName: 'Show #1 100%?.mkv' }, 'c', stream(2, 'und'), 'srt')) .toBe('Show 1 100[c]_subtitle.s2.und.srt'); expect(sanitizeFilename('a/b:c*d?e#f%g')).toBe('abcdefg'); }); }); describe('buildExtractionArgs', () => { it('is ONE ffmpeg invocation with one -map/output per track', () => { const jobs = [3, 4, 10].map((i) => ({ stream: { ordinal: i, streamIndex: i, codec: 'ass', lang3: 'eng', forced: false, ext: 'ass', encoder: 'copy' } as TextSubtitleStream, tempFile: `/tmp/t-${i}.ass`, })); const args = buildExtractionArgs('http://core/webdav/watch/a.mkv', jobs); expect(args.filter((a) => a === '-i')).toHaveLength(1); expect(args.filter((a) => a === '-map')).toHaveLength(3); expect(args.join(' ')).toContain('-map 0:3 -c:s copy /tmp/t-3.ass -map 0:4 -c:s copy /tmp/t-4.ass -map 0:10 -c:s copy /tmp/t-10.ass'); }); it('maps by subtitle ordinal when the global index is unknown', () => { const args = buildExtractionArgs('in.mkv', [{ stream: { ordinal: 1, codec: 'subrip', lang3: 'ger', forced: false, ext: 'srt', encoder: 'copy' } as TextSubtitleStream, tempFile: 'out.srt', }]); expect(args).toContain('0:s:1'); }); }); describe('hasCues', () => { it('does not count a header-only ASS file', () => { expect(hasCues(Buffer.from('[Script Info]\nTitle: x\n[Events]\n'), 'ass')).toBe(false); expect(hasCues(Buffer.from('[Events]\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,Hi'), 'ass')).toBe(true); expect(hasCues(Buffer.from('1\n00:00:00,000 --> 00:00:01,000\nHi\n'), 'srt')).toBe(true); expect(hasCues(Buffer.from(''), 'srt')).toBe(false); }); }); describe('record keys (METADATA_KEYS.md §8)', () => { it('writes subtitles// and extractedSubtitles// leaves', () => { expect(videoSubtitleKeys([{ subCid: 'bagsubfre', lang3: 'fre' }, { subCid: 'bagsubund', lang3: 'und' }])).toEqual({ 'subtitles/fre/bagsubfre': 'true', 'extractedSubtitles/fre/bagsubfre': 'true', 'subtitleLanguages/fre': 'true', 'languages/fre': 'true', 'subtitles/und/bagsubund': 'true', 'extractedSubtitles/und/bagsubund': 'true', }); }); it('never writes a bare subtitles/ node or an und facet', () => { const keys = Object.keys(videoSubtitleKeys([{ subCid: 'c', lang3: 'und' }])); expect(keys.every((k) => k.split('/').length === 3)).toBe(true); }); it('subtitle record carries the back-pointer, its origin, and a determined language only', () => { expect(subtitleRecordKeys('bagvideo', 'fre')).toEqual({ 'videos/bagvideo': 'true', 'source/extract': 'true', subtitleLanguage: 'fre', }); expect(subtitleRecordKeys('bagvideo', 'und')).toEqual({ 'videos/bagvideo': 'true', 'source/extract': 'true', }); }); }); describe('hasExtractedKeySet', () => { it('recognises the key-set in both payload forms', () => { expect(hasExtractedKeySet({ extractedSubtitles: { fre: { c: true } } })).toBe(true); expect(hasExtractedKeySet({ 'extractedSubtitles/fre/c': 'true' })).toBe(true); }); it('does not treat the legacy csv scalar as done — that record needs migrating', () => { expect(hasExtractedKeySet({ extractedSubtitles: 'cidA,cidB' })).toBe(false); expect(hasExtractedKeySet({})).toBe(false); }); }); describe('legacySubtitleMigration', () => { it('deletes legacy scalars and keeps what they said as key-set leaves', () => { expect(legacySubtitleMigration({ extractedSubtitles: 'cidA,cidB', subtitleLanguages: 'eng,fra', subtitles: 'cidS', })).toEqual({ deletes: ['extractedSubtitles', 'subtitleLanguages', 'subtitles'], sets: { 'subtitleLanguages/eng': 'true', 'languages/eng': 'true', 'subtitleLanguages/fre': 'true', 'languages/fre': 'true', 'subtitles/und/cidS': 'true', }, }); }); it('leaves key-sets alone', () => { expect(legacySubtitleMigration({ subtitles: { fre: { c: true } }, subtitleLanguages: { fre: true } })) .toEqual({ deletes: [], sets: {} }); }); }); describe('meta-shape helpers', () => { it('flattens the nested document to the same keys meta-core stores', () => { const nested = { fileType: 'video', fileinfo: { duration: 1433.089 }, subtitles: { fre: { bagsub: true } }, stream: ['{"a":1}'] }; expect(flattenMeta(nested)).toEqual({ fileType: 'video', 'fileinfo/duration': '1433.089', 'subtitles/fre/bagsub': 'true', 'stream/0': '{"a":1}', }); }); it('onlyNewKeys drops what the record already holds', () => { expect(onlyNewKeys({ 'a/b': 'true', c: 'x' }, { 'a/b': 'true', c: 'y', d: 'true' })).toEqual({ c: 'y', d: 'true' }); }); }); describe('process — skip logic (no I/O)', () => { const collect = () => { const seen: CallbackPayload[] = []; return { seen, cb: async (p: CallbackPayload) => { seen.push(p); } }; }; const req = (existingMeta: Record) => ({ taskId: 't1', cid: 'bagvideo', filePath: '/files/watch/test.mkv', callbackUrl: 'http://localhost/callback', metaCoreUrl: 'http://127.0.0.1:9', // unreachable: every case must bail first existingMeta, }); it('skips non-video files', async () => { const { seen, cb } = collect(); await processFile(req({ fileType: 'subtitle' }), cb); expect(seen[0]).toMatchObject({ status: 'skipped', reason: 'Not a video file' }); }); it('skips a record whose extractedSubtitles key-set is already there (nested form)', async () => { const { seen, cb } = collect(); await processFile(req({ fileType: 'video', extractedSubtitles: { eng: { c: true } }, stream: [JSON.stringify(SUB_ENG)] }), cb); expect(seen[0]).toMatchObject({ status: 'skipped', reason: 'Subtitles already extracted' }); }); it('skips a video with no subtitle streams', async () => { const { seen, cb } = collect(); await processFile(req({ fileType: 'video', stream: [JSON.stringify(VIDEO)] }), cb); expect(seen[0]).toMatchObject({ status: 'skipped', reason: 'No subtitle streams found' }); }); it('skips a video whose only subtitles are image-based', async () => { const { seen, cb } = collect(); await processFile(req({ fileType: 'video', stream: [VIDEO, { type: 'subtitle', codec: 'hdmv_pgs_subtitle', index: 1, language: 'eng' }].map((s) => JSON.stringify(s)), }), cb); expect(seen[0]).toMatchObject({ status: 'skipped', reason: 'Only image-based subtitles (cannot convert to text)' }); }); }); describe.skipIf(!ffmpegReady)('extractTracks (ffmpeg)', () => { beforeAll(() => mkdirSync(TEMP, { recursive: true })); const jobsFor = (file: string, tag: string): ExtractJob[] => classifySubtitleStreams(parseSubtitleStreams({ stream: nestedStreamsFor(file) }), 'native').text .map((stream) => ({ stream, tempFile: path.join(TEMP, `${tag}-${stream.ordinal}.${stream.ext}`) })); it('extracts every text track of the MKV in a single pass', async () => { const jobs = jobsFor(fixture('with-subs.mkv'), 'single'); expect(jobs.map((j) => [j.stream.lang3, j.stream.ext, j.stream.forced])).toEqual([ ['eng', 'srt', false], ['fre', 'ass', true], ['und', 'srt', false], ]); const before = extractionStats.ffmpegPasses; const result = await extractTracks(fixture('with-subs.mkv'), jobs, 60000); expect(extractionStats.ffmpegPasses - before).toBe(1); expect(result.passes).toBe(1); const [eng, fre, und] = result.outputs.map((o) => o.data?.toString('utf8') ?? ''); expect(eng).toContain('quick brown fox'); expect(fre).toContain('[Script Info]'); expect(fre).toMatch(/^Dialogue:.*renard brun/m); expect(und).toContain('-->'); }); it('converts mov_text to SRT', async () => { const jobs = jobsFor(fixture('mov-text.mp4'), 'movtext'); expect(jobs.map((j) => [j.stream.lang3, j.stream.ext, j.stream.encoder])).toEqual([['ger', 'srt', 'srt']]); const result = await extractTracks(fixture('mov-text.mp4'), jobs, 60000); expect(result.outputs[0].data?.toString('utf8')).toContain('schnelle braune Fuchs'); }); it('salvages good tracks one by one when the shared pass fails', async () => { const jobs = jobsFor(fixture('with-subs.mkv'), 'salvage'); const broken: ExtractJob = { ...jobs[0], stream: { ...jobs[0].stream, streamIndex: 42 }, tempFile: path.join(TEMP, 'salvage-broken.srt') }; const result = await extractTracks(fixture('with-subs.mkv'), [jobs[0], broken, jobs[1]], 60000); expect(result.passes).toBe(4); expect(result.outputs.map((o) => o.data !== null)).toEqual([true, false, true]); }); }); describe.skipIf(!ffmpegReady)('process — end to end against a fake core', () => { let core: FakeCore; const VIDEO_CID = 'bagacbabaevideo'; const videoPath = '/files/watch/show/with-subs.mkv'; let nestedMeta: Record; beforeAll(async () => { core = await startFakeCore(); core.files.set('/watch/show/with-subs.mkv', readFileSync(fixture('with-subs.mkv'))); process.env.WEBDAV_URL = core.webdavUrl; process.env.CACHE_PATH = TEMP; nestedMeta = { fileType: 'video', title: 'Test Show', fileName: 'with-subs.mkv', fileinfo: { duration: 3, formatName: 'matroska,webm' }, stream: nestedStreamsFor(fixture('with-subs.mkv')), // Legacy scalars from the `_add` era, which must not survive next to key-sets. extractedSubtitles: 'bagold1,bagold2', subtitleLanguages: 'eng', }; core.records.set(VIDEO_CID, flattenMeta(nestedMeta)); }); afterAll(async () => { delete process.env.WEBDAV_URL; await core.close(); }); const run = async (existingMeta: Record) => { const seen: CallbackPayload[] = []; await processFile({ taskId: 'task-e2e', cid: VIDEO_CID, filePath: videoPath, callbackUrl: 'http://unused', metaCoreUrl: core.url, existingMeta, }, async (p) => { seen.push(p); }); return seen[0]; }; it('extracts in one pass, uploads, and links subtitles//', async () => { const passes = extractionStats.ffmpegPasses; const result = await run(nestedMeta); expect(result.status).toBe('completed'); expect(extractionStats.ffmpegPasses - passes).toBe(1); const uploads = [...core.files.keys()].filter((p) => p.startsWith('/plugin/subtitle-extractor/')).sort(); expect(uploads).toEqual([ '/plugin/subtitle-extractor/Test Show[bagacbabaevideo]_subtitle.s1.eng.srt', '/plugin/subtitle-extractor/Test Show[bagacbabaevideo]_subtitle.s2.fre.forced.ass', '/plugin/subtitle-extractor/Test Show[bagacbabaevideo]_subtitle.s3.und.srt', ]); const cidOf = (suffix: string) => computeMidHash256FromBuffer(core.files.get(uploads.find((u) => u.endsWith(suffix))!)!); const eng = cidOf('eng.srt'); const fre = cidOf('fre.forced.ass'); const und = cidOf('und.srt'); const video = core.records.get(VIDEO_CID)!; expect(video).toMatchObject({ [`subtitles/eng/${eng}`]: 'true', [`subtitles/fre/${fre}`]: 'true', [`subtitles/und/${und}`]: 'true', [`extractedSubtitles/eng/${eng}`]: 'true', [`extractedSubtitles/fre/${fre}`]: 'true', [`extractedSubtitles/und/${und}`]: 'true', 'subtitleLanguages/eng': 'true', 'subtitleLanguages/fre': 'true', 'languages/eng': 'true', 'languages/fre': 'true', }); expect(video.extractedSubtitles).toBeUndefined(); expect(video.subtitleLanguages).toBeUndefined(); expect(video['subtitleLanguages/und']).toBeUndefined(); // `source/extract` rides on the subtitle's OWN record, so a peer that // fetched only the file can still tell it came out of a container. expect(core.records.get(fre)).toEqual({ [`videos/${VIDEO_CID}`]: 'true', 'source/extract': 'true', subtitleLanguage: 'fre', }); expect(core.records.get(und)).toEqual({ [`videos/${VIDEO_CID}`]: 'true', 'source/extract': 'true', }); // The video PATCH carried only new keys — nothing the record already had. const patch = writesTo(core, VIDEO_CID).find((c) => c.method === 'PATCH')!; expect(Object.keys(patch.body as object).some((k) => ['fileType', 'title', 'stream/0'].includes(k))).toBe(false); }); it('skips once the key-set is on the record', async () => { const result = await run(core.records.get(VIDEO_CID)!); expect(result).toMatchObject({ status: 'skipped', reason: 'Subtitles already extracted' }); }); it('re-links from existing outputs without reading the video again', async () => { // Metadata lost, output files still there. const bare = flattenMeta({ ...nestedMeta, extractedSubtitles: undefined, subtitleLanguages: undefined }); const passes = extractionStats.ffmpegPasses; const gets = core.fileGets.get('/watch/show/with-subs.mkv'); const result = await run(bare); expect(result.status).toBe('completed'); expect(extractionStats.ffmpegPasses).toBe(passes); expect(core.fileGets.get('/watch/show/with-subs.mkv')).toBe(gets); }); it('a second link over an up-to-date record writes nothing', async () => { const before = core.calls.length; const video = core.records.get(VIDEO_CID)!; const produced = Object.keys(video) .filter((k) => k.startsWith('subtitles/')) .map((k) => ({ lang3: k.split('/')[1], subCid: k.split('/')[2] })); expect(await linkSubtitles(new MetaCoreClient(core.url), VIDEO_CID, video, produced)).toBe(true); expect(core.calls.length).toBe(before); }); it('fails the task when meta-core rejects the write', async () => { core.failPatch = true; try { const result = await run(flattenMeta({ fileType: 'video', title: 'Test Show', stream: nestedStreamsFor(fixture('with-subs.mkv')) })); expect(result).toMatchObject({ status: 'failed', error: 'meta-core write failed' }); } finally { core.failPatch = false; } }); });