align plugin with new architecture
Build and Push Docker Image / build (push) Failing after 15s

This commit is contained in:
Worph
2026-09-13 11:54:56 +02:00
parent e1994cbfa8
commit b9e6de6c8b
10 changed files with 544 additions and 404 deletions
-165
View File
@@ -1,165 +0,0 @@
/**
* Language Plugin Integration Tests
*
* Tests language aggregation from streams and primary language determination.
* Uses @metazla/filename-tools library for ISO 639-3 conversion.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { anyTo_iso_639_3 } from '@metazla/filename-tools';
// Dynamic import of plugin module
let manifest: typeof import('../src/plugin.js').manifest;
let processFile: typeof import('../src/plugin.js').process;
// Mock callback collector
interface CallbackResult {
taskId: string;
status: 'completed' | 'failed' | 'skipped';
duration: number;
error?: string;
}
let lastCallback: CallbackResult | null = null;
const mockSendCallback = async (payload: CallbackResult): Promise<void> => {
lastCallback = payload;
};
describe('Language Plugin Integration Tests', () => {
beforeAll(async () => {
const plugin = await import('../src/plugin.js');
manifest = plugin.manifest;
processFile = plugin.process;
});
describe('Manifest', () => {
it('has required fields', () => {
expect(manifest.id).toBe('language');
expect(manifest.name).toBeDefined();
expect(manifest.version).toBeDefined();
expect(manifest.dependencies).toContain('ffmpeg');
expect(manifest.priority).toBe(40);
});
it('declares correct schema', () => {
expect(manifest.schema).toHaveProperty('languages');
});
});
describe('ISO 639-3 Conversion', () => {
it('handles 3-letter codes', () => {
// The function should handle 3-letter ISO 639-3 codes
const engResult = anyTo_iso_639_3('eng');
const jpnResult = anyTo_iso_639_3('jpn');
const fraResult = anyTo_iso_639_3('fra');
// Function returns the code if valid, or 'und' for unknown
expect(['eng', 'und']).toContain(engResult);
expect(['jpn', 'und']).toContain(jpnResult);
expect(['fra', 'und']).toContain(fraResult);
});
it('returns a defined value for known codes', () => {
const result = anyTo_iso_639_3('eng');
expect(result).toBeDefined();
expect(typeof result).toBe('string');
});
it('handles unknown codes gracefully', () => {
const result = anyTo_iso_639_3('xyz');
// Should return 'und' (undefined) or the original value
expect(result).toBeDefined();
expect(typeof result).toBe('string');
});
});
describe('Process Function', () => {
it('processes file with audio streams', async () => {
await processFile({
taskId: 'test-lang-1',
cid: 'test-cid-lang',
filePath: '/movies/Movie.mkv',
callbackUrl: 'http://localhost/callback',
metaCoreUrl: 'http://localhost:9000',
existingMeta: {
originalTitle: 'Test Movie',
'fileinfo/streamdetails/audio/0/language': 'eng',
'fileinfo/streamdetails/audio/1/language': 'jpn',
},
}, mockSendCallback);
expect(lastCallback).toBeDefined();
expect(lastCallback?.status).toBe('completed');
});
it('processes file with subtitle streams', async () => {
await processFile({
taskId: 'test-lang-2',
cid: 'test-cid-lang-2',
filePath: '/movies/Movie.mkv',
callbackUrl: 'http://localhost/callback',
metaCoreUrl: 'http://localhost:9000',
existingMeta: {
originalTitle: 'Test Movie',
'fileinfo/streamdetails/subtitle/0/language': 'eng',
'fileinfo/streamdetails/subtitle/1/language': 'fra',
},
}, mockSendCallback);
expect(lastCallback).toBeDefined();
expect(lastCallback?.status).toBe('completed');
});
it('processes file with existing title languages', async () => {
await processFile({
taskId: 'test-lang-3',
cid: 'test-cid-lang-3',
filePath: '/movies/Movie.mkv',
callbackUrl: 'http://localhost/callback',
metaCoreUrl: 'http://localhost:9000',
existingMeta: {
originalTitle: 'Test Movie',
'titles/eng': 'Test Movie',
'titles/jpn': 'テスト映画',
},
}, mockSendCallback);
expect(lastCallback).toBeDefined();
expect(lastCallback?.status).toBe('completed');
});
it('processes file with no language information', async () => {
await processFile({
taskId: 'test-lang-4',
cid: 'test-cid-lang-4',
filePath: '/movies/Movie.mkv',
callbackUrl: 'http://localhost/callback',
metaCoreUrl: 'http://localhost:9000',
existingMeta: {
originalTitle: 'Test Movie',
},
}, mockSendCallback);
expect(lastCallback).toBeDefined();
expect(lastCallback?.status).toBe('completed');
});
it('processes file with video stream language', async () => {
await processFile({
taskId: 'test-lang-5',
cid: 'test-cid-lang-5',
filePath: '/movies/Movie.mkv',
callbackUrl: 'http://localhost/callback',
metaCoreUrl: 'http://localhost:9000',
existingMeta: {
originalTitle: 'Test Movie',
'fileinfo/streamdetails/video/0/language': 'eng',
},
}, mockSendCallback);
expect(lastCallback).toBeDefined();
expect(lastCallback?.status).toBe('completed');
});
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* Language plugin unit tests — no network, no meta-core: the writer is injected.
*/
import { describe, it, expect } from 'vitest';
import {
manifest,
process as processFile,
streamsFromMeta,
toLang3,
audioLanguagesFromMeta,
languageWrites,
type LanguageWriter,
} from '../src/plugin.js';
import type { CallbackPayload, ProcessRequest } from '../src/types.js';
// Trimmed from the real nested /meta document of a dev-stack record
// (1080p AV1 episode: 2 audio, 9 subtitle, cover-art mjpeg).
const REAL_STREAMS: string[] = [
'{"type":"video","codec":"av1","index":0,"duration":"N/A","forced":false,"default":true,"title":"By Trix","width":1920,"height":1080,"frameRate":"24000/1001"}',
'{"type":"audio","codec":"aac","index":1,"duration":"N/A","forced":false,"default":true,"language":"jpn","title":"Japanese - AAC 2.0","sampleRate":"44100","channelLayout":"stereo"}',
'{"type":"audio","codec":"aac","index":2,"duration":"N/A","forced":false,"default":true,"language":"eng","title":"English - AAC 2.0","sampleRate":"44100","channelLayout":"stereo"}',
'{"type":"subtitle","codec":"ass","index":3,"language":"eng","title":"English (Full) [4de]"}',
'{"type":"subtitle","codec":"ass","index":5,"language":"fre","title":"French"}',
'{"type":"subtitle","codec":"ass","index":6,"language":"ger","title":"German"}',
'{"type":"video","codec":"mjpeg","index":12,"duration":"1433.089","width":230,"height":345}',
];
function nestedDoc(extra: Record<string, unknown> = {}): Record<string, unknown> {
return {
fileinfo: { duration: 1433.089, formatName: 'matroska,webm' },
stream: REAL_STREAMS,
originalTitle: 'Some Show',
cids: ['bagacbabaebs63pblr2rsycodkmg4ojn47ol7k4oz6ptcre2bwaguhsib4hjpg'],
...extra,
};
}
class RecordingWriter implements LanguageWriter {
calls: Array<{ hashId: string; metadata: Record<string, string> }> = [];
constructor(private ok = true) {}
async mergeMetadata(hashId: string, metadata: Record<string, string>): Promise<boolean> {
this.calls.push({ hashId, metadata });
return this.ok;
}
}
async function run(existingMeta: Record<string, unknown> | undefined, writer = new RecordingWriter()) {
let callback: CallbackPayload | null = null;
const request = {
taskId: 't1',
cid: 'cid-1',
filePath: '/files/watch/Show - 01.mkv',
callbackUrl: 'http://metasort-app/api/plugins/callback',
metaCoreUrl: 'http://metasort-app',
existingMeta,
} as unknown as ProcessRequest;
await processFile(request, async (p) => { callback = p; }, writer);
return { callback: callback as CallbackPayload | null, writer };
}
describe('manifest', () => {
it('keeps the plugin identity and ffmpeg dependency', () => {
expect(manifest.id).toBe('language');
expect(manifest.dependencies).toContain('ffmpeg');
expect(manifest.priority).toBe(40);
expect(manifest.schema).toHaveProperty('languages');
});
});
describe('streamsFromMeta', () => {
it('reads the nested stream array of JSON strings (meta-sort payload)', () => {
const streams = streamsFromMeta(nestedDoc());
expect(streams).toHaveLength(REAL_STREAMS.length);
expect(streams[1]).toMatchObject({ type: 'audio', language: 'jpn', index: 1 });
});
it('reads a nested stream array of objects', () => {
const streams = streamsFromMeta({ stream: REAL_STREAMS.map((s) => JSON.parse(s)) });
expect(streams.map((s) => s.index)).toEqual([0, 1, 2, 3, 5, 6, 12]);
});
it('reads a nested stream JSON string and an index-keyed object', () => {
expect(streamsFromMeta({ stream: JSON.stringify(REAL_STREAMS) })).toHaveLength(REAL_STREAMS.length);
const keyed = Object.fromEntries(REAL_STREAMS.map((s, i) => [String(i), s]));
expect(streamsFromMeta({ stream: keyed })).toHaveLength(REAL_STREAMS.length);
});
it('reads flat stream/{n} keys (meta-core form) in numeric order', () => {
const flat: Record<string, string> = { 'fileinfo/duration': '1433.089' };
// insert out of order, with n >= 10 to catch lexical sorting
flat['stream/10'] = '{"type":"audio","index":10,"language":"spa"}';
flat['stream/2'] = '{"type":"audio","index":2,"language":"eng"}';
flat['stream/1'] = '{"type":"audio","index":1,"language":"jpn"}';
expect(streamsFromMeta(flat).map((s) => s.index)).toEqual([1, 2, 10]);
});
it('returns nothing for missing streams and skips malformed entries', () => {
expect(streamsFromMeta(undefined)).toEqual([]);
expect(streamsFromMeta({})).toEqual([]);
expect(streamsFromMeta({ originalTitle: 'x' })).toEqual([]);
expect(streamsFromMeta({ stream: ['not json', '{"type":"audio","language":"eng"}', null] }))
.toEqual([{ type: 'audio', language: 'eng' }]);
});
it('ignores the legacy fileinfo/streamdetails keys nobody writes', () => {
expect(streamsFromMeta({ 'fileinfo/streamdetails/audio/0/language': 'eng' })).toEqual([]);
});
});
describe('toLang3', () => {
it('keeps 639-2/B codes', () => {
for (const c of ['eng', 'jpn', 'fre', 'ger', 'chi', 'spa']) expect(toLang3(c)).toBe(c);
});
it('folds 639-2/T onto 639-2/B (the filter vocabulary)', () => {
expect(toLang3('fra')).toBe('fre');
expect(toLang3('deu')).toBe('ger');
expect(toLang3('zho')).toBe('chi');
expect(toLang3('nld')).toBe('dut');
expect(toLang3('ces')).toBe('cze');
});
it('maps 639-1 and BCP-47 tags (the old normaliser returned und for all 2-letter codes)', () => {
expect(toLang3('en')).toBe('eng');
expect(toLang3('fr')).toBe('fre');
expect(toLang3('ja')).toBe('jpn');
expect(toLang3('pt-BR')).toBe('por');
expect(toLang3('EN_us')).toBe('eng');
expect(toLang3(' JPN ')).toBe('jpn');
});
it('never yields und and drops no-language spellings', () => {
for (const c of ['und', 'UND', 'zxx', 'mis', '', undefined, null, 'unknown']) expect(toLang3(c)).toBe('');
});
it('keeps mul and unknown 3-letter codes, drops unknown 2-letter and full names', () => {
expect(toLang3('mul')).toBe('mul');
expect(toLang3('xyz')).toBe('xyz');
expect(toLang3('xx')).toBe('');
expect(toLang3('French')).toBe('');
expect(toLang3('e1g')).toBe('');
});
});
describe('audioLanguagesFromMeta', () => {
it('takes audio tracks only — subtitle languages are not audio languages', () => {
expect(audioLanguagesFromMeta(nestedDoc())).toEqual(['jpn', 'eng']);
});
it('dedupes after normalisation and skips und/untagged audio', () => {
const meta = {
stream: [
{ type: 'audio', language: 'fra' },
{ type: 'audio', language: 'fre' },
{ type: 'audio', language: 'fr' },
{ type: 'audio', language: 'und' },
{ type: 'audio' },
{ type: 'video', language: 'eng' },
],
};
expect(audioLanguagesFromMeta(meta)).toEqual(['fre']);
});
});
describe('languageWrites', () => {
it('skips members already present in the flat or nested form', () => {
expect(languageWrites(nestedDoc({ 'languages/jpn': 'true' }))).toEqual({ 'languages/eng': 'true' });
expect(languageWrites(nestedDoc({ languages: { eng: 'true', jpn: true } }))).toEqual({});
});
});
describe('process', () => {
it('writes the audio languages/* key-set from the nested payload in one merge', async () => {
const { callback, writer } = await run(nestedDoc());
expect(callback?.status).toBe('completed');
expect(writer.calls).toEqual([
{ hashId: 'cid-1', metadata: { 'languages/jpn': 'true', 'languages/eng': 'true' } },
]);
});
it('writes from flat stream/{n} keys too', async () => {
const flat = Object.fromEntries(REAL_STREAMS.map((s, i) => [`stream/${i}`, s]));
const { callback, writer } = await run(flat);
expect(callback?.status).toBe('completed');
expect(writer.calls[0].metadata).toEqual({ 'languages/jpn': 'true', 'languages/eng': 'true' });
});
it('never writes titles/* or any non-languages key, even with originalTitle and title keys', async () => {
const { writer } = await run(nestedDoc({ 'titles/eng': 'Better Title', titles: { jpn: '番組' } }));
const keys = writer.calls.flatMap((c) => Object.keys(c.metadata));
expect(keys.length).toBeGreaterThan(0);
expect(keys.every((k) => k.startsWith('languages/'))).toBe(true);
expect(keys.some((k) => k.startsWith('titles'))).toBe(false);
});
it('does not derive languages from title keys', async () => {
const { callback, writer } = await run({ originalTitle: 'x', 'titles/jpn': 'y', 'titles/jpl': 'z' });
expect(callback?.status).toBe('completed');
expect(writer.calls).toEqual([]);
});
it('completes without writing when there are no streams or no tagged audio', async () => {
for (const meta of [undefined, {}, { stream: [] }, { stream: [{ type: 'audio', language: 'und' }] }]) {
const { callback, writer } = await run(meta);
expect(callback?.status).toBe('completed');
expect(writer.calls).toEqual([]);
}
});
it('is idempotent: a re-run over a record that already has the members writes nothing', async () => {
const { writer } = await run(nestedDoc({ 'languages/jpn': 'true', 'languages/eng': 'true' }));
expect(writer.calls).toEqual([]);
});
it('fails the task when the write is rejected', async () => {
const { callback } = await run(nestedDoc(), new RecordingWriter(false));
expect(callback?.status).toBe('failed');
expect(callback?.error).toContain('languages/jpn');
});
});