gts-x / src /lib /qc /mec-parser.ts
JAMES HAN
Fix S890 Thai decoding + add Amazon MEC XML metadata QC
40069e0
Raw
History Blame Contribute Delete
6.58 kB
/**
* Amazon MEC (Media Entertainment Core) XML metadata parser.
* Parses mdmec:CoreMetadata XML and extracts text fields as QC lines
* so they can be fed through the AI QC pipeline for spelling/grammar checks.
*/
import type { QCLine } from "./types";
interface MECField {
fieldName: string;
language: string;
value: string;
}
/**
* Detect if a buffer is an Amazon MEC XML file.
*/
export function isMECFile(buffer: Buffer, filename: string): boolean {
if (!filename.match(/\.(xml|mec)$/i)) return false;
const head = buffer.subarray(0, Math.min(500, buffer.length)).toString("utf-8");
return head.includes("mdmec:CoreMetadata") || head.includes("movielabs.com/schema/mdmec");
}
/**
* Parse MEC XML and extract all text fields as QCLines.
* Each field becomes a QCLine with the field path as context.
*/
export function parseMECToLines(buffer: Buffer, targetLanguage?: string): {
lines: QCLine[];
metadata: MECMetadata;
} {
const xml = buffer.toString("utf-8");
const fields = extractMECFields(xml);
const metadata = extractMECMetadata(xml);
// Filter by language if specified
const filtered = targetLanguage
? fields.filter(f => {
// Match language prefix (e.g. "en" matches "en-US", "fil" matches "fil-PH")
const langPrefix = targetLanguage.toLowerCase().split("-")[0];
const fieldLangPrefix = f.language.toLowerCase().split("-")[0];
return fieldLangPrefix === langPrefix || f.language === "" || f.language === "all";
})
: fields;
const lines: QCLine[] = filtered.map((f, i) => ({
id: i + 1,
text: f.value,
tcIn: undefined,
tcOut: undefined,
// Store field context in a way the QC prompts can use
_fieldName: f.fieldName,
_language: f.language,
} as QCLine & { _fieldName: string; _language: string }));
return { lines, metadata };
}
export interface MECMetadata {
contentId: string;
title: string;
originalTitle: string;
workType: string;
releaseYear: string;
releaseDate: string;
languages: string[];
country: string;
genres: string[];
castCount: number;
crewCount: number;
}
function extractMECMetadata(xml: string): MECMetadata {
const get = (tag: string): string => {
const m = xml.match(new RegExp(`<[^>]*${tag}[^>]*>([^<]*)<`));
return m ? m[1].trim() : "";
};
const getAttr = (tag: string, attr: string): string => {
const m = xml.match(new RegExp(`<[^>]*${tag}[^>]*${attr}="([^"]*)"`));
return m ? m[1].trim() : "";
};
const languages = new Set<string>();
const langMatches = xml.matchAll(/language="([^"]+)"/g);
for (const m of langMatches) languages.add(m[1]);
const genresSet = new Set<string>();
const genreMatches = xml.matchAll(/Genre\s+id="([^"]+)"/g);
for (const m of genreMatches) genresSet.add(m[1]);
const castCount = (xml.match(/Actor<\/md:JobFunction>/g) || []).length;
const crewCount = (xml.match(/<md:JobFunction>(?!Actor)/g) || []).length;
return {
contentId: getAttr("Basic", "ContentID"),
title: get("TitleDisplayUnlimited"),
originalTitle: get("OriginalTitle"),
workType: get("WorkType"),
releaseYear: get("ReleaseYear"),
releaseDate: get("ReleaseDate"),
languages: [...languages],
country: get("country"),
genres: [...genresSet],
castCount: castCount / 2, // dedupe fil-PH + en-US
crewCount: crewCount / 2,
};
}
function extractMECFields(xml: string): MECField[] {
const fields: MECField[] = [];
// Extract all LocalizedInfo blocks
const localizedBlocks = xml.matchAll(
/<md:LocalizedInfo\s+language="([^"]*)"[^>]*>([\s\S]*?)<\/md:LocalizedInfo>/g
);
for (const block of localizedBlocks) {
const lang = block[1];
const content = block[2];
// Title
const title = content.match(/<md:TitleDisplayUnlimited>([^<]*)<\/md:TitleDisplayUnlimited>/);
if (title?.[1]?.trim()) {
fields.push({ fieldName: `Title (${lang})`, language: lang, value: title[1].trim() });
}
// Original Title
const origTitle = content.match(/<md:OriginalTitle>([^<]*)<\/md:OriginalTitle>/);
if (origTitle?.[1]?.trim()) {
fields.push({ fieldName: `OriginalTitle (${lang})`, language: lang, value: origTitle[1].trim() });
}
// Summary190
const s190 = content.match(/<md:Summary190>([^<]*)<\/md:Summary190>/);
if (s190?.[1]?.trim()) {
fields.push({ fieldName: `Summary190 (${lang})`, language: lang, value: s190[1].trim() });
}
// Summary400
const s400 = content.match(/<md:Summary400>([\s\S]*?)<\/md:Summary400>/);
if (s400?.[1]?.trim()) {
fields.push({ fieldName: `Summary400 (${lang})`, language: lang, value: s400[1].trim() });
}
// Summary4000
const s4000 = content.match(/<md:Summary4000>([\s\S]*?)<\/md:Summary4000>/);
if (s4000?.[1]?.trim()) {
fields.push({ fieldName: `Summary4000 (${lang})`, language: lang, value: s4000[1].trim() });
}
}
// Extract People names + characters
const people = xml.matchAll(
/<md:People>([\s\S]*?)<\/md:People>/g
);
const seenPeople = new Set<string>();
for (const p of people) {
const content = p[1];
const job = content.match(/<md:JobFunction>([^<]*)<\/md:JobFunction>/)?.[1]?.trim();
const name = content.match(/<md:DisplayName\s+language="([^"]*)"[^>]*>([^<]*)<\/md:DisplayName>/);
const character = content.match(/<md:Character>([^<]*)<\/md:Character>/)?.[1]?.trim();
if (name?.[2]?.trim()) {
const lang = name[1];
const nameVal = name[2].trim();
const key = `${job}:${nameVal}:${lang}`;
if (!seenPeople.has(key)) {
seenPeople.add(key);
const label = character
? `${job}: ${nameVal} as "${character}" (${lang})`
: `${job}: ${nameVal} (${lang})`;
fields.push({ fieldName: label, language: lang, value: nameVal });
if (character) {
fields.push({ fieldName: `Character name (${lang})`, language: lang, value: character });
}
}
}
}
// CompanyDisplayCredit
const company = xml.match(/<md:DisplayString>([^<]*)<\/md:DisplayString>/);
if (company?.[1]?.trim()) {
fields.push({ fieldName: "CompanyDisplayCredit", language: "all", value: company[1].trim() });
}
return fields;
}
/**
* Format MEC lines for QC prompt — includes field context.
*/
export function formatMECLinesForPrompt(lines: (QCLine & { _fieldName?: string; _language?: string })[]): string {
return lines.map(l => {
const field = l._fieldName || `Field ${l.id}`;
return `[LINE ${l.id}] [${field}] ${l.text}`;
}).join("\n");
}