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(), /** 本工具写入(或核对未变)时文件内容的 sha256;「更新」模式据此判断文件之后有没有被人改过 */ hash: z.string().nullable().optional(), }); export type ScrapeFileResult = z.infer; /** * missing = 只补缺失;overwrite = 全部覆盖; * update = 更新(缺省):不存在的写入,本工具上次写入且没被改过的用新内容覆盖,被改过 / 不是本工具写的不动 */ export const ScrapeMode = z.enum(["missing", "overwrite", "update"]); export type ScrapeMode = z.infer; export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed"]); export type ScrapeStatus = z.infer; export const ScrapeTrigger = z.enum(["auto", "manual"]); export type ScrapeTrigger = z.infer; /** 识别结果:library = 目录里的文件已登记到该作品;auto = 按路径唯一识别;chosen = 用户从候选里选的 */ export const ScrapeMatchSource = z.enum(["library", "auto", "chosen"]); export type ScrapeMatchSource = z.infer; 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; /** 一次刮削(表 scrape_record):目录、识别结果、写了哪些文件、跳过 / 失败原因 */ export const ScrapeRecord = z.object({ id: Id, /** 登记目录 id(`lib:` / `dl::`) */ 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(), /** 只刮这些视频(整理后刮削:作品 / 季目录照常,集只刮本次整理的);null = 目录下全部 */ videos: z.array(z.string()).nullable(), /** 引擎 run id */ taskId: z.string().nullable(), requestedAt: Timestamp, startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), }); export type ScrapeRecord = z.infer; /** 表 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(), videos: z.array(z.string()).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; /** 作品详情的刮削状态 = 该作品最近一次刮削记录 */ export const LibraryScrapeState = ScrapeRecord; export type LibraryScrapeState = ScrapeRecord; export const LibraryScrapeRequest = z.object({ /** 缺省按设置 organize.scrapeMode */ mode: ScrapeMode.optional(), }); export type LibraryScrapeRequest = z.infer; 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;