feat(scrape): 任意目录就地刮削,按路径识别作品,每次刮削一条记录(SCR-2)
- POST /files/scrape 改为对目录 / 视频文件刮削:已登记用作品,否则按路径识别(本地 + TMDB), 认不准返回候选,选定后再提交;未登记直接用 TMDB 字段,addToLibrary 顺手建作品 - 作品详情重新刮削、入库后自动刮削改为对作品目录刮削,同一实现 - 迁移 0058_scrape_record 替代 media_scrape;GET /files/scrape-records - 与 MP ScrapingChain 的文件布局差分(7 个场景,3 处登记偏离) Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f60254e265
commit
87bf33b42b
29 files changed
+2272
-653
No files matched your search
@@ -0,0 +1,119 @@
|
||||
import { z } from "zod";
|
||||
import { Id, NonNegInt, Timestamp } from "./common.ts";
|
||||
|
||||
// 刮削(SCR-1 / SCR-2):在目录里就地写 NFO 与图片(Emby / Jellyfin / Kodi 读取)。
|
||||
// SCR-2 起照 MP 简化:文件管理里任意目录 / 视频文件都能刮削(按路径识别作品,不要求先登记);
|
||||
// 作品详情「重新刮削」、入库后自动刮削走同一实现(对作品目录刮削)。每次刮削一条记录(表 scrape_record)。
|
||||
|
||||
/** 一个写入目标(NFO / 图片)的结果;path 为所在登记目录(库根 / 下载根)内的相对路径 */
|
||||
export const ScrapeFileResult = z.object({
|
||||
path: z.string(),
|
||||
kind: z.enum(["nfo", "image"]),
|
||||
/** movie_nfo / tvshow_nfo / season_nfo / episode_nfo / poster / fanart / season_poster / episode_thumb */
|
||||
role: z.string(),
|
||||
action: z.enum(["written", "skipped", "failed"]),
|
||||
/** 跳过 / 失败原因码(reasons.ts:scrape_exists / scrape_write_failed / scrape_image_failed …) */
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
export type ScrapeFileResult = z.infer<typeof ScrapeFileResult>;
|
||||
|
||||
/** missing = 只补缺失;overwrite = 全部覆盖 */
|
||||
export const ScrapeMode = z.enum(["missing", "overwrite"]);
|
||||
export type ScrapeMode = z.infer<typeof ScrapeMode>;
|
||||
|
||||
export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed"]);
|
||||
export type ScrapeStatus = z.infer<typeof ScrapeStatus>;
|
||||
export const ScrapeTrigger = z.enum(["auto", "manual"]);
|
||||
export type ScrapeTrigger = z.infer<typeof ScrapeTrigger>;
|
||||
|
||||
/** 识别结果:library = 目录里的文件已登记到该作品;auto = 按路径唯一识别;chosen = 用户从候选里选的 */
|
||||
export const ScrapeMatchSource = z.enum(["library", "auto", "chosen"]);
|
||||
export type ScrapeMatchSource = z.infer<typeof ScrapeMatchSource>;
|
||||
|
||||
export const ScrapeMatch = z.object({
|
||||
/** 本地作品(没有登记时为 null,直接用 TMDB 字段) */
|
||||
mediaEntityId: Id.nullable(),
|
||||
provider: z.literal("tmdb").nullable(),
|
||||
externalId: z.string().nullable(),
|
||||
kind: z.enum(["movie", "tv"]),
|
||||
title: z.string(),
|
||||
year: z.number().int().nullable(),
|
||||
source: ScrapeMatchSource,
|
||||
});
|
||||
export type ScrapeMatch = z.infer<typeof ScrapeMatch>;
|
||||
|
||||
/** 一次刮削(表 scrape_record):目录、识别结果、写了哪些文件、跳过 / 失败原因 */
|
||||
export const ScrapeRecord = z.object({
|
||||
id: Id,
|
||||
/** 登记目录 id(`lib:<id>` / `dl:<id>:<n>`) */
|
||||
root: z.string(),
|
||||
/** 目录内相对路径(刮削的目录或视频文件) */
|
||||
path: z.string(),
|
||||
status: ScrapeStatus,
|
||||
trigger: ScrapeTrigger,
|
||||
mode: ScrapeMode,
|
||||
match: ScrapeMatch.nullable(),
|
||||
written: NonNegInt,
|
||||
skipped: NonNegInt,
|
||||
failed: NonNegInt,
|
||||
/** 整体失败原因码(没有元数据、目录不可访问等) */
|
||||
reason: z.string().nullable(),
|
||||
files: z.array(ScrapeFileResult),
|
||||
/** 触发它的整理记录(入库后自动刮削) */
|
||||
organizationRecordId: Id.nullable(),
|
||||
/** 引擎 run id */
|
||||
taskId: z.string().nullable(),
|
||||
requestedAt: Timestamp,
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type ScrapeRecord = z.infer<typeof ScrapeRecord>;
|
||||
|
||||
/** 表 scrape_record 的行(0058) */
|
||||
export const ScrapeRecordRow = z.object({
|
||||
id: Id,
|
||||
rootId: z.string(),
|
||||
rootPath: z.string(),
|
||||
libraryRootId: Id.nullable(),
|
||||
path: z.string(),
|
||||
mediaEntityId: Id.nullable(),
|
||||
match: ScrapeMatch.nullable(),
|
||||
status: ScrapeStatus,
|
||||
trigger: ScrapeTrigger,
|
||||
mode: ScrapeMode,
|
||||
organizationRecordId: Id.nullable(),
|
||||
engineRunId: z.string().nullable(),
|
||||
actorType: z.string().nullable(),
|
||||
actorId: z.string().nullable(),
|
||||
requestedAt: Timestamp,
|
||||
startedAt: Timestamp.nullable(),
|
||||
finishedAt: Timestamp.nullable(),
|
||||
written: NonNegInt,
|
||||
skipped: NonNegInt,
|
||||
failed: NonNegInt,
|
||||
reason: z.string().nullable(),
|
||||
files: z.array(ScrapeFileResult),
|
||||
createdAt: Timestamp,
|
||||
updatedAt: Timestamp,
|
||||
});
|
||||
export type ScrapeRecordRow = z.infer<typeof ScrapeRecordRow>;
|
||||
|
||||
/** 作品详情的刮削状态 = 该作品最近一次刮削记录 */
|
||||
export const LibraryScrapeState = ScrapeRecord;
|
||||
export type LibraryScrapeState = ScrapeRecord;
|
||||
|
||||
export const LibraryScrapeRequest = z.object({
|
||||
/** 缺省按设置 organize.scrapeMode */
|
||||
mode: ScrapeMode.optional(),
|
||||
});
|
||||
export type LibraryScrapeRequest = z.infer<typeof LibraryScrapeRequest>;
|
||||
export const LibraryScrapeResult = z.object({
|
||||
mediaEntityId: Id,
|
||||
/** 第一条记录的引擎 run id */
|
||||
taskId: z.string().nullable(),
|
||||
/** 同一目录已有排队中的刮削,直接复用 */
|
||||
deduplicated: z.boolean(),
|
||||
/** 每个作品目录一条刮削记录 */
|
||||
recordIds: z.array(Id),
|
||||
});
|
||||
export type LibraryScrapeResult = z.infer<typeof LibraryScrapeResult>;
|
||||
Reference in new issue
Block a user