Merge branch 'scr1'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # CHANGELOG.md # docs/plan/00-first-version-plan.md
This commit is contained in:
commit
2e83107f68
45 files changed
+2867
-41
No files matched your search
@@ -2238,6 +2238,15 @@ export const Api = {
|
||||
query: libraryVersions.LibraryVersionsQuery,
|
||||
response: libraryVersions.LibraryVersions,
|
||||
}),
|
||||
scrape: ep({
|
||||
method: "post",
|
||||
path: "/library/{id}/scrape",
|
||||
scope: "organize.execute",
|
||||
summary: "刮削:把作品的 NFO 与图片写进媒体库目录(后台任务 scrapeMedia;覆盖方式缺省按设置)",
|
||||
params: IdParam,
|
||||
body: organization.LibraryScrapeRequest,
|
||||
response: organization.LibraryScrapeResult,
|
||||
}),
|
||||
reconcile: ep({
|
||||
method: "post",
|
||||
path: "/library/reconcile",
|
||||
|
||||
@@ -685,6 +685,7 @@ export type IdentifyCompletedPayload = z.infer<typeof IdentifyCompletedPayload>;
|
||||
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),
|
||||
});
|
||||
@@ -695,9 +696,11 @@ export const FilesScrapeResult = z.object({
|
||||
taskId: z.string(),
|
||||
/** 当前被用户锁定、刷新不会改动的字段 */
|
||||
lockedFields: z.array(z.string()),
|
||||
/** NFO/图片投影:W5 实现前请求时为 not_implemented */
|
||||
nfo: z.enum(["written", "skipped", "not_requested", "not_implemented"]),
|
||||
images: z.enum(["written", "skipped", "not_requested", "not_implemented"]),
|
||||
/** 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(),
|
||||
});
|
||||
export type FilesScrapeResult = z.infer<typeof FilesScrapeResult>;
|
||||
|
||||
@@ -845,6 +848,8 @@ 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({
|
||||
|
||||
@@ -370,6 +370,81 @@ 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,前端区分展示) */
|
||||
@@ -403,6 +478,8 @@ export const LibraryDetail = z.object({
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
/** SCR-1:最近一次刮削(写 NFO 与图片);没刮削过为 null */
|
||||
scrape: LibraryScrapeState.nullable().optional(),
|
||||
});
|
||||
export type LibraryDetail = z.infer<typeof LibraryDetail>;
|
||||
|
||||
|
||||
@@ -87,6 +87,14 @@ 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: "检查目录权限后重新刮削" },
|
||||
scrape_image_failed: { text: "图片没下载成功", action: "稍后重新刮削" },
|
||||
scrape_no_work_dir: { text: "视频直接放在媒体库目录下,不写剧集和季的文件" },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,6 +72,14 @@ export const OrganizeSettings = z.object({
|
||||
allowCrossVolumeMove: z.boolean(),
|
||||
/** 整理 / 文件管理识别时视频文件的最小体积(字节),低于它的视为样片跳过;请求(向导)里给的值优先 */
|
||||
minSizeBytes: z.number().int().nonnegative(),
|
||||
/** SCR-1:整理入库成功后自动刮削(往媒体库目录写 NFO 与图片);缺省关(同 MP 目录「刮削」缺省) */
|
||||
scrapeAfterImport: z.boolean(),
|
||||
/** SCR-1:目标文件已存在时 missing = 只补缺失(MP「仅缺失」)/ overwrite = 全部覆盖 */
|
||||
scrapeMode: z.enum(["missing", "overwrite"]),
|
||||
/** SCR-1:下载海报、背景图、季海报 */
|
||||
scrapeImages: z.boolean(),
|
||||
/** SCR-1:下载每集剧照(`<视频名>-thumb.jpg`) */
|
||||
scrapeEpisodeThumbs: z.boolean(),
|
||||
});
|
||||
export type OrganizeSettings = z.infer<typeof OrganizeSettings>;
|
||||
|
||||
@@ -618,7 +626,15 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
|
||||
globalImageCache: false,
|
||||
globalImageCacheDays: 7,
|
||||
},
|
||||
organize: { defaultMode: "copy", allowCrossVolumeMove: true, minSizeBytes: 50 * 1024 * 1024 },
|
||||
organize: {
|
||||
defaultMode: "copy",
|
||||
allowCrossVolumeMove: true,
|
||||
minSizeBytes: 50 * 1024 * 1024,
|
||||
scrapeAfterImport: false,
|
||||
scrapeMode: "missing",
|
||||
scrapeImages: true,
|
||||
scrapeEpisodeThumbs: true,
|
||||
},
|
||||
retention: { seedRetentionDays: 7, logRetentionDays: 180 },
|
||||
pi: {
|
||||
defaultUpstreamId: null,
|
||||
@@ -927,17 +943,22 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
||||
{
|
||||
id: "library",
|
||||
label: "媒体库与目录",
|
||||
description: "媒体库目录、默认整理方式、命名模板",
|
||||
description: "媒体库目录、默认整理方式、刮削、命名模板",
|
||||
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
|
||||
anchors: [
|
||||
{ id: "roots", label: "媒体库目录" },
|
||||
{ id: "organize", label: "整理方式" },
|
||||
{ id: "scrape", label: "刮削" },
|
||||
{ id: "naming", label: "命名模板" },
|
||||
],
|
||||
fields: [
|
||||
"organize.defaultMode",
|
||||
"organize.allowCrossVolumeMove",
|
||||
"organize.minSizeBytes",
|
||||
"organize.scrapeAfterImport",
|
||||
"organize.scrapeMode",
|
||||
"organize.scrapeImages",
|
||||
"organize.scrapeEpisodeThumbs",
|
||||
"naming.movie",
|
||||
"naming.tv",
|
||||
"naming.variety",
|
||||
|
||||
@@ -63,6 +63,7 @@ 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 },
|
||||
@@ -209,6 +210,9 @@ 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,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// SCR-1 刮削:每部作品最近一次刮削(往媒体库目录写 NFO 与图片)的状态与逐文件结果。
|
||||
// 一部作品一行,重新刮削覆盖;历史看审计(organize.scrape)与引擎运行记录。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`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 down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, "drop table if exists media_scrape;");
|
||||
}
|
||||
@@ -159,6 +159,9 @@ 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";
|
||||
@@ -915,6 +918,25 @@ 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;
|
||||
@@ -1698,6 +1720,7 @@ 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;
|
||||
@@ -1858,6 +1881,9 @@ 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>;
|
||||
|
||||
@@ -26,6 +26,7 @@ const EXPECTED_TABLES = [
|
||||
"path_mapping",
|
||||
// 内容 / 身份
|
||||
"media_entity",
|
||||
"media_scrape",
|
||||
"content_unit",
|
||||
"title_alias",
|
||||
"external_identity",
|
||||
@@ -153,6 +154,8 @@ 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)", () => {
|
||||
|
||||
@@ -11,4 +11,5 @@ export * from "./path/index.ts";
|
||||
export * from "./release-facets/index.ts";
|
||||
export * from "./release-parser/index.ts";
|
||||
export * from "./rules/index.ts";
|
||||
export * from "./scrape/index.ts";
|
||||
export * from "./slot/index.ts";
|
||||
@@ -0,0 +1,3 @@
|
||||
// SCR-1 刮削:NFO 生成与目标文件布局(纯函数)
|
||||
export * from "./layout.ts";
|
||||
export * from "./nfo.ts";
|
||||
@@ -0,0 +1,222 @@
|
||||
// SCR-1 刮削:算出一部作品要写哪些 NFO / 图片、写到哪里。纯函数,路径都是库根内相对路径(POSIX)。
|
||||
// 文件名跟随已入库视频(视频名本身按命名模板生成),图片名按 Emby / Jellyfin / Kodi 通用约定。
|
||||
// 对照 MP app/chain/scraping.py `_get_target_fileitem_and_path` / `_get_target_fileitems_and_paths` /
|
||||
// `_expand_with_aliases` 与 themoviedb/scraper.py `get_metadata_img` / `get_season_poster`:
|
||||
// - 电影:`<视频名>.nfo` 与视频同目录;海报 / 背景图放作品目录:poster.jpg、fanart.jpg
|
||||
// (MP 另写 backdrop.jpg 副本,三家都认 fanart,我们不写副本);
|
||||
// - 剧集:作品目录 tvshow.nfo、poster.jpg、fanart.jpg;季目录 season.nfo;季海报同时写作品目录
|
||||
// `season01-poster.jpg`(特别篇 `season-specials-poster.jpg`)与季目录 `poster.jpg`(MP 同);
|
||||
// - 每集:`<视频名>.nfo`;剧照 `<视频名>-thumb.jpg`(Kodi / Jellyfin / Emby 都认;MP 写 `<视频名>.jpg`)。
|
||||
// 视频直接在库根(没有作品目录)时,电影图片改用 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`,
|
||||
// 剧集不写 tvshow / 季文件(不能往库根里放 tvshow.nfo)。
|
||||
|
||||
/** 纯字符串的 POSIX dirname / basename / extname(domain 不依赖 node:path,前端也能打包) */
|
||||
const posix = {
|
||||
dirname(p: string): string {
|
||||
const i = p.replace(/\/+$/, "").lastIndexOf("/");
|
||||
return i < 0 ? "." : i === 0 ? "/" : p.slice(0, i);
|
||||
},
|
||||
basename(p: string): string {
|
||||
const t = p.replace(/\/+$/, "");
|
||||
return t.slice(t.lastIndexOf("/") + 1);
|
||||
},
|
||||
extname(p: string): string {
|
||||
const b = posix.basename(p);
|
||||
const i = b.lastIndexOf(".");
|
||||
return i > 0 ? b.slice(i) : "";
|
||||
},
|
||||
};
|
||||
|
||||
export type ScrapeTargetRole =
|
||||
| "movie_nfo"
|
||||
| "tvshow_nfo"
|
||||
| "season_nfo"
|
||||
| "episode_nfo"
|
||||
| "poster"
|
||||
| "fanart"
|
||||
| "season_poster"
|
||||
| "episode_thumb";
|
||||
|
||||
export interface ScrapeVideo {
|
||||
/** 库根内相对路径 */
|
||||
path: string;
|
||||
season?: number | null;
|
||||
/** 首集集号(多集文件按首集,同 MP begin_episode) */
|
||||
episode?: number | null;
|
||||
}
|
||||
|
||||
export interface ScrapeImages {
|
||||
posterPath?: string | null;
|
||||
backdropPath?: string | null;
|
||||
/** 季号 → 季海报路径 */
|
||||
seasonPosters?: ReadonlyMap<number, string | null>;
|
||||
/** "季:集" → 剧照路径 */
|
||||
stills?: ReadonlyMap<string, string | null>;
|
||||
}
|
||||
|
||||
export interface ScrapeLayoutInput {
|
||||
kind: "movie" | "tv";
|
||||
/** 作品目录(库根内相对路径);null / "" = 视频直接放在库根 */
|
||||
workDir: string | null;
|
||||
videos: readonly ScrapeVideo[];
|
||||
images: ScrapeImages;
|
||||
options: { images: boolean; episodeThumbs: boolean };
|
||||
}
|
||||
|
||||
export interface ScrapeTarget {
|
||||
/** 库根内相对路径 */
|
||||
path: string;
|
||||
kind: "nfo" | "image";
|
||||
role: ScrapeTargetRole;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
/** 这个文件对应的视频(电影 / 集 NFO、剧照) */
|
||||
video?: string;
|
||||
/** 图片:TMDB 图片路径 */
|
||||
imagePath?: string;
|
||||
}
|
||||
|
||||
const SEASON_DIR_RE = /^(season\s*\d+|s\d{1,3}|specials?|特别篇|第\s*\d+\s*季)$/i;
|
||||
|
||||
/** 没有作品绑定时从视频路径推作品目录:电影取父目录;剧集父目录像季目录时取祖父目录 */
|
||||
export function inferWorkDir(kind: "movie" | "tv", videoPath: string): string | null {
|
||||
const parent = posix.dirname(videoPath);
|
||||
if (parent === "." || parent === "") return null;
|
||||
if (kind === "tv" && SEASON_DIR_RE.test(posix.basename(parent))) {
|
||||
const gp = posix.dirname(parent);
|
||||
return gp === "." || gp === "" ? null : gp;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
const stemOf = (p: string) => {
|
||||
const base = posix.basename(p);
|
||||
const i = base.lastIndexOf(".");
|
||||
return i > 0 ? base.slice(0, i) : base;
|
||||
};
|
||||
const extOf = (imagePath: string) => {
|
||||
const e = posix.extname(imagePath.split("?")[0] ?? "").toLowerCase();
|
||||
return /^\.(jpe?g|png|webp)$/.test(e) ? e : ".jpg";
|
||||
};
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** 季海报在作品目录下的文件名(MP get_season_poster) */
|
||||
export function seasonPosterName(season: number, ext = ".jpg"): string {
|
||||
return season === 0 ? `season-specials-poster${ext}` : `season${pad2(season)}-poster${ext}`;
|
||||
}
|
||||
|
||||
const normDir = (d: string | null) => (d && d !== "." ? d.replace(/\/+$/, "") : null);
|
||||
const isUnder = (p: string, dir: string) => p === dir || p.startsWith(`${dir}/`);
|
||||
|
||||
/** 一部作品的刮削目标清单(去重,顺序:作品 → 季 → 集) */
|
||||
export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
|
||||
const out: ScrapeTarget[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (t: ScrapeTarget) => {
|
||||
if (seen.has(t.path)) return;
|
||||
seen.add(t.path);
|
||||
out.push(t);
|
||||
};
|
||||
const workDir = normDir(input.workDir);
|
||||
const videos = [...input.videos].sort((a, b) => a.path.localeCompare(b.path));
|
||||
const img = input.images;
|
||||
const join = (dir: string | null, name: string) => (dir ? `${dir}/${name}` : name);
|
||||
|
||||
if (input.kind === "movie") {
|
||||
for (const v of videos) {
|
||||
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;
|
||||
const inWork = workDir && videos.some((v) => isUnder(v.path, workDir));
|
||||
const art = (role: "poster" | "fanart", p: string | null | undefined) => {
|
||||
if (!p) return;
|
||||
if (inWork) add({ path: join(workDir, `${role}${extOf(p)}`), kind: "image", role, imagePath: p });
|
||||
else
|
||||
for (const v of videos) {
|
||||
const dir = normDir(posix.dirname(v.path));
|
||||
add({
|
||||
path: join(dir, `${stemOf(v.path)}-${role}${extOf(p)}`),
|
||||
kind: "image",
|
||||
role,
|
||||
imagePath: p,
|
||||
video: v.path,
|
||||
});
|
||||
}
|
||||
};
|
||||
art("poster", img.posterPath);
|
||||
art("fanart", img.backdropPath);
|
||||
return out;
|
||||
}
|
||||
|
||||
// 剧集
|
||||
const underWork = workDir ? videos.filter((v) => isUnder(v.path, workDir)) : [];
|
||||
if (workDir && underWork.length) {
|
||||
add({ path: join(workDir, "tvshow.nfo"), kind: "nfo", role: "tvshow_nfo" });
|
||||
if (input.options.images) {
|
||||
if (img.posterPath)
|
||||
add({
|
||||
path: join(workDir, `poster${extOf(img.posterPath)}`),
|
||||
kind: "image",
|
||||
role: "poster",
|
||||
imagePath: img.posterPath,
|
||||
});
|
||||
if (img.backdropPath)
|
||||
add({
|
||||
path: join(workDir, `fanart${extOf(img.backdropPath)}`),
|
||||
kind: "image",
|
||||
role: "fanart",
|
||||
imagePath: img.backdropPath,
|
||||
});
|
||||
}
|
||||
// 季:按视频所在目录(不等于作品目录的才算季目录)
|
||||
const seasons = new Map<number, Set<string>>();
|
||||
for (const v of underWork) {
|
||||
if (v.season === null || v.season === undefined) continue;
|
||||
const dir = normDir(posix.dirname(v.path));
|
||||
const set = seasons.get(v.season) ?? new Set<string>();
|
||||
if (dir && dir !== workDir) set.add(dir);
|
||||
seasons.set(v.season, set);
|
||||
}
|
||||
for (const [season, dirs] of [...seasons].sort((a, b) => a[0] - b[0])) {
|
||||
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,
|
||||
});
|
||||
for (const d of [...dirs].sort())
|
||||
add({ path: `${d}/poster${extOf(sp)}`, kind: "image", role: "season_poster", season, imagePath: sp });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const v of videos) {
|
||||
if (v.season === null || v.season === undefined || v.episode === null || v.episode === undefined) continue;
|
||||
const dir = normDir(posix.dirname(v.path));
|
||||
const stem = stemOf(v.path);
|
||||
add({
|
||||
path: join(dir, `${stem}.nfo`),
|
||||
kind: "nfo",
|
||||
role: "episode_nfo",
|
||||
season: v.season,
|
||||
episode: v.episode,
|
||||
video: v.path,
|
||||
});
|
||||
const still = img.stills?.get(`${v.season}:${v.episode}`);
|
||||
if (input.options.images && input.options.episodeThumbs && still)
|
||||
add({
|
||||
path: join(dir, `${stem}-thumb${extOf(still)}`),
|
||||
kind: "image",
|
||||
role: "episode_thumb",
|
||||
season: v.season,
|
||||
episode: v.episode,
|
||||
video: v.path,
|
||||
imagePath: still,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// SCR-1 刮削:作品 / 季 / 集元数据 → NFO XML(Emby / Jellyfin / Kodi 通用)。纯函数,无 IO。
|
||||
// 对照 MP app/modules/themoviedb/scraper.py(__gen_common_nfo / __gen_movie_nfo_file / __gen_tv_nfo_file /
|
||||
// __gen_tv_season_nfo_file / __gen_tv_episode_nfo_file)的元素名、顺序与属性;输出格式对照 minidom
|
||||
// toprettyxml(indent=" ", encoding="utf-8"):UTF-8 声明、两空格缩进、只有文本的元素写在一行、简介用 CDATA。
|
||||
// 与 MP 的差异(差分登记见 apps/server/test/scrape/nfo-deviations.json):
|
||||
// - 缺值不写元素(MP 写空元素 `<premiered/>`、评分缺省写 0、空 CDATA);
|
||||
// - 简介里的 `]]>` 拆成两段 CDATA(MP 的 minidom 直接抛错);
|
||||
// - 额外写 豆瓣 id(doubanid + uniqueid type=douban)、标语 tagline、集时长 runtime;
|
||||
// - 集 NFO 没有 TMDB 集 id(本地不存),不写 uniqueid / tmdbid,媒体服务器按季号集号匹配。
|
||||
|
||||
export const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original";
|
||||
|
||||
/** 人物:演员(role = 角色名)或导演 */
|
||||
export interface NfoPerson {
|
||||
tmdbId?: number | null;
|
||||
name: string;
|
||||
role?: string | null;
|
||||
/** TMDB profile_path(`/xx.jpg`)或完整 URL */
|
||||
profilePath?: string | null;
|
||||
}
|
||||
|
||||
export interface NfoIds {
|
||||
tmdb?: string | null;
|
||||
imdb?: string | null;
|
||||
tvdb?: string | null;
|
||||
douban?: string | null;
|
||||
}
|
||||
|
||||
/** 电影 / 剧集(tvshow.nfo) */
|
||||
export interface NfoWork {
|
||||
kind: "movie" | "tvshow";
|
||||
title: string;
|
||||
originalTitle?: string | null;
|
||||
year?: number | null;
|
||||
/** 首映 / 首播日期 YYYY-MM-DD */
|
||||
premiered?: string | null;
|
||||
overview?: string | null;
|
||||
tagline?: string | null;
|
||||
/** 0–10 */
|
||||
rating?: number | null;
|
||||
genres?: readonly string[];
|
||||
/** 制片国(显示名) */
|
||||
countries?: readonly string[];
|
||||
/** 出品 / 播出平台 */
|
||||
studios?: readonly string[];
|
||||
/** 分钟 */
|
||||
runtime?: number | null;
|
||||
directors?: readonly NfoPerson[];
|
||||
actors?: readonly NfoPerson[];
|
||||
ids: NfoIds;
|
||||
}
|
||||
|
||||
export interface NfoSeason {
|
||||
season: number;
|
||||
title?: string | null;
|
||||
overview?: string | null;
|
||||
airDate?: string | null;
|
||||
}
|
||||
|
||||
export interface NfoEpisode {
|
||||
season: number;
|
||||
episode: number;
|
||||
title?: string | null;
|
||||
overview?: string | null;
|
||||
airDate?: string | null;
|
||||
rating?: number | null;
|
||||
runtime?: number | null;
|
||||
directors?: readonly NfoPerson[];
|
||||
actors?: readonly NfoPerson[];
|
||||
}
|
||||
|
||||
// ── 极简 XML 树(只覆盖 NFO 用到的形态) ──
|
||||
|
||||
type Node = { tag: string; attrs?: [string, string][]; text?: string; cdata?: string; children?: Node[] };
|
||||
|
||||
const escText = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const escAttr = (s: string) =>
|
||||
escText(s).replace(/"/g, """).replace(/\r/g, " ").replace(/\n/g, " ").replace(/\t/g, "	");
|
||||
/** XML 1.0 不允许的控制字符去掉(TMDB 简介偶见) */
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: 就是要删控制字符
|
||||
const clean = (s: string) => s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
|
||||
const cdata = (s: string) => `<![CDATA[${s.replace(/]]>/g, "]]]]><![CDATA[>")}]]>`;
|
||||
|
||||
function render(n: Node, indent: string, out: string[]) {
|
||||
const attrs = (n.attrs ?? []).map(([k, v]) => ` ${k}="${escAttr(clean(v))}"`).join("");
|
||||
if (n.children?.length) {
|
||||
out.push(`${indent}<${n.tag}${attrs}>`);
|
||||
for (const c of n.children) render(c, `${indent} `, out);
|
||||
out.push(`${indent}</${n.tag}>`);
|
||||
} else if (n.cdata !== undefined) out.push(`${indent}<${n.tag}${attrs}>${cdata(clean(n.cdata))}</${n.tag}>`);
|
||||
else if (n.text !== undefined) out.push(`${indent}<${n.tag}${attrs}>${escText(clean(n.text))}</${n.tag}>`);
|
||||
else out.push(`${indent}<${n.tag}${attrs}/>`);
|
||||
}
|
||||
|
||||
function toXml(root: Node): string {
|
||||
const out = ['<?xml version="1.0" encoding="utf-8"?>'];
|
||||
render(root, "", out);
|
||||
return `${out.join("\n")}\n`;
|
||||
}
|
||||
|
||||
const has = (v: string | null | undefined): v is string => typeof v === "string" && v.trim().length > 0;
|
||||
const num = (v: number | null | undefined): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
/** TMDB 图片路径 → 完整 URL(MP settings.TMDB_IMAGE_URL,原图) */
|
||||
export function tmdbImageFullUrl(path: string): string {
|
||||
return /^https?:\/\//.test(path) ? path : `${TMDB_IMAGE_BASE}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
/** 评分:MP 写 round(x, 1) 的 Python 字符串形式(8.0 → "8.0") */
|
||||
const ratingText = (v: number) => {
|
||||
const r = Math.round(v * 10) / 10;
|
||||
return Number.isInteger(r) ? r.toFixed(1) : String(r);
|
||||
};
|
||||
|
||||
class Builder {
|
||||
readonly nodes: Node[] = [];
|
||||
text(tag: string, v: string | number | null | undefined, attrs?: [string, string][]) {
|
||||
if (typeof v === "number") {
|
||||
if (num(v)) this.nodes.push({ tag, text: String(v), ...(attrs ? { attrs } : {}) });
|
||||
} else if (has(v)) this.nodes.push({ tag, text: v.trim(), ...(attrs ? { attrs } : {}) });
|
||||
return this;
|
||||
}
|
||||
plot(v: string | null | undefined) {
|
||||
if (has(v)) {
|
||||
this.nodes.push({ tag: "plot", cdata: v.trim() });
|
||||
this.nodes.push({ tag: "outline", cdata: v.trim() });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
directors(list: readonly NfoPerson[] | undefined) {
|
||||
for (const d of list ?? []) {
|
||||
if (!has(d.name)) continue;
|
||||
this.nodes.push({ tag: "director", text: d.name, attrs: [["tmdbid", num(d.tmdbId) ? String(d.tmdbId) : ""]] });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
actors(list: readonly NfoPerson[] | undefined, withRole: boolean) {
|
||||
for (const a of list ?? []) {
|
||||
if (!has(a.name)) continue;
|
||||
const children: Node[] = [
|
||||
{ tag: "name", text: a.name },
|
||||
{ tag: "type", text: "Actor" },
|
||||
];
|
||||
if (withRole && has(a.role)) children.push({ tag: "role", text: a.role });
|
||||
if (num(a.tmdbId)) children.push({ tag: "tmdbid", text: String(a.tmdbId) });
|
||||
if (has(a.profilePath)) children.push({ tag: "thumb", text: tmdbImageFullUrl(a.profilePath) });
|
||||
if (num(a.tmdbId)) children.push({ tag: "profile", text: `https://www.themoviedb.org/person/${a.tmdbId}` });
|
||||
this.nodes.push({ tag: "actor", children });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
list(tag: string, values: readonly string[] | undefined) {
|
||||
const seen = new Set<string>();
|
||||
for (const v of values ?? []) {
|
||||
if (!has(v) || seen.has(v.trim())) continue;
|
||||
seen.add(v.trim());
|
||||
this.nodes.push({ tag, text: v.trim() });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部 id 块(MP __gen_common_nfo):tmdbid + uniqueid(tmdb);有 TVDB / IMDb 时追加,IMDb 存在时 IMDb 为 default。
|
||||
* 豆瓣是我们加的(uniqueid type=douban,不做 default)。
|
||||
*/
|
||||
function idBlock(b: Builder, ids: NfoIds) {
|
||||
const tmdb: Node | null = has(ids.tmdb)
|
||||
? {
|
||||
tag: "uniqueid",
|
||||
text: ids.tmdb,
|
||||
attrs: [
|
||||
["type", "tmdb"],
|
||||
["default", "true"],
|
||||
],
|
||||
}
|
||||
: null;
|
||||
if (tmdb) {
|
||||
b.text("tmdbid", ids.tmdb);
|
||||
b.nodes.push(tmdb);
|
||||
}
|
||||
if (has(ids.tvdb)) {
|
||||
b.text("tvdbid", ids.tvdb);
|
||||
b.nodes.push({ tag: "uniqueid", text: ids.tvdb, attrs: [["type", "tvdb"]] });
|
||||
}
|
||||
if (has(ids.imdb)) {
|
||||
b.text("imdbid", ids.imdb);
|
||||
b.nodes.push({
|
||||
tag: "uniqueid",
|
||||
text: ids.imdb,
|
||||
attrs: [
|
||||
["type", "imdb"],
|
||||
["default", "true"],
|
||||
],
|
||||
});
|
||||
if (tmdb)
|
||||
tmdb.attrs = [
|
||||
["type", "tmdb"],
|
||||
["default", "false"],
|
||||
];
|
||||
}
|
||||
if (has(ids.douban)) {
|
||||
b.text("doubanid", ids.douban);
|
||||
b.nodes.push({ tag: "uniqueid", text: ids.douban, attrs: [["type", "douban"]] });
|
||||
}
|
||||
}
|
||||
|
||||
/** movie.nfo(`<视频名>.nfo`)/ tvshow.nfo */
|
||||
export function workNfo(w: NfoWork): string {
|
||||
const b = new Builder();
|
||||
idBlock(b, w.ids);
|
||||
b.plot(w.overview);
|
||||
b.directors(w.directors);
|
||||
b.actors(w.actors, true);
|
||||
b.list("genre", w.genres);
|
||||
if (num(w.rating) && w.rating > 0) b.text("rating", ratingText(w.rating));
|
||||
b.list("studio", w.studios);
|
||||
b.list("country", w.countries);
|
||||
if (num(w.runtime) && w.runtime > 0) b.text("runtime", w.runtime);
|
||||
b.text("title", w.title);
|
||||
b.text("originaltitle", w.originalTitle);
|
||||
b.text("premiered", w.premiered);
|
||||
const year = num(w.year)
|
||||
? w.year
|
||||
: has(w.premiered) && /^\d{4}/.test(w.premiered)
|
||||
? Number(w.premiered.slice(0, 4))
|
||||
: null;
|
||||
b.text("year", year);
|
||||
if (w.kind === "tvshow") {
|
||||
b.text("season", "-1");
|
||||
b.text("episode", "-1");
|
||||
}
|
||||
b.text("tagline", w.tagline);
|
||||
return toXml({ tag: w.kind === "movie" ? "movie" : "tvshow", children: b.nodes });
|
||||
}
|
||||
|
||||
/** season.nfo(季目录内) */
|
||||
export function seasonNfo(s: NfoSeason): string {
|
||||
const b = new Builder();
|
||||
b.plot(s.overview);
|
||||
b.text("title", has(s.title) ? s.title : `季 ${s.season}`);
|
||||
b.text("premiered", s.airDate);
|
||||
b.text("releasedate", s.airDate);
|
||||
b.text("year", has(s.airDate) && /^\d{4}/.test(s.airDate) ? s.airDate.slice(0, 4) : null);
|
||||
b.text("seasonnumber", String(s.season));
|
||||
return toXml({ tag: "season", children: b.nodes });
|
||||
}
|
||||
|
||||
/** 每集 `<视频名>.nfo` */
|
||||
export function episodeNfo(e: NfoEpisode): string {
|
||||
const b = new Builder();
|
||||
b.text("title", has(e.title) ? e.title : `第 ${e.episode} 集`);
|
||||
b.plot(e.overview);
|
||||
b.text("aired", e.airDate);
|
||||
b.text("year", has(e.airDate) && /^\d{4}/.test(e.airDate) ? e.airDate.slice(0, 4) : null);
|
||||
b.text("season", String(e.season));
|
||||
b.text("episode", String(e.episode));
|
||||
if (num(e.rating) && e.rating > 0) b.text("rating", ratingText(e.rating));
|
||||
b.directors(e.directors);
|
||||
b.actors(e.actors, false);
|
||||
if (num(e.runtime) && e.runtime > 0) b.text("runtime", e.runtime);
|
||||
return toXml({ tag: "episodedetails", children: b.nodes });
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { episodeNfo, inferWorkDir, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "../src/index.ts";
|
||||
|
||||
describe("NFO 生成", () => {
|
||||
it("电影:MP 元素顺序,缺值不写,IMDb 为 default", () => {
|
||||
const xml = workNfo({
|
||||
kind: "movie",
|
||||
title: "星际穿越",
|
||||
originalTitle: "Interstellar",
|
||||
year: 2014,
|
||||
premiered: "2014-11-05",
|
||||
overview: "穿越虫洞。",
|
||||
rating: 8.46,
|
||||
genres: ["科幻", "剧情", "科幻"],
|
||||
countries: ["美国"],
|
||||
runtime: 169,
|
||||
directors: [{ tmdbId: 525, name: "克里斯托弗·诺兰" }],
|
||||
actors: [{ tmdbId: 10297, name: "马修·麦康纳", role: "Cooper", profilePath: "/mm.jpg" }],
|
||||
ids: { tmdb: "157336", imdb: "tt0816692" },
|
||||
});
|
||||
expect(xml).toBe(
|
||||
[
|
||||
'<?xml version="1.0" encoding="utf-8"?>',
|
||||
"<movie>",
|
||||
" <tmdbid>157336</tmdbid>",
|
||||
' <uniqueid type="tmdb" default="false">157336</uniqueid>',
|
||||
" <imdbid>tt0816692</imdbid>",
|
||||
' <uniqueid type="imdb" default="true">tt0816692</uniqueid>',
|
||||
" <plot><![CDATA[穿越虫洞。]]></plot>",
|
||||
" <outline><![CDATA[穿越虫洞。]]></outline>",
|
||||
' <director tmdbid="525">克里斯托弗·诺兰</director>',
|
||||
" <actor>",
|
||||
" <name>马修·麦康纳</name>",
|
||||
" <type>Actor</type>",
|
||||
" <role>Cooper</role>",
|
||||
" <tmdbid>10297</tmdbid>",
|
||||
" <thumb>https://image.tmdb.org/t/p/original/mm.jpg</thumb>",
|
||||
" <profile>https://www.themoviedb.org/person/10297</profile>",
|
||||
" </actor>",
|
||||
" <genre>科幻</genre>",
|
||||
" <genre>剧情</genre>",
|
||||
" <rating>8.5</rating>",
|
||||
" <country>美国</country>",
|
||||
" <runtime>169</runtime>",
|
||||
" <title>星际穿越</title>",
|
||||
" <originaltitle>Interstellar</originaltitle>",
|
||||
" <premiered>2014-11-05</premiered>",
|
||||
" <year>2014</year>",
|
||||
"</movie>",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("剧集 tvshow.nfo:season/episode = -1,豆瓣 id、标语、平台", () => {
|
||||
const xml = workNfo({
|
||||
kind: "tvshow",
|
||||
title: "人生切割术",
|
||||
premiered: "2022-02-17",
|
||||
tagline: "工作与生活,彻底分开",
|
||||
studios: ["Apple TV+"],
|
||||
ids: { tmdb: "95396", tvdb: "371980", douban: "30448896" },
|
||||
});
|
||||
expect(xml).toContain('<uniqueid type="tmdb" default="true">95396</uniqueid>');
|
||||
expect(xml).toContain('<uniqueid type="tvdb">371980</uniqueid>');
|
||||
expect(xml).toContain('<uniqueid type="douban">30448896</uniqueid>');
|
||||
expect(xml).toContain("<studio>Apple TV+</studio>");
|
||||
expect(xml).toMatch(/<year>2022<\/year>\n {2}<season>-1<\/season>\n {2}<episode>-1<\/episode>\n {2}<tagline>/);
|
||||
expect(xml).not.toContain("<plot>");
|
||||
expect(xml).not.toContain("<rating>");
|
||||
});
|
||||
|
||||
it("转义与 CDATA:& < > 转义,]]> 拆段,控制字符删除", () => {
|
||||
const xml = workNfo({
|
||||
kind: "movie",
|
||||
title: 'A & <B> "c"\u0001',
|
||||
overview: "x]]>y",
|
||||
ids: { tmdb: "1" },
|
||||
});
|
||||
expect(xml).toContain('<title>A & <B> "c"</title>');
|
||||
expect(xml).toContain("<plot><![CDATA[x]]]]><![CDATA[>y]]></plot>");
|
||||
});
|
||||
|
||||
it("季与集", () => {
|
||||
expect(seasonNfo({ season: 1, title: "第 1 季", airDate: "2022-02-17" })).toBe(
|
||||
[
|
||||
'<?xml version="1.0" encoding="utf-8"?>',
|
||||
"<season>",
|
||||
" <title>第 1 季</title>",
|
||||
" <premiered>2022-02-17</premiered>",
|
||||
" <releasedate>2022-02-17</releasedate>",
|
||||
" <year>2022</year>",
|
||||
" <seasonnumber>1</seasonnumber>",
|
||||
"</season>",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const ep = episodeNfo({ season: 1, episode: 2, overview: "参观。", airDate: "2022-02-18", rating: 8, runtime: 53 });
|
||||
expect(ep).toContain("<title>第 2 集</title>");
|
||||
expect(ep).toContain("<rating>8.0</rating>");
|
||||
expect(ep).toMatch(/<season>1<\/season>\n {2}<episode>2<\/episode>/);
|
||||
expect(ep).toContain("<runtime>53</runtime>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("刮削目标布局", () => {
|
||||
const images = {
|
||||
posterPath: "/p.jpg",
|
||||
backdropPath: "/b.png",
|
||||
seasonPosters: new Map([[1, "/s1.jpg"]]),
|
||||
stills: new Map([
|
||||
["1:1", "/e1.jpg"],
|
||||
["1:2", null],
|
||||
]),
|
||||
};
|
||||
|
||||
it("作品目录推断", () => {
|
||||
expect(inferWorkDir("tv", "人生切割术 (2022)/Season 1/a.mkv")).toBe("人生切割术 (2022)");
|
||||
expect(inferWorkDir("tv", "剧集/人生切割术 (2022)/S01/a.mkv")).toBe("剧集/人生切割术 (2022)");
|
||||
expect(inferWorkDir("movie", "星际穿越 (2014)/a.mkv")).toBe("星际穿越 (2014)");
|
||||
expect(inferWorkDir("movie", "a.mkv")).toBeNull();
|
||||
expect(seasonPosterName(0)).toBe("season-specials-poster.jpg");
|
||||
});
|
||||
|
||||
it("剧集:tvshow / 季 / 集,季海报写两处,缺剧照不写", () => {
|
||||
const t = planScrapeTargets({
|
||||
kind: "tv",
|
||||
workDir: "人生切割术 (2022)",
|
||||
videos: [
|
||||
{ path: "人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 第 2 集.mkv", season: 1, episode: 2 },
|
||||
{ path: "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.mkv", season: 1, episode: 1 },
|
||||
],
|
||||
images,
|
||||
options: { images: true, episodeThumbs: true },
|
||||
});
|
||||
expect(t.map((x) => x.path)).toEqual([
|
||||
"人生切割术 (2022)/tvshow.nfo",
|
||||
"人生切割术 (2022)/poster.jpg",
|
||||
"人生切割术 (2022)/fanart.png",
|
||||
"人生切割术 (2022)/Season 1/season.nfo",
|
||||
"人生切割术 (2022)/season01-poster.jpg",
|
||||
"人生切割术 (2022)/Season 1/poster.jpg",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.nfo",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集-thumb.jpg",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 第 2 集.nfo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("关图片 / 关剧照", () => {
|
||||
const base = {
|
||||
kind: "tv" as const,
|
||||
workDir: "S",
|
||||
videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }],
|
||||
images,
|
||||
};
|
||||
expect(planScrapeTargets({ ...base, options: { images: false, episodeThumbs: true } }).map((x) => x.role)).toEqual([
|
||||
"tvshow_nfo",
|
||||
"season_nfo",
|
||||
"episode_nfo",
|
||||
]);
|
||||
expect(
|
||||
planScrapeTargets({ ...base, options: { images: true, episodeThumbs: false } }).some(
|
||||
(x) => x.role === "episode_thumb",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("电影:作品目录里写 poster/fanart;在库根时用 <视频名>-poster", () => {
|
||||
const inDir = planScrapeTargets({
|
||||
kind: "movie",
|
||||
workDir: "星际穿越 (2014)",
|
||||
videos: [{ path: "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv" }],
|
||||
images,
|
||||
options: { images: true, episodeThumbs: true },
|
||||
});
|
||||
expect(inDir.map((x) => x.path)).toEqual([
|
||||
"星际穿越 (2014)/星际穿越 (2014) - 1080p.nfo",
|
||||
"星际穿越 (2014)/poster.jpg",
|
||||
"星际穿越 (2014)/fanart.png",
|
||||
]);
|
||||
const atRoot = planScrapeTargets({
|
||||
kind: "movie",
|
||||
workDir: null,
|
||||
videos: [{ path: "x.mkv" }],
|
||||
images,
|
||||
options: { images: true, episodeThumbs: true },
|
||||
});
|
||||
expect(atRoot.map((x) => x.path)).toEqual(["x.nfo", "x-poster.jpg", "x-fanart.png"]);
|
||||
});
|
||||
|
||||
it("剧集视频在库根:不写 tvshow / 季文件,只写集 NFO", () => {
|
||||
const t = planScrapeTargets({
|
||||
kind: "tv",
|
||||
workDir: null,
|
||||
videos: [{ path: "a.S01E01.mkv", season: 1, episode: 1 }],
|
||||
images,
|
||||
options: { images: true, episodeThumbs: true },
|
||||
});
|
||||
expect(t.map((x) => x.path)).toEqual(["a.S01E01.nfo", "a.S01E01-thumb.jpg"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user