feat(scrape): 多集文件写多个 episodedetails 块,综艺日期期数按播出日期对集
- 多集文件(文件名 E01-E02 或覆盖多个集单元)按 Kodi 约定在一个 NFO 里每集写一块,MP 只写首集,偏离登记在 nfo-deviations.json; - 综艺按日期命名的期数按本地季表或 TMDB 季详情的播出日期对上集,写季号、集号与 aired,不再 scrape_no_episode 跳过; - 认不出集号、集数待确认的跳过让记录记部分完成(已存在 / 没变 / 被人改过仍算完成)。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
0085e45ca7
commit
8c4c7d028a
8 files changed
+513
-61
No files matched your search
@@ -11,10 +11,10 @@ import {
|
||||
type ScrapeRecord,
|
||||
} from "@umt/contracts";
|
||||
import {
|
||||
episodeNfo,
|
||||
fileKindOf,
|
||||
inferWorkDir,
|
||||
isSeasonDirName,
|
||||
multiEpisodeNfo,
|
||||
parseReleasePath,
|
||||
planScrapeTargets,
|
||||
type ScrapeTarget,
|
||||
@@ -565,15 +565,23 @@ async function layoutOf(rootReal: string, rel: string, isDir: boolean, kind: "mo
|
||||
return { videos, workDir: rel || null, parts: all, dirSeason: null };
|
||||
}
|
||||
|
||||
/** 已登记视频的季集(覆盖单元里最小的一集,多集文件按首集) */
|
||||
/** 已登记视频的季集:覆盖单元里最小的一集为首集,同一季覆盖的其余集一并列出(多集文件每集一个 NFO 块) */
|
||||
interface Covered {
|
||||
season: number;
|
||||
episode: number;
|
||||
episodes: number[];
|
||||
}
|
||||
/** 一个文件最多按这么多集写 NFO 块(整季合成一个文件的极端情况不至于写出巨型 NFO) */
|
||||
const MAX_EPISODES_PER_FILE = 100;
|
||||
|
||||
async function coverageOf(
|
||||
d: AoDeps,
|
||||
libraryRootId: string,
|
||||
entityId: string,
|
||||
videos: readonly string[],
|
||||
units: readonly { id: string; kind: string; season_number: number | null; episode_number: number | null }[],
|
||||
): Promise<Map<string, { season: number; episode: number }>> {
|
||||
const out = new Map<string, { season: number; episode: number }>();
|
||||
): Promise<Map<string, Covered>> {
|
||||
const out = new Map<string, Covered>();
|
||||
if (!videos.length) return out;
|
||||
const rows = await d.ctx.db
|
||||
.selectFrom("asset_location as l")
|
||||
@@ -586,16 +594,58 @@ async function coverageOf(
|
||||
.where("a.media_entity_id", "=", entityId)
|
||||
.execute();
|
||||
const byId = new Map(units.map((u) => [u.id, u]));
|
||||
const all = new Map<string, { season: number; episode: number }[]>();
|
||||
for (const r of rows) {
|
||||
const u = byId.get(r.content_unit_id);
|
||||
if (u?.kind !== "episode" || u.season_number === null || u.episode_number === null) continue;
|
||||
const cur = out.get(r.relative_path);
|
||||
if (!cur || u.season_number < cur.season || (u.season_number === cur.season && u.episode_number < cur.episode))
|
||||
out.set(r.relative_path, { season: u.season_number, episode: u.episode_number });
|
||||
all.set(r.relative_path, [
|
||||
...(all.get(r.relative_path) ?? []),
|
||||
{ season: u.season_number, episode: u.episode_number },
|
||||
]);
|
||||
}
|
||||
for (const [path, list] of all) {
|
||||
list.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||
const first = list[0]!;
|
||||
const episodes = [...new Set(list.filter((x) => x.season === first.season).map((x) => x.episode))].slice(
|
||||
0,
|
||||
MAX_EPISODES_PER_FILE,
|
||||
);
|
||||
out.set(path, { season: first.season, episode: first.episode, episodes });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 集区间 → 集号列表(多集文件 E01-E02) */
|
||||
const rangeOf = (r: { start: number; end: number }): number[] =>
|
||||
Array.from({ length: Math.max(1, Math.min(r.end - r.start + 1, MAX_EPISODES_PER_FILE)) }, (_, i) => r.start + i);
|
||||
|
||||
/** 播出日期统一成 YYYY-MM-DD(解析结果是 YYYYMMDD;单元列可能是 Date) */
|
||||
function isoDay(v: unknown): string | null {
|
||||
if (v instanceof Date) return Number.isNaN(+v) ? null : v.toISOString().slice(0, 10);
|
||||
if (typeof v !== "string") return null;
|
||||
const m = /^(\d{4})-?(\d{2})-?(\d{2})/.exec(v.trim());
|
||||
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
|
||||
}
|
||||
|
||||
/** 按播出日期在集单元里找集(综艺按日期命名的期数):季已知时只看该季;多于一个候选时不猜 */
|
||||
function episodeByAirDate(
|
||||
units: ReadonlyMap<string, UnitForNfo>,
|
||||
day: string,
|
||||
season: number | null,
|
||||
): { season: number; episode: number } | null {
|
||||
const hits: { season: number; episode: number }[] = [];
|
||||
for (const u of units.values()) {
|
||||
if (u.season_number === null || u.episode_number === null) continue;
|
||||
if (season !== null && u.season_number !== season) continue;
|
||||
const m = (u.metadata ?? {}) as { airDate?: { value?: unknown } };
|
||||
if (isoDay(m.airDate?.value) === day || isoDay(u.air_date) === day)
|
||||
hits.push({ season: u.season_number, episode: u.episode_number });
|
||||
}
|
||||
return hits.length === 1 ? hits[0]! : null;
|
||||
}
|
||||
|
||||
/** 预期内的跳过(不影响记录状态):已存在、内容没变、被人改过 */
|
||||
const EXPECTED_SKIPS = new Set(["scrape_exists", "scrape_unchanged", "scrape_user_modified"]);
|
||||
const sha256 = (c: Uint8Array | string) => createHash("sha256").update(c).digest("hex");
|
||||
/** 旧版本写的文件没有 hash:修改时间不晚于那次刮削结束(加这点余量)就当没被改过 */
|
||||
const LEGACY_MTIME_SLACK_MS = 2_000;
|
||||
@@ -757,7 +807,7 @@ export async function runScrapeRecord(
|
||||
|
||||
// ── 字段:本地作品(有元数据)优先 ──
|
||||
let fields: Fields | null = null;
|
||||
let coverage = new Map<string, { season: number; episode: number }>();
|
||||
let coverage = new Map<string, Covered>();
|
||||
let mapEpisodes: EntityEpisodeMapper | null = null;
|
||||
if (match.mediaEntityId) {
|
||||
const e = await db
|
||||
@@ -809,9 +859,12 @@ export async function runScrapeRecord(
|
||||
|
||||
// 季 / 集号:已登记的按覆盖单元;其余按路径解析(季目录名兜底,同 MP 对每集重新 MetaInfoPath)
|
||||
const videos: ScrapeVideo[] = [];
|
||||
const noEpisode: ScrapeFileResult[] = [];
|
||||
/** 集数待确认(不写分集 NFO,原因 episode_unresolved) */
|
||||
const unresolved = new Set<string>();
|
||||
/** 只有播出日期、没有集号的视频(综艺按日期命名的期数):路径 → YYYY-MM-DD,季 / 集元数据到位后按日期对集 */
|
||||
const dated = new Map<string, string>();
|
||||
// 集数映射(批次 3):未登记的文件按本地剧集作品的季表 + 规则把文件名季集换成规范季集再对集元数据;
|
||||
// 集数待确认的不写分集 NFO(原因 episode_unresolved)
|
||||
// 综艺按播出日期对上季表里的集
|
||||
for (const v of lay.videos) {
|
||||
if (kind === "movie") {
|
||||
videos.push({ path: v });
|
||||
@@ -819,33 +872,29 @@ export async function runScrapeRecord(
|
||||
}
|
||||
const cov = coverage.get(v);
|
||||
if (cov) {
|
||||
videos.push({ path: v, season: cov.season, episode: cov.episode });
|
||||
videos.push({ path: v, season: cov.season, episode: cov.episode, episodes: cov.episodes });
|
||||
continue;
|
||||
}
|
||||
const p = parseReleasePath(v);
|
||||
let season = p.season ?? lay.dirSeason ?? seasonOfDirName(baseOf(dirOf(v))) ?? (p.episodes ? 1 : null);
|
||||
let episode = p.episodes?.start ?? null;
|
||||
if (mapEpisodes && p.episodes) {
|
||||
let range = p.episodes ?? null;
|
||||
if (mapEpisodes && (p.episodes || p.airDate)) {
|
||||
const m = mapEpisodes(p);
|
||||
if (m.resolution.method === "unresolved") {
|
||||
noEpisode.push({
|
||||
path: v,
|
||||
kind: "nfo",
|
||||
role: "episode_nfo",
|
||||
action: "skipped",
|
||||
reason: "episode_unresolved",
|
||||
});
|
||||
unresolved.add(v);
|
||||
videos.push({ path: v, season, episode: null });
|
||||
continue;
|
||||
}
|
||||
if (m.resolution.canonical) {
|
||||
season = m.resolution.canonical.season;
|
||||
episode = m.resolution.canonical.episodes.start;
|
||||
range = m.resolution.canonical.episodes;
|
||||
}
|
||||
}
|
||||
if (episode === null)
|
||||
noEpisode.push({ path: v, kind: "nfo", role: "episode_nfo", action: "skipped", reason: "scrape_no_episode" });
|
||||
videos.push({ path: v, season, episode });
|
||||
if (!range && p.airDate) {
|
||||
const day = isoDay(p.airDate);
|
||||
if (day) dated.set(v, day);
|
||||
}
|
||||
videos.push({ path: v, season, episode: range?.start ?? null, ...(range ? { episodes: rangeOf(range) } : {}) });
|
||||
}
|
||||
|
||||
// ── 没有本地字段:按 TMDB 条目直接取 ──
|
||||
@@ -868,6 +917,21 @@ export async function runScrapeRecord(
|
||||
|
||||
// ── 季 / 集元数据:TMDB 分支取每个涉及的季;本地分支只在季 / 集单元缺元数据时按 TMDB id 现取补齐 ──
|
||||
const { seasonUnit, episodeUnit } = fields;
|
||||
const epsOf = (v: ScrapeVideo) =>
|
||||
v.episodes?.length ? v.episodes : typeof v.episode === "number" ? [v.episode] : [];
|
||||
/** 只有播出日期的视频按日期对集(本地集单元或刚取到的 TMDB 季详情) */
|
||||
const resolveDated = () => {
|
||||
for (const v of videos) {
|
||||
const day = dated.get(v.path);
|
||||
if (!day || typeof v.episode === "number") continue;
|
||||
const hit = episodeByAirDate(episodeUnit, day, v.season ?? null);
|
||||
if (!hit) continue;
|
||||
v.season = hit.season;
|
||||
v.episode = hit.episode;
|
||||
v.episodes = [hit.episode];
|
||||
}
|
||||
};
|
||||
resolveDated();
|
||||
if (kind === "tv") {
|
||||
const tmdbId = match.externalId ?? fields.ids.tmdb ?? null;
|
||||
const seasons = [...new Set(videos.map((v) => v.season).filter((s): s is number => typeof s === "number"))];
|
||||
@@ -877,8 +941,8 @@ export async function runScrapeRecord(
|
||||
videos.some(
|
||||
(v) =>
|
||||
v.season === n &&
|
||||
typeof v.episode === "number" &&
|
||||
episodeMetadataMissing(episodeUnit.get(`${n}:${v.episode}`)),
|
||||
(epsOf(v).some((ep) => episodeMetadataMissing(episodeUnit.get(`${n}:${ep}`))) ||
|
||||
(typeof v.episode !== "number" && dated.has(v.path))),
|
||||
);
|
||||
if (!tmdbId || (!fromTmdb && !lacking)) continue;
|
||||
const sd = await d.ports.scrapeTmdbSeason(tmdbId, n).catch((err) => {
|
||||
@@ -894,6 +958,17 @@ export async function runScrapeRecord(
|
||||
if (fromTmdb || episodeMetadataMissing(episodeUnit.get(k))) episodeUnit.set(k, eu);
|
||||
}
|
||||
}
|
||||
resolveDated();
|
||||
for (const v of videos) {
|
||||
if (typeof v.episode === "number") continue;
|
||||
files.push({
|
||||
path: v.path,
|
||||
kind: "nfo",
|
||||
role: "episode_nfo",
|
||||
action: "skipped",
|
||||
reason: unresolved.has(v.path) ? "episode_unresolved" : "scrape_no_episode",
|
||||
});
|
||||
}
|
||||
}
|
||||
const work = nfoWork(fields.entity, fields.ids);
|
||||
const seasonPosters = new Map<number, string | null>();
|
||||
@@ -909,7 +984,6 @@ export async function runScrapeRecord(
|
||||
options: { images: settings.scrapeImages, episodeThumbs: settings.scrapeEpisodeThumbs },
|
||||
parts: lay.parts,
|
||||
});
|
||||
files.push(...noEpisode);
|
||||
// 「更新」模式:本工具以前写过的文件(路径 → 写入时的 hash / 时间)
|
||||
const ours = mode === "update" ? await previousWrites(d, rec.root_id, recordId) : new Map<string, PrevWrite>();
|
||||
const imageCache = new Map<string, Promise<Uint8Array>>();
|
||||
@@ -978,12 +1052,15 @@ export async function runScrapeRecord(
|
||||
}
|
||||
xml = seasonNfo(nfoSeason(t.season!, u));
|
||||
} else {
|
||||
const u = episodeUnit.get(`${t.season}:${t.episode}`);
|
||||
if (episodeMetadataMissing(u) && hasTmdbId) {
|
||||
// 多集文件每集一块;缺元数据的集不写(全缺才跳过整个 NFO)
|
||||
const eps = (t.episodes?.length ? t.episodes : [t.episode!]).filter(
|
||||
(ep) => !(hasTmdbId && episodeMetadataMissing(episodeUnit.get(`${t.season}:${ep}`))),
|
||||
);
|
||||
if (!eps.length) {
|
||||
files.push(missingMeta(t));
|
||||
continue;
|
||||
}
|
||||
xml = episodeNfo(nfoEpisode(t.season!, t.episode!, u));
|
||||
xml = multiEpisodeNfo(eps.map((ep) => nfoEpisode(t.season!, ep, episodeUnit.get(`${t.season}:${ep}`))));
|
||||
}
|
||||
files.push(await write(t, dec, xml));
|
||||
}
|
||||
@@ -1007,11 +1084,12 @@ export async function runScrapeRecord(
|
||||
|
||||
const written = files.filter((f) => f.action === "written").length;
|
||||
const failed = files.filter((f) => f.action === "failed").length;
|
||||
// 缺元数据跳过的季 / 集不算成功(要刷新元数据后再刮)
|
||||
const lacking = files.some((f) => f.reason === "scrape_metadata_missing");
|
||||
// 缺元数据、认不出集号、集数待确认而跳过的不算成功(要刷新元数据 / 确认集数后再刮);
|
||||
// 已存在、内容没变、被人改过的跳过是预期的
|
||||
const incomplete = files.find((f) => f.action === "skipped" && !EXPECTED_SKIPS.has(f.reason ?? ""));
|
||||
const status: ScrapeRecord["status"] =
|
||||
failed === 0
|
||||
? lacking
|
||||
? incomplete
|
||||
? "partial"
|
||||
: "succeeded"
|
||||
: written > 0 || files.some((f) => f.action === "skipped")
|
||||
@@ -1021,9 +1099,9 @@ export async function runScrapeRecord(
|
||||
status,
|
||||
reason: failed
|
||||
? (files.find((f) => f.action === "failed")?.reason ?? null)
|
||||
: lacking
|
||||
: files.some((f) => f.reason === "scrape_metadata_missing")
|
||||
? "scrape_metadata_missing"
|
||||
: null,
|
||||
: (incomplete?.reason ?? null),
|
||||
files,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
Reference in new issue
Block a user