/** * 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(); const langMatches = xml.matchAll(/language="([^"]+)"/g); for (const m of langMatches) languages.add(m[1]); const genresSet = new Set(); 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(/(?!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( /]*>([\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>/); if (title?.[1]?.trim()) { fields.push({ fieldName: `Title (${lang})`, language: lang, value: title[1].trim() }); } // Original Title const origTitle = content.match(/([^<]*)<\/md:OriginalTitle>/); if (origTitle?.[1]?.trim()) { fields.push({ fieldName: `OriginalTitle (${lang})`, language: lang, value: origTitle[1].trim() }); } // Summary190 const s190 = content.match(/([^<]*)<\/md:Summary190>/); if (s190?.[1]?.trim()) { fields.push({ fieldName: `Summary190 (${lang})`, language: lang, value: s190[1].trim() }); } // Summary400 const s400 = content.match(/([\s\S]*?)<\/md:Summary400>/); if (s400?.[1]?.trim()) { fields.push({ fieldName: `Summary400 (${lang})`, language: lang, value: s400[1].trim() }); } // Summary4000 const s4000 = content.match(/([\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( /([\s\S]*?)<\/md:People>/g ); const seenPeople = new Set(); for (const p of people) { const content = p[1]; const job = content.match(/([^<]*)<\/md:JobFunction>/)?.[1]?.trim(); const name = content.match(/]*>([^<]*)<\/md:DisplayName>/); const character = content.match(/([^<]*)<\/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>/); 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"); }