86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
/**
|
|
* Minimal Kodi/Jellyfin-style .nfo parser. Operates on a string that contains
|
|
* an XML document and extracts the fields we map onto cover metadata.
|
|
*
|
|
* No XML library dependency — small, regex-based, deliberately lenient.
|
|
* Returns null if the buffer doesn't look like a movie .nfo.
|
|
*/
|
|
|
|
export interface NfoMetadata {
|
|
title?: string;
|
|
code?: string;
|
|
releaseDate?: string;
|
|
runtimeMin?: number;
|
|
director?: string;
|
|
studio?: string;
|
|
series?: string;
|
|
actresses?: string[];
|
|
genres?: string[];
|
|
notes?: string;
|
|
}
|
|
|
|
const TAG_RE = (tag: string) =>
|
|
new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "gi");
|
|
|
|
function firstMatch(xml: string, tag: string): string | undefined {
|
|
const re = TAG_RE(tag);
|
|
const m = re.exec(xml);
|
|
if (!m) return undefined;
|
|
return decodeEntities(m[1].trim());
|
|
}
|
|
|
|
function allMatches(xml: string, tag: string): string[] {
|
|
const re = TAG_RE(tag);
|
|
const out: string[] = [];
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(xml)) !== null) {
|
|
const text = decodeEntities(m[1].trim());
|
|
if (text) out.push(text);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function decodeEntities(s: string): string {
|
|
return s
|
|
.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, "$1")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
export function parseNfo(xml: string): NfoMetadata | null {
|
|
if (!/<movie\b|<id\b|<title\b/i.test(xml)) return null;
|
|
|
|
const runtimeRaw = firstMatch(xml, "runtime");
|
|
const runtime = runtimeRaw ? parseInt(runtimeRaw, 10) : NaN;
|
|
|
|
return strip({
|
|
title: firstMatch(xml, "title") ?? firstMatch(xml, "originaltitle"),
|
|
code: firstMatch(xml, "id") ?? firstMatch(xml, "num"),
|
|
releaseDate: firstMatch(xml, "premiered") ?? firstMatch(xml, "releasedate") ?? firstMatch(xml, "year"),
|
|
runtimeMin: Number.isFinite(runtime) ? runtime : undefined,
|
|
director: firstMatch(xml, "director"),
|
|
studio: firstMatch(xml, "studio") ?? firstMatch(xml, "maker"),
|
|
series: firstMatch(xml, "set") ?? firstMatch(xml, "series"),
|
|
actresses: allMatches(xml, "actor").map((block) => {
|
|
const name = block.match(/<name>([\s\S]*?)<\/name>/i);
|
|
return decodeEntities(name ? name[1].trim() : block.trim());
|
|
}).filter(Boolean),
|
|
genres: [...allMatches(xml, "genre"), ...allMatches(xml, "tag")],
|
|
notes: firstMatch(xml, "plot") ?? firstMatch(xml, "outline"),
|
|
});
|
|
}
|
|
|
|
function strip(o: NfoMetadata): NfoMetadata {
|
|
const out: NfoMetadata = {};
|
|
for (const [k, v] of Object.entries(o)) {
|
|
if (v == null) continue;
|
|
if (Array.isArray(v) && v.length === 0) continue;
|
|
if (typeof v === "string" && v === "") continue;
|
|
(out as Record<string, unknown>)[k] = v;
|
|
}
|
|
return out;
|
|
}
|