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:
rootandClaude Opus 5.5 committed 2026-10-01 00:08:17 +08:00
1 parent f60254e265
commit 87bf33b42b
29 files changed
+2272 -653

No files matched your search

+47 -19
View File
@@ -5,6 +5,7 @@ import { DownloadStatus } from "./execution.ts";
import { InstanceHealth, LibraryMediaKind } from "./instances.ts";
import { OrganizationStatus, PublicOrganizationRecord } from "./organization.ts";
import { SlotStatus } from "./policy.ts";
import { ScrapeMode, ScrapeRecord } from "./scrape.ts";
// 文件管理(`/files`,docs/plan/06-file-manager-spec.md)。
// 只能浏览已登记的库根与下载根;路径一律用 {root, path}:root 为 FileRootId,path 为根内相对路径("" = 根)。
@@ -680,30 +681,59 @@ export const IdentifyCompletedPayload = z.object({
});
export type IdentifyCompletedPayload = z.infer<typeof IdentifyCompletedPayload>;
// 刮削(仅已登记作品)
// 刮削(SCR-2,照 MP):任意目录 / 视频文件就地写 NFO 与图片;按路径识别作品(已登记直接用,未登记走 TMDB 匹配),
// 认不准时返回候选(needsChoice),选定后带 mediaEntityId / external 再提交一次。
export const FilesScrapeItem = FileRef.extend({
/** 指定本地作品(选定候选后回传);与 external 二选一 */
mediaEntityId: Id.optional(),
/** 指定 TMDB 条目(候选里只有 TMDB 的);不登记也能刮削 */
external: FilesExternalCandidate.optional(),
});
export type FilesScrapeItem = z.infer<typeof FilesScrapeItem>;
export const FilesScrapeRequest = z.object({
idempotencyKey: IdempotencyKey,
mediaEntityId: Id,
/** SCR-1:true 时另排 scrapeMedia,把 NFO(与 images 时的图片)写进媒体库目录 */
writeNfo: z.boolean().default(false),
images: z.boolean().default(false),
items: z.array(FilesScrapeItem).min(1).max(100),
/** 缺省按设置 organize.scrapeMode */
mode: ScrapeMode.optional(),
/** 指定的 TMDB 条目顺手加入媒体库(建作品);不影响刮削本身 */
addToLibrary: z.boolean().default(false),
});
export type FilesScrapeRequest = z.infer<typeof FilesScrapeRequest>;
export const FilesScrapeNeedsChoice = z.object({
root: FileRootId,
path: z.string(),
/** 从路径解析出的标题 */
title: z.string().nullable(),
/** 前 5 个候选(可能为空:让用户搜索) */
candidates: z.array(FileIdentifyCandidate).max(5),
warnings: z.array(z.string()),
});
export type FilesScrapeNeedsChoice = z.infer<typeof FilesScrapeNeedsChoice>;
export const FilesScrapeResult = z.object({
mediaEntityId: Id,
/** refreshMetadata 引擎 run id(异步;字段级刷新,用户锁优先,不动路径) */
taskId: z.string(),
/** 当前被用户锁定、刷新不会改动的字段 */
lockedFields: z.array(z.string()),
/** NFO/图片投影(SCR-1):queued = 已排 scrapeMedia,结果看作品刮削状态;not_implemented 已不再返回 */
nfo: z.enum(["written", "skipped", "queued", "not_requested", "not_implemented"]),
images: z.enum(["written", "skipped", "queued", "not_requested", "not_implemented"]),
/** scrapeMedia 引擎 run id(writeNfo / images 时) */
scrapeTaskId: z.string().nullable().optional(),
/** 已提交的刮削(每项一条记录);pending=true 时仍有排队 / 进行中的,按 id 查 /files/scrape-records */
records: z.array(ScrapeRecord),
/** 认不准作品的项:选一个候选后带 mediaEntityId / external 重新提交 */
needsChoice: z.array(FilesScrapeNeedsChoice),
/** 没提交的项:scrape_root_read_only(只登记目录)/ scrape_not_video(不是视频或目录) */
skipped: z.array(z.object({ root: FileRootId, path: z.string(), reason: z.string() })),
pending: z.boolean(),
/** addToLibrary 时加入媒体库的作品 */
entities: z.array(z.object({ mediaEntityId: Id, title: z.string(), created: z.boolean() })),
});
export type FilesScrapeResult = z.infer<typeof FilesScrapeResult>;
export const FilesScrapeRecordsQuery = z.object({
/** 逗号分隔的刮削记录 id(≤100) */
ids: z.string().min(1).max(4000),
});
export type FilesScrapeRecordsQuery = z.infer<typeof FilesScrapeRecordsQuery>;
export const FilesScrapeRecordsResult = z.object({ records: z.array(ScrapeRecord) });
export type FilesScrapeRecordsResult = z.infer<typeof FilesScrapeRecordsResult>;
// 探测
export const FilesProbeRequest = z.object({ items: z.array(FileRef).min(1).max(20) });
@@ -843,20 +873,18 @@ export const FilesEntityFromCandidateResult = z.object({
});
export type FilesEntityFromCandidateResult = z.infer<typeof FilesEntityFromCandidateResult>;
/** G5:批量刷新元数据(刮削):按作品去重;items 里的文件/目录归到其已登记资产的作品 */
/** G5:批量刷新元数据:按作品去重;items 里的文件/目录归到其已登记资产的作品(刮削见 /files/scrape) */
export const FilesScrapeBatchRequest = z.object({
idempotencyKey: IdempotencyKey,
items: z.array(FileRef).max(500).optional(),
mediaEntityIds: z.array(Id).max(200).optional(),
/** SCR-1:true = 刮削(排 scrapeMedia 写 NFO 与图片,覆盖方式按设置);false(缺省)= 刷新元数据 */
write: z.boolean().default(false),
});
export type FilesScrapeBatchRequest = z.infer<typeof FilesScrapeBatchRequest>;
export const FilesScrapeBatchResult = z.object({
submitted: z.array(
z.object({ mediaEntityId: Id, title: z.string(), taskId: z.string(), lockedFields: z.array(z.string()) }),
),
/** 未提交:not_registered(作品没有已登记资产)/ not_found / no_entity(文件不属于任何已登记作品) */
/** 未提交:not_found / no_entity(文件不属于任何已登记作品) */
skipped: z.array(z.object({ ref: z.string(), reason: z.string() })),
});
export type FilesScrapeBatchResult = z.infer<typeof FilesScrapeBatchResult>;