import { parseDocument } from "yaml"; /** Ordered TMDB rules; first matching category wins. */ export interface ClassificationRule { category: string; conditions: { field: string; values: string[] }[]; } export interface ClassificationConfig { movie: ClassificationRule[]; tv: ClassificationRule[]; } export interface ClassificationResult { category: string | null; matchedFields: string[]; fallback: boolean; } export { DEFAULT_CLASSIFICATION_YAML } from "@umt/contracts"; const scalar = (v: unknown): v is string | number | boolean => typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)); export function parseClassificationYaml(yaml: string): ClassificationConfig { const doc = parseDocument(yaml, { uniqueKeys: true, schema: "core" }); if (doc.errors.length) throw new Error(doc.errors.map((e) => e.message).join("; ")); const raw: unknown = doc.toJS({ mapAsMap: true, maxAliasCount: 0 }); if (!(raw instanceof Map) || [...raw.keys()].some((k) => k !== "movie" && k !== "tv")) throw new Error("分类配置的一级名称只能是 movie、tv"); const config: ClassificationConfig = { movie: [], tv: [] }; for (const type of ["movie", "tv"] as const) { const categories: unknown = raw.get(type); if (!(categories instanceof Map)) throw new Error(`${type} 必须是分类名称映射`); for (const [category, fields] of categories) { if ( typeof category !== "string" || !category.trim() || category.trim() !== category || /[\\/<>:"|?*]/.test(category) || Array.from(category).some((ch) => ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127) || category === "." || category === ".." || /[. ]$/.test(category) || /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(category) ) throw new Error(`${type} 分类名称必须是安全的单层目录名`); if (fields !== null && !(fields instanceof Map)) throw new Error(`${type}.${category} 必须是条件映射或 null`); const conditions: ClassificationRule["conditions"] = []; for (const [field, input] of fields ?? []) { if (typeof field !== "string" || !field.trim()) throw new Error(`${category} 的条件字段必须是非空字符串`); const list: unknown[] = Array.isArray(input) ? input : [input]; if (!list.length || !list.every(scalar)) throw new Error(`${category}.${field} 条件必须是标量或标量列表`); const values = list.flatMap((v) => String(v) .split(",") .map((s) => s.trim()), ); if (values.some((v) => !v || v === "!")) throw new Error(`${category}.${field} 条件值不能为空`); if (field === "release_year") { for (const value of values) { const year = value.replace(/^!/, ""); if (!/^\d{4}(?:-\d{4})?$/.test(year) || (year.includes("-") && year.slice(0, 4) > year.slice(5))) throw new Error(`${category}.release_year 请使用 YYYY 或 YYYY-YYYY`); } } conditions.push({ field, values }); } config[type].push({ category, conditions }); } if (config[type].some((r, i) => !r.conditions.length && i !== config[type].length - 1)) throw new Error(`${type} 无条件兜底分类必须放在最后`); } return config; } function actualValues(raw: Record, type: "movie" | "tv", field: string): string[] { let input = field === "release_year" ? raw[type === "movie" ? "release_date" : "first_air_date"] : raw[field]; if (field === "genre_ids" && input === undefined) input = raw.genres; const list = Array.isArray(input) ? input : [input]; return list.flatMap((item) => { let value = item; if (value && typeof value === "object") { const o = value as Record; value = field === "production_countries" ? o.iso_3166_1 : field === "genre_ids" || field === "genres" ? o.id : undefined; } if (!scalar(value) || String(value).trim() === "") return []; if (field === "release_year") { const year = String(value) .match(/^\d{4}(?:-|$)/)?.[0] .slice(0, 4); return year ? [year] : []; } return [String(value)]; }); } function matches(actual: string[], values: string[], field: string): boolean { if (!actual.length) return false; const hit = (value: string) => actual.some((a) => { if (field === "release_year" && value.includes("-")) { const [start, end] = value.split("-"); return a >= start! && a <= end!; } return a === value; }); const excluded = values.filter((v) => v.startsWith("!")).map((v) => v.slice(1)); const included = values.filter((v) => !v.startsWith("!")); return !excluded.some(hit) && (!included.length || included.some(hit)); } export function classifyTmdb( config: ClassificationConfig, type: "movie" | "tv", raw: Record | null | undefined, ): ClassificationResult { const none: ClassificationResult = { category: null, matchedFields: [], fallback: false }; if (!raw || !Object.keys(raw).length) return none; for (const rule of config[type]) { if (rule.conditions.every(({ field, values }) => matches(actualValues(raw, type, field), values, field))) return { category: rule.category, matchedFields: rule.conditions.map((c) => c.field), fallback: !rule.conditions.length, }; } return none; }