Merge branch 'scr2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	CHANGELOG.md
#	apps/server/src/modules/file-manager/routes.ts
This commit is contained in:
root committed 2026-10-01 00:09:01 +08:00
commit 44f7c88be6
41 files changed
+2810 -826

No files matched your search

+13 -3
View File
@@ -21,6 +21,7 @@ import * as organization from "./organization.ts";
import * as pi from "./pi.ts";
import * as policy from "./policy.ts";
import * as schedules from "./schedules.ts";
import * as scrape from "./scrape.ts";
import * as search from "./search.ts";
import * as settingsNs from "./settings.ts";
import { GlobalSettings, GlobalSettingsUpdate } from "./settings.ts";
@@ -1567,10 +1568,19 @@ export const Api = {
method: "post",
path: "/files/scrape",
scope: "organize.execute",
summary: "刮削已登记作品(字段级刷新,遵守字段锁;NFO 投影 W5)",
summary:
"刮削:对目录或视频文件就地写 NFO 与图片(按路径识别作品,不要求已登记;认不准时返回候选,选定后再提交)",
body: files.FilesScrapeRequest,
response: files.FilesScrapeResult,
}),
scrapeRecords: ep({
method: "get",
path: "/files/scrape-records",
scope: "organize.read",
summary: "按 id 查刮削记录(目录、识别结果、逐文件结果)",
query: files.FilesScrapeRecordsQuery,
response: files.FilesScrapeRecordsResult,
}),
probe: ep({
method: "post",
path: "/files/probe",
@@ -2260,8 +2270,8 @@ export const Api = {
scope: "organize.execute",
summary: "刮削:把作品的 NFO 与图片写进媒体库目录(后台任务 scrapeMedia;覆盖方式缺省按设置)",
params: IdParam,
body: organization.LibraryScrapeRequest,
response: organization.LibraryScrapeResult,
body: scrape.LibraryScrapeRequest,
response: scrape.LibraryScrapeResult,
}),
reconcile: ep({
method: "post",
+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>;
+1
View File
@@ -21,5 +21,6 @@ export * from "./pi.ts";
export * from "./policy.ts";
export * from "./reasons.ts";
export * from "./redact.ts";
export * from "./scrape.ts";
export * from "./settings.ts";
export * from "./sources.ts";
+1 -75
View File
@@ -6,6 +6,7 @@ import { MediaEntity, MediaKind } from "./content.ts";
import { AcquisitionPlan, DownloadStatus, DownloadTask } from "./execution.ts";
import { ExternalIdentity } from "./identity.ts";
import { AcquisitionSlot, CandidateEvaluation, SlotStatus } from "./policy.ts";
import { LibraryScrapeState } from "./scrape.ts";
// 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4)
// 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。
@@ -456,81 +457,6 @@ export const LibraryDroppedPlanView = z.object({
});
export type LibraryDroppedPlanView = z.infer<typeof LibraryDroppedPlanView>;
/** SCR-1 刮削:一个写入目标(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>;
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>;
/** 表 media_scrape 的行(一部作品一行,最近一次刮削) */
export const MediaScrape = z.object({
mediaEntityId: Id,
status: ScrapeStatus,
trigger: ScrapeTrigger,
mode: ScrapeMode,
recordId: Id.nullable(),
engineRunId: 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 MediaScrape = z.infer<typeof MediaScrape>;
/** SCR-1 作品刮削状态(最近一次;表 media_scrape) */
export const LibraryScrapeState = z.object({
status: ScrapeStatus,
trigger: ScrapeTrigger,
mode: ScrapeMode,
requestedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
written: NonNegInt,
skipped: NonNegInt,
failed: NonNegInt,
/** 整体失败原因码(没有文件、没有元数据等) */
reason: z.string().nullable(),
files: z.array(ScrapeFileResult),
/** 触发它的整理记录(入库后自动刮削) */
recordId: Id.nullable(),
/** 引擎 run id */
taskId: z.string().nullable(),
});
export type LibraryScrapeState = z.infer<typeof LibraryScrapeState>;
export const LibraryScrapeRequest = z.object({
/** 缺省按设置 organize.scrapeMode */
mode: ScrapeMode.optional(),
});
export type LibraryScrapeRequest = z.infer<typeof LibraryScrapeRequest>;
export const LibraryScrapeResult = z.object({
mediaEntityId: Id,
taskId: z.string(),
/** 同一作品已有排队中的刮削,直接复用 */
deduplicated: z.boolean(),
});
export type LibraryScrapeResult = z.infer<typeof LibraryScrapeResult>;
export const LibraryDetail = z.object({
entity: MediaEntity,
/** 身份映射状态(含 proposed/conflicted,前端区分展示) */
+14 -6
View File
@@ -87,13 +87,21 @@ const REASONS: Record<string, ReasonText> = {
read_only_reconcile: { text: "只读对账中,暂不提交下载" },
rejected: { text: "下载器拒绝了这个种子" },
source_failed: { text: "从站点取种子失败", action: "检查站点登录后重新下载" },
// SCR-1 刮削(作品刮削状态、逐文件结果)
scrape_no_files: { text: "作品没有已入库文件", action: "先整理入库" },
scrape_no_metadata: { text: "作品还没有元数据", action: "先刷新元数据" },
scrape_root_read_only: { text: "媒体库目录是只登记模式,不写文件" },
scrape_exists: { text: "文件已存在,按设置只补缺失" },
scrape_write_failed: { text: "写入媒体库目录失败", action: "检查目录权限后重新刮削" },
// SCR-1 / SCR-2 刮削(刮削记录、逐文件结果)
scrape_no_files: { text: "作品没有已入库文件", action: "到文件管理里对目录刮削" },
scrape_no_metadata: { text: "没有可用的元数据", action: "指定 TMDB 条目后重新刮削" },
scrape_root_read_only: { text: "只登记模式的媒体库目录,不写文件" },
scrape_root_offline: { text: "目录不可访问或未挂载" },
scrape_not_video: { text: "不是视频文件或目录" },
scrape_path_missing: { text: "目录或文件已不存在" },
path_escape: { text: "路径指向登记目录之外" },
task_enqueue_failed: { text: "后台任务没排上", action: "稍后重试" },
scrape_no_videos: { text: "目录里没有视频", action: "对作品或季目录刮削" },
scrape_tmdb_unavailable: { text: "TMDB 不可用", action: "检查 TMDB 设置后重新刮削" },
scrape_exists: { text: "文件已存在,只补缺失时不改" },
scrape_write_failed: { text: "写入目录失败", action: "检查目录权限后重新刮削" },
scrape_image_failed: { text: "图片没下载成功", action: "稍后重新刮削" },
scrape_no_episode: { text: "文件名里认不出集号" },
scrape_no_work_dir: { text: "视频直接放在媒体库目录下,不写剧集和季的文件" },
};
+119
View File
@@ -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>;