feat(scrape): 覆盖方式加「更新」并设为缺省,季集缺元数据不写占位,作品类型判定统一

- 每次写入在 scrape_record.files[].hash 记 sha256;update 模式只覆盖本工具写过且没被改过的文件,
  被改过的跳过(scrape_user_modified),内容没变不重写(scrape_unchanged),旧记录无 hash 按修改时间兜底;
  迁移 0070 给 scrape_record.mode 加 update,设置缺省改为 update(旧值照旧有效)
- 季 / 集单元缺元数据时按 TMDB id 现取季详情补齐;仍缺跳过(scrape_metadata_missing)并记 partial,不写占位 NFO
- scrapeKindOf 统一作品类型判定,合集 / 其他类作品 NFO 根元素为 <movie>;TMDB 映射按作品类型过滤 entity_type
- 兜底异常记 scrape_internal_error 并保留已写文件结果
- 新增 scheduleEntityRescrape(刮削过的作品延后排 update 重刮,排队记录合批防抖)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 21:04:57 +08:00
1 parent 00045a24cb
commit dbbf39eda4
13 files changed
+607 -54

No files matched your search

+8
View File
@@ -10,6 +10,14 @@
// 视频直接在库根(没有作品目录)时,电影图片改用 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`,
// 剧集不写 tvshow / 季文件(不能往库根里放 tvshow.nfo)。
/**
* 作品类型 → 刮削布局与 NFO 根元素:剧集 / 综艺按剧集(tvshow.nfo + 季 / 集),其余(电影、合集、其他)都按电影
* (`<视频名>.nfo`,根元素 `<movie>`)。刮削的范围判定与 NFO 生成都用这一个口径。
*/
export function scrapeKindOf(entityKind: string): "movie" | "tv" {
return entityKind === "series" || entityKind === "variety" || entityKind === "tv" ? "tv" : "movie";
}
/** 纯字符串的 POSIX dirname / basename / extname(domain 不依赖 node:path,前端也能打包) */
const posix = {
dirname(p: string): string {
+8
View File
@@ -4,6 +4,7 @@ import {
inferWorkDir,
isSeasonDirName,
planScrapeTargets,
scrapeKindOf,
seasonNfo,
seasonOfDirName,
seasonPosterName,
@@ -247,3 +248,10 @@ describe("刮削目标布局", () => {
expect(ep.map((x) => x.role)).toEqual(["episode_nfo", "episode_thumb"]);
});
});
describe("作品类型 → 刮削类型", () => {
it("剧集 / 综艺按剧集,电影 / 合集 / 其他按电影", () => {
expect(["series", "variety", "tv"].map(scrapeKindOf)).toEqual(["tv", "tv", "tv"]);
expect(["movie", "collection", "other"].map(scrapeKindOf)).toEqual(["movie", "movie", "movie"]);
});
});