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:
commit
44f7c88be6
41 files changed
+2810
-826
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
@@ -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,前端区分展示) */
|
||||
|
||||
@@ -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: "视频直接放在媒体库目录下,不写剧集和季的文件" },
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -63,7 +63,6 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
|
||||
LibraryRoot: { row: C.LibraryRoot },
|
||||
MediaAsset: { row: C.MediaAsset },
|
||||
MediaEntity: { row: C.MediaEntity },
|
||||
MediaScrape: { row: C.MediaScrape },
|
||||
MediaServerInstance: { row: C.MediaServerInstance },
|
||||
Observation: { row: C.Observation },
|
||||
OrganizationFileResult: { row: C.OrganizationFileResult },
|
||||
@@ -76,6 +75,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
|
||||
PreferenceTag: { row: C.PreferenceTag },
|
||||
ReconcileRun: { row: C.ReconcileRun },
|
||||
Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] },
|
||||
ScrapeRecord: { row: C.ScrapeRecordRow },
|
||||
ServerMapping: { row: C.ServerMapping },
|
||||
Settings: { row: C.SettingEntry },
|
||||
// deleted_at:软删除只在服务端过滤(已删站点不出 API);capabilities 由类型 ∩ 站点定义算出(IA-2)
|
||||
@@ -210,9 +210,6 @@ const ENUMS: Record<string, z.ZodEnum> = {
|
||||
MediaAssetStatus: C.AssetStatus,
|
||||
MediaEntityKind: C.MediaKind,
|
||||
MediaEntityStatus: C.MediaEntityStatus,
|
||||
MediaScrapeMode: C.ScrapeMode,
|
||||
MediaScrapeStatus: C.ScrapeStatus,
|
||||
MediaScrapeTrigger: C.ScrapeTrigger,
|
||||
MediaServerInstanceKind: C.MediaServerKind,
|
||||
MediaServerInstanceHealthStatus: C.InstanceHealth,
|
||||
ObservationStatus: C.ObservationStatus,
|
||||
@@ -246,6 +243,9 @@ const ENUMS: Record<string, z.ZodEnum> = {
|
||||
ScheduledJobLastStatus: C.ScheduleRunStatus,
|
||||
ScheduledJobRunTrigger: C.ScheduleTrigger,
|
||||
ScheduledJobRunStatus: C.ScheduleRunStatus,
|
||||
ScrapeRecordStatus: C.ScrapeStatus,
|
||||
ScrapeRecordTrigger: C.ScrapeTrigger,
|
||||
ScrapeRecordMode: C.ScrapeMode,
|
||||
PiUpstreamAuthKind: C.PiAuthKind,
|
||||
PiUpstreamStatus: C.PiUpstreamStatus,
|
||||
PiUpstreamThinkingLevel: C.PiThinkingLevel,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// SCR-2 刮削照 MP 简化:刮削对象从"作品"改为"目录 / 视频文件"(不要求先登记),每次刮削一条记录:
|
||||
// 目录(登记目录 id + 相对路径)、识别结果(本地作品 / TMDB 条目)、写了哪些文件、跳过 / 失败原因。
|
||||
// 替代 SCR-1 的 media_scrape(一部作品一行);作品详情取该作品最近一条记录。
|
||||
|
||||
const MEDIA_SCRAPE = `create table if not exists media_scrape (
|
||||
media_entity_id uuid primary key references media_entity (id) on delete cascade,
|
||||
status text not null default 'queued' check (status in ('queued', 'running', 'succeeded', 'partial', 'failed')),
|
||||
trigger text not null default 'manual' check (trigger in ('auto', 'manual')),
|
||||
mode text not null default 'missing' check (mode in ('missing', 'overwrite')),
|
||||
record_id uuid references organization_record (id) on delete set null,
|
||||
engine_run_id text,
|
||||
requested_at timestamptz not null default now(),
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
written integer not null default 0,
|
||||
skipped integer not null default 0,
|
||||
failed integer not null default 0,
|
||||
reason text,
|
||||
files jsonb not null default '[]'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
drop trigger if exists media_scrape_touch_updated_at on media_scrape;
|
||||
create trigger media_scrape_touch_updated_at before update on media_scrape
|
||||
for each row execute function umt_touch_updated_at();`;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`create table if not exists scrape_record (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
root_id text not null,
|
||||
root_path text not null,
|
||||
library_root_id uuid references library_root (id) on delete set null,
|
||||
path text not null default '',
|
||||
media_entity_id uuid references media_entity (id) on delete set null,
|
||||
match jsonb,
|
||||
status text not null default 'queued' check (status in ('queued', 'running', 'succeeded', 'partial', 'failed')),
|
||||
trigger text not null default 'manual' check (trigger in ('auto', 'manual')),
|
||||
mode text not null default 'missing' check (mode in ('missing', 'overwrite')),
|
||||
organization_record_id uuid references organization_record (id) on delete set null,
|
||||
engine_run_id text,
|
||||
actor_type text,
|
||||
actor_id text,
|
||||
requested_at timestamptz not null default now(),
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
written integer not null default 0,
|
||||
skipped integer not null default 0,
|
||||
failed integer not null default 0,
|
||||
reason text,
|
||||
files jsonb not null default '[]'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create index if not exists scrape_record_entity_idx on scrape_record (media_entity_id, requested_at desc);
|
||||
create index if not exists scrape_record_path_idx on scrape_record (root_id, path, requested_at desc);
|
||||
drop trigger if exists scrape_record_touch_updated_at on scrape_record;
|
||||
create trigger scrape_record_touch_updated_at before update on scrape_record
|
||||
for each row execute function umt_touch_updated_at();
|
||||
drop table if exists media_scrape;`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, `drop table if exists scrape_record; ${MEDIA_SCRAPE}`);
|
||||
}
|
||||
+34
-26
@@ -159,9 +159,6 @@ export type MediaAssetKind = "video" | "bluray_dir" | "subtitle" | "audio" | "nf
|
||||
export type MediaAssetStatus = "active" | "replaced" | "retired" | "missing";
|
||||
export type MediaEntityKind = "movie" | "series" | "variety" | "collection" | "other";
|
||||
export type MediaEntityStatus = "provisional" | "active" | "merged" | "archived";
|
||||
export type MediaScrapeStatus = "queued" | "running" | "succeeded" | "partial" | "failed";
|
||||
export type MediaScrapeTrigger = "auto" | "manual";
|
||||
export type MediaScrapeMode = "missing" | "overwrite";
|
||||
export type MediaServerInstanceKind = "emby" | "jellyfin";
|
||||
export type MediaServerInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline";
|
||||
export type MetadataEnrichmentStatus = "suggested" | "applied" | "failed";
|
||||
@@ -226,6 +223,9 @@ export type ScheduledJobKind = "task" | "agent_check";
|
||||
export type ScheduledJobLastStatus = "ok" | "failed" | "skipped" | "running";
|
||||
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 SecretPurpose =
|
||||
| "site_cookie"
|
||||
| "site_passkey"
|
||||
@@ -918,25 +918,6 @@ export interface MediaEntityTable {
|
||||
naming_override: Json | null;
|
||||
}
|
||||
|
||||
export interface MediaScrapeTable {
|
||||
media_entity_id: string;
|
||||
status: Generated<MediaScrapeStatus>;
|
||||
trigger: Generated<MediaScrapeTrigger>;
|
||||
mode: Generated<MediaScrapeMode>;
|
||||
record_id: string | null;
|
||||
engine_run_id: string | null;
|
||||
requested_at: GeneratedTimestamptz;
|
||||
started_at: Timestamptz | null;
|
||||
finished_at: Timestamptz | null;
|
||||
written: Generated<number>;
|
||||
skipped: Generated<number>;
|
||||
failed: Generated<number>;
|
||||
reason: string | null;
|
||||
files: Generated<Json>;
|
||||
created_at: GeneratedTimestamptz;
|
||||
updated_at: GeneratedTimestamptz;
|
||||
}
|
||||
|
||||
export interface MediaServerInstanceTable {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
@@ -1345,6 +1326,33 @@ export interface ScheduledJobRunTable {
|
||||
created_at: GeneratedTimestamptz;
|
||||
}
|
||||
|
||||
export interface ScrapeRecordTable {
|
||||
id: Generated<string>;
|
||||
root_id: string;
|
||||
root_path: string;
|
||||
library_root_id: string | null;
|
||||
path: Generated<string>;
|
||||
media_entity_id: string | null;
|
||||
match: Json | null;
|
||||
status: Generated<ScrapeRecordStatus>;
|
||||
trigger: Generated<ScrapeRecordTrigger>;
|
||||
mode: Generated<ScrapeRecordMode>;
|
||||
organization_record_id: string | null;
|
||||
engine_run_id: string | null;
|
||||
actor_type: string | null;
|
||||
actor_id: string | null;
|
||||
requested_at: GeneratedTimestamptz;
|
||||
started_at: Timestamptz | null;
|
||||
finished_at: Timestamptz | null;
|
||||
written: Generated<number>;
|
||||
skipped: Generated<number>;
|
||||
failed: Generated<number>;
|
||||
reason: string | null;
|
||||
files: Generated<Json>;
|
||||
created_at: GeneratedTimestamptz;
|
||||
updated_at: GeneratedTimestamptz;
|
||||
}
|
||||
|
||||
export interface SecretTable {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
@@ -1720,7 +1728,6 @@ export interface DB {
|
||||
mcp_server_config: McpServerConfigTable;
|
||||
media_asset: MediaAssetTable;
|
||||
media_entity: MediaEntityTable;
|
||||
media_scrape: MediaScrapeTable;
|
||||
media_server_instance: MediaServerInstanceTable;
|
||||
metadata_enrichment: MetadataEnrichmentTable;
|
||||
metadata_field: MetadataFieldTable;
|
||||
@@ -1744,6 +1751,7 @@ export interface DB {
|
||||
rule_group: RuleGroupTable;
|
||||
scheduled_job: ScheduledJobTable;
|
||||
scheduled_job_run: ScheduledJobRunTable;
|
||||
scrape_record: ScrapeRecordTable;
|
||||
secret: SecretTable;
|
||||
server_mapping: ServerMappingTable;
|
||||
settings: SettingsTable;
|
||||
@@ -1881,9 +1889,6 @@ export type MediaAssetUpdate = Updateable<MediaAssetTable>;
|
||||
export type MediaEntityRow = Selectable<MediaEntityTable>;
|
||||
export type NewMediaEntity = Insertable<MediaEntityTable>;
|
||||
export type MediaEntityUpdate = Updateable<MediaEntityTable>;
|
||||
export type MediaScrapeRow = Selectable<MediaScrapeTable>;
|
||||
export type NewMediaScrape = Insertable<MediaScrapeTable>;
|
||||
export type MediaScrapeUpdate = Updateable<MediaScrapeTable>;
|
||||
export type MediaServerInstanceRow = Selectable<MediaServerInstanceTable>;
|
||||
export type NewMediaServerInstance = Insertable<MediaServerInstanceTable>;
|
||||
export type MediaServerInstanceUpdate = Updateable<MediaServerInstanceTable>;
|
||||
@@ -1953,6 +1958,9 @@ export type ScheduledJobUpdate = Updateable<ScheduledJobTable>;
|
||||
export type ScheduledJobRunRow = Selectable<ScheduledJobRunTable>;
|
||||
export type NewScheduledJobRun = Insertable<ScheduledJobRunTable>;
|
||||
export type ScheduledJobRunUpdate = Updateable<ScheduledJobRunTable>;
|
||||
export type ScrapeRecordRow = Selectable<ScrapeRecordTable>;
|
||||
export type NewScrapeRecord = Insertable<ScrapeRecordTable>;
|
||||
export type ScrapeRecordUpdate = Updateable<ScrapeRecordTable>;
|
||||
export type SecretRow = Selectable<SecretTable>;
|
||||
export type NewSecret = Insertable<SecretTable>;
|
||||
export type SecretUpdate = Updateable<SecretTable>;
|
||||
|
||||
@@ -26,7 +26,6 @@ const EXPECTED_TABLES = [
|
||||
"path_mapping",
|
||||
// 内容 / 身份
|
||||
"media_entity",
|
||||
"media_scrape",
|
||||
"content_unit",
|
||||
"title_alias",
|
||||
"external_identity",
|
||||
@@ -124,6 +123,8 @@ const EXPECTED_TABLES = [
|
||||
"kv_cache",
|
||||
// 站点请求计数(0046,按小时聚合)
|
||||
"site_request_count",
|
||||
// SCR-2 刮削记录(0058,替代 0057 media_scrape)
|
||||
"scrape_record",
|
||||
].sort();
|
||||
|
||||
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
|
||||
@@ -154,8 +155,6 @@ const NON_ENTITY_TABLES = [
|
||||
"kv_cache",
|
||||
// 站点请求计数:(site_id, hour) 主键
|
||||
"site_request_count",
|
||||
// 作品最近一次刮削:media_entity_id 主键(SCR-1)
|
||||
"media_scrape",
|
||||
];
|
||||
|
||||
describe.skipIf(skip)("schema migrations (real PG)", () => {
|
||||
|
||||
@@ -61,6 +61,11 @@ export interface ScrapeLayoutInput {
|
||||
videos: readonly ScrapeVideo[];
|
||||
images: ScrapeImages;
|
||||
options: { images: boolean; episodeThumbs: boolean };
|
||||
/**
|
||||
* SCR-2 就地刮削的范围(对照 MP scrape_metadata 的 init_folder):work = 作品级(剧集 tvshow.nfo / 海报 / 背景图,
|
||||
* 电影海报 / 背景图);season = 季级(season.nfo / 季海报)。缺省都写。选中单集文件时两者都关,选中季目录时只关 work。
|
||||
*/
|
||||
parts?: { work?: boolean; season?: boolean };
|
||||
}
|
||||
|
||||
export interface ScrapeTarget {
|
||||
@@ -76,7 +81,18 @@ export interface ScrapeTarget {
|
||||
imagePath?: string;
|
||||
}
|
||||
|
||||
const SEASON_DIR_RE = /^(season\s*\d+|s\d{1,3}|specials?|特别篇|第\s*\d+\s*季)$/i;
|
||||
const SEASON_DIR_RE = /^(season\s*\d+|s\d{1,3}|specials?|sps?|特别篇|第\s*\d+\s*季)$/i;
|
||||
|
||||
/** 目录名像季目录(Season 1 / S01 / Specials / 第 1 季);对照 MP season_zero_names 与 MetaInfo(dir).begin_season */
|
||||
export const isSeasonDirName = (name: string): boolean => SEASON_DIR_RE.test(name.trim());
|
||||
|
||||
/** 季目录名 → 季号(Specials / SPs / 特别篇 = 0);认不出为 null */
|
||||
export function seasonOfDirName(name: string): number | null {
|
||||
const n = name.trim();
|
||||
if (/^(specials?|sps?|特别篇)$/i.test(n)) return 0;
|
||||
const m = /^(?:season\s*|s)(\d{1,4})$/i.exec(n) ?? /^第\s*(\d{1,4})\s*季$/.exec(n);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** 没有作品绑定时从视频路径推作品目录:电影取父目录;剧集父目录像季目录时取祖父目录 */
|
||||
export function inferWorkDir(kind: "movie" | "tv", videoPath: string): string | null {
|
||||
@@ -127,7 +143,7 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
|
||||
const dir = normDir(posix.dirname(v.path));
|
||||
add({ path: join(dir, `${stemOf(v.path)}.nfo`), kind: "nfo", role: "movie_nfo", video: v.path });
|
||||
}
|
||||
if (!input.options.images) return out;
|
||||
if (!input.options.images || input.parts?.work === false) return out;
|
||||
const inWork = workDir && videos.some((v) => isUnder(v.path, workDir));
|
||||
const art = (role: "poster" | "fanart", p: string | null | undefined) => {
|
||||
if (!p) return;
|
||||
@@ -150,8 +166,10 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
|
||||
}
|
||||
|
||||
// 剧集
|
||||
const wantWork = input.parts?.work !== false;
|
||||
const wantSeason = input.parts?.season !== false;
|
||||
const underWork = workDir ? videos.filter((v) => isUnder(v.path, workDir)) : [];
|
||||
if (workDir && underWork.length) {
|
||||
if (workDir && underWork.length && wantWork) {
|
||||
add({ path: join(workDir, "tvshow.nfo"), kind: "nfo", role: "tvshow_nfo" });
|
||||
if (input.options.images) {
|
||||
if (img.posterPath)
|
||||
@@ -169,9 +187,11 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
|
||||
imagePath: img.backdropPath,
|
||||
});
|
||||
}
|
||||
// 季:按视频所在目录(不等于作品目录的才算季目录)
|
||||
}
|
||||
// 季:按视频所在目录(不等于作品目录的才算季目录);没有作品目录时只写季目录里的 season.nfo / poster
|
||||
if (wantSeason) {
|
||||
const seasons = new Map<number, Set<string>>();
|
||||
for (const v of underWork) {
|
||||
for (const v of workDir ? underWork : videos) {
|
||||
if (v.season === null || v.season === undefined) continue;
|
||||
const dir = normDir(posix.dirname(v.path));
|
||||
const set = seasons.get(v.season) ?? new Set<string>();
|
||||
@@ -182,13 +202,14 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
|
||||
for (const d of [...dirs].sort()) add({ path: `${d}/season.nfo`, kind: "nfo", role: "season_nfo", season });
|
||||
const sp = img.seasonPosters?.get(season);
|
||||
if (input.options.images && sp) {
|
||||
add({
|
||||
path: join(workDir, seasonPosterName(season, extOf(sp))),
|
||||
kind: "image",
|
||||
role: "season_poster",
|
||||
season,
|
||||
imagePath: sp,
|
||||
});
|
||||
if (workDir)
|
||||
add({
|
||||
path: join(workDir, seasonPosterName(season, extOf(sp))),
|
||||
kind: "image",
|
||||
role: "season_poster",
|
||||
season,
|
||||
imagePath: sp,
|
||||
});
|
||||
for (const d of [...dirs].sort())
|
||||
add({ path: `${d}/poster${extOf(sp)}`, kind: "image", role: "season_poster", season, imagePath: sp });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { episodeNfo, inferWorkDir, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "../src/index.ts";
|
||||
import {
|
||||
episodeNfo,
|
||||
inferWorkDir,
|
||||
isSeasonDirName,
|
||||
planScrapeTargets,
|
||||
seasonNfo,
|
||||
seasonOfDirName,
|
||||
seasonPosterName,
|
||||
workNfo,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("NFO 生成", () => {
|
||||
it("电影:MP 元素顺序,缺值不写,IMDb 为 default", () => {
|
||||
@@ -198,4 +207,43 @@ describe("刮削目标布局", () => {
|
||||
});
|
||||
expect(t.map((x) => x.path)).toEqual(["a.S01E01.nfo", "a.S01E01-thumb.jpg"]);
|
||||
});
|
||||
|
||||
it("SCR-2 季目录名:Season 1 / S01 / Specials / SPs / 第 1 季", () => {
|
||||
for (const n of ["Season 1", "season 12", "S01", "Specials", "SPs", "特别篇", "第 2 季"])
|
||||
expect(isSeasonDirName(n)).toBe(true);
|
||||
for (const n of ["人生切割术 (2022)", "Extras", "Season"]) expect(isSeasonDirName(n)).toBe(false);
|
||||
expect(seasonOfDirName("Season 3")).toBe(3);
|
||||
expect(seasonOfDirName("S01")).toBe(1);
|
||||
expect(seasonOfDirName("Specials")).toBe(0);
|
||||
expect(seasonOfDirName("第 2 季")).toBe(2);
|
||||
expect(seasonOfDirName("Extras")).toBeNull();
|
||||
});
|
||||
|
||||
it("SCR-2 范围:选中季目录只写季级与各集(季海报仍写上级),选中单集只写该集", () => {
|
||||
const videos = [{ path: "人生切割术 (2022)/Season 1/a.S01E01.mkv", season: 1, episode: 1 }];
|
||||
const season = planScrapeTargets({
|
||||
kind: "tv",
|
||||
workDir: "人生切割术 (2022)",
|
||||
videos,
|
||||
images,
|
||||
options: { images: true, episodeThumbs: true },
|
||||
parts: { work: false },
|
||||
});
|
||||
expect(season.map((x) => `${x.role}:${x.path}`)).toEqual([
|
||||
"season_nfo:人生切割术 (2022)/Season 1/season.nfo",
|
||||
"season_poster:人生切割术 (2022)/season01-poster.jpg",
|
||||
"season_poster:人生切割术 (2022)/Season 1/poster.jpg",
|
||||
"episode_nfo:人生切割术 (2022)/Season 1/a.S01E01.nfo",
|
||||
"episode_thumb:人生切割术 (2022)/Season 1/a.S01E01-thumb.jpg",
|
||||
]);
|
||||
const ep = planScrapeTargets({
|
||||
kind: "tv",
|
||||
workDir: null,
|
||||
videos,
|
||||
images,
|
||||
options: { images: true, episodeThumbs: true },
|
||||
parts: { work: false, season: false },
|
||||
});
|
||||
expect(ep.map((x) => x.role)).toEqual(["episode_nfo", "episode_thumb"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user