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:
1 parent
00045a24cb
commit
dbbf39eda4
13 files changed
+607
-54
No files matched your search
@@ -105,7 +105,11 @@ const REASONS: Record<string, ReasonText> = {
|
||||
task_enqueue_failed: { text: "后台任务没排上", action: "稍后重试" },
|
||||
scrape_no_videos: { text: "目录里没有视频", action: "对作品或季目录刮削" },
|
||||
scrape_tmdb_unavailable: { text: "TMDB 不可用", action: "检查 TMDB 设置后重新刮削" },
|
||||
scrape_exists: { text: "文件已存在,只补缺失时不改" },
|
||||
scrape_exists: { text: "文件已存在,不是本工具写的或只补缺失时不改" },
|
||||
scrape_unchanged: { text: "内容没有变化" },
|
||||
scrape_user_modified: { text: "文件写入后被改过,更新时不覆盖", action: "要替换就选全部覆盖重新刮削" },
|
||||
scrape_metadata_missing: { text: "缺少这一季或这一集的元数据,没有写占位文件", action: "刷新元数据后重新刮削" },
|
||||
scrape_internal_error: { text: "刮削过程中出错", action: "查看日志后重新刮削" },
|
||||
scrape_write_failed: { text: "写入目录失败", action: "检查目录权限后重新刮削" },
|
||||
scrape_image_failed: { text: "图片没下载成功", action: "稍后重新刮削" },
|
||||
scrape_no_episode: { text: "文件名里认不出集号" },
|
||||
|
||||
@@ -14,11 +14,16 @@ export const ScrapeFileResult = z.object({
|
||||
action: z.enum(["written", "skipped", "failed"]),
|
||||
/** 跳过 / 失败原因码(reasons.ts:scrape_exists / scrape_write_failed / scrape_image_failed …) */
|
||||
reason: z.string().nullable(),
|
||||
/** 本工具写入(或核对未变)时文件内容的 sha256;「更新」模式据此判断文件之后有没有被人改过 */
|
||||
hash: z.string().nullable().optional(),
|
||||
});
|
||||
export type ScrapeFileResult = z.infer<typeof ScrapeFileResult>;
|
||||
|
||||
/** missing = 只补缺失;overwrite = 全部覆盖 */
|
||||
export const ScrapeMode = z.enum(["missing", "overwrite"]);
|
||||
/**
|
||||
* missing = 只补缺失;overwrite = 全部覆盖;
|
||||
* update = 更新(缺省):不存在的写入,本工具上次写入且没被改过的用新内容覆盖,被改过 / 不是本工具写的不动
|
||||
*/
|
||||
export const ScrapeMode = z.enum(["missing", "overwrite", "update"]);
|
||||
export type ScrapeMode = z.infer<typeof ScrapeMode>;
|
||||
|
||||
export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed"]);
|
||||
|
||||
@@ -78,8 +78,11 @@ export const OrganizeSettings = z.object({
|
||||
minSizeBytes: z.number().int().nonnegative(),
|
||||
/** SCR-1:整理入库成功后自动刮削(往媒体库目录写 NFO 与图片);缺省关(同 MP 目录「刮削」缺省) */
|
||||
scrapeAfterImport: z.boolean(),
|
||||
/** SCR-1:目标文件已存在时 missing = 只补缺失(MP「仅缺失」)/ overwrite = 全部覆盖 */
|
||||
scrapeMode: z.enum(["missing", "overwrite"]),
|
||||
/**
|
||||
* 目标文件已存在时:update = 更新(缺省;只覆盖本工具写过且没被改过的文件)/ missing = 只补缺失(MP「仅缺失」)/
|
||||
* overwrite = 全部覆盖
|
||||
*/
|
||||
scrapeMode: z.enum(["missing", "overwrite", "update"]),
|
||||
/** SCR-1:下载海报、背景图、季海报 */
|
||||
scrapeImages: z.boolean(),
|
||||
/** SCR-1:下载每集剧照(`<视频名>-thumb.jpg`) */
|
||||
@@ -642,7 +645,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
|
||||
allowCrossVolumeMove: true,
|
||||
minSizeBytes: 50 * 1024 * 1024,
|
||||
scrapeAfterImport: false,
|
||||
scrapeMode: "missing",
|
||||
scrapeMode: "update",
|
||||
scrapeImages: true,
|
||||
scrapeEpisodeThumbs: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 刮削覆盖策略加「更新」(update):只覆盖本工具上次写入且之后没被改过的文件(按 scrape_record.files 里记的 hash 判断),
|
||||
// 被人改过的跳过(scrape_user_modified)。scrape_record.mode 的 CHECK 增加 update;逐文件 hash 放在 files jsonb 里,不加列。
|
||||
|
||||
const OLD = ["missing", "overwrite"];
|
||||
const NEW = [...OLD, "update"];
|
||||
|
||||
const swap = (values: readonly string[]) => `
|
||||
alter table scrape_record drop constraint if exists scrape_record_mode_check;
|
||||
alter table scrape_record add constraint scrape_record_mode_check ${oneOf("mode", values)};
|
||||
`;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, swap(NEW));
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, `update scrape_record set mode = 'missing' where mode = 'update'; ${swap(OLD)}`);
|
||||
}
|
||||
@@ -233,7 +233,7 @@ export type ScheduledJobRunTrigger = "schedule" | "manual" | "skip";
|
||||
export type ScheduledJobRunStatus = "ok" | "failed" | "skipped" | "running";
|
||||
export type ScrapeRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed";
|
||||
export type ScrapeRecordTrigger = "auto" | "manual";
|
||||
export type ScrapeRecordMode = "missing" | "overwrite";
|
||||
export type ScrapeRecordMode = "missing" | "overwrite" | "update";
|
||||
export type SecretPurpose =
|
||||
| "site_cookie"
|
||||
| "site_passkey"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user