merge: fix/i-scrape
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
ee503dd1ea
35 files changed
+1915
-200
No files matched your search
@@ -119,6 +119,8 @@ const REASONS: Record<string, ReasonText> = {
|
||||
scrape_image_failed: { text: "图片没下载成功", action: "稍后重新刮削" },
|
||||
scrape_no_episode: { text: "文件名里认不出集号" },
|
||||
scrape_no_work_dir: { text: "视频直接放在媒体库目录下,不写剧集和季的文件" },
|
||||
scrape_canceled: { text: "刮削任务被取消", action: "重新刮削" },
|
||||
scrape_not_library: { text: "整理目标不在媒体库目录里,不自动刮削", action: "到文件管理里对目录刮削" },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,8 @@ export type ScrapeFileResult = z.infer<typeof ScrapeFileResult>;
|
||||
export const ScrapeMode = z.enum(["missing", "overwrite", "update"]);
|
||||
export type ScrapeMode = z.infer<typeof ScrapeMode>;
|
||||
|
||||
export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed"]);
|
||||
/** skipped = 入库后自动刮削没排上(原因在 reason;0074),整理详情显示「未刮削」 */
|
||||
export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "skipped"]);
|
||||
export type ScrapeStatus = z.infer<typeof ScrapeStatus>;
|
||||
export const ScrapeTrigger = z.enum(["auto", "manual"]);
|
||||
export type ScrapeTrigger = z.infer<typeof ScrapeTrigger>;
|
||||
|
||||
@@ -644,7 +644,8 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
|
||||
defaultMode: "copy",
|
||||
allowCrossVolumeMove: true,
|
||||
minSizeBytes: 50 * 1024 * 1024,
|
||||
scrapeAfterImport: false,
|
||||
// 缺省开:只对有 TMDB id 或本地元数据的作品排刮削(否则在整理记录上记「未刮削」),「更新」方式不动别人写的文件
|
||||
scrapeAfterImport: true,
|
||||
scrapeMode: "update",
|
||||
scrapeImages: true,
|
||||
scrapeEpisodeThumbs: true,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 入库后自动刮削没排上(库根不是可写管理模式、作品没有 TMDB id 也没有本地元数据、排队失败)时在整理记录上可见:
|
||||
// 写一条 status = skipped 的刮削记录(原因码在 reason),整理详情的「整理后刮削」里显示「未刮削(原因)」。
|
||||
// 设置关闭属预期,不写记录。
|
||||
|
||||
const OLD = ["queued", "running", "succeeded", "partial", "failed"];
|
||||
const NEW = [...OLD, "skipped"];
|
||||
|
||||
const swap = (values: readonly string[]) => `
|
||||
alter table scrape_record drop constraint if exists scrape_record_status_check;
|
||||
alter table scrape_record add constraint scrape_record_status_check ${oneOf("status", values)};
|
||||
`;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, swap(NEW));
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, `delete from scrape_record where status = 'skipped'; ${swap(OLD)}`);
|
||||
}
|
||||
@@ -233,7 +233,7 @@ 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 ScrapeRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "skipped";
|
||||
export type ScrapeRecordTrigger = "auto" | "manual";
|
||||
export type ScrapeRecordMode = "missing" | "overwrite" | "update";
|
||||
export type SecretPurpose =
|
||||
|
||||
@@ -17,4 +17,5 @@ export {
|
||||
} from "./plan.ts";
|
||||
export * from "./replacement.ts";
|
||||
export { type DefaultSubtitleLang, sidecarLanguageSuffix, sidecarStem } from "./sidecars.ts";
|
||||
export { type TitleCheckFile, titleMatchesNames, titleMismatches } from "./title-check.ts";
|
||||
export { findOtherVersions } from "./versions.ts";
|
||||
@@ -69,9 +69,9 @@ export interface FileOverride {
|
||||
episodes?: { start: number; end: number };
|
||||
/**
|
||||
* 不入库(调用方按作品集数映射判定:集数待确认 episode_unresolved、映射规则标为特别篇 / 花絮 / 忽略 special_extra);
|
||||
* 只按精确路径生效,目录前缀覆写不带它
|
||||
* 只按精确路径生效,目录前缀覆写不带它。title_mismatch = 文件标题明显不是本作品(混合包里别的作品),电影也生效
|
||||
*/
|
||||
exclude?: { reason: "episode_unresolved" | "special_extra"; detail?: string };
|
||||
exclude?: { reason: "episode_unresolved" | "special_extra" | "title_mismatch"; detail?: string };
|
||||
}
|
||||
|
||||
/** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */
|
||||
@@ -121,7 +121,8 @@ export interface ExcludedFile {
|
||||
| "special_extra"
|
||||
| "extra_content"
|
||||
| "no_target_dir"
|
||||
| "naming_failed";
|
||||
| "naming_failed"
|
||||
| "title_mismatch";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
@@ -232,7 +233,8 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
|
||||
}
|
||||
const parsed = parseReleasePath(segmentsFor(f.path, input.sourceRoot), input.parse ?? {});
|
||||
const ov = input.mediaType === "tv" ? overrideFor(input.overrides, f.path) : undefined;
|
||||
const ex = input.mediaType === "tv" ? input.overrides?.[f.path]?.exclude : undefined;
|
||||
const exact = input.overrides?.[f.path]?.exclude;
|
||||
const ex = input.mediaType === "tv" || exact?.reason === "title_mismatch" ? exact : undefined;
|
||||
if (ex) {
|
||||
excluded.push({ src: f.path, kind, reason: ex.reason, ...(ex.detail ? { detail: ex.detail } : {}) });
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// 整理的文件级标题粗核(审视 ④ 风险 7):资源级认定了作品,但混合包 / 合集里混着别的作品时,
|
||||
// 按文件名解析出的标题和作品标题 / 别名对一下,明显不是同一部的不按本作品命名入库(降为待确认 title_mismatch)。
|
||||
// 只拦明显不同的:
|
||||
// - 只比文件名本身(不看目录名);文件名里解析不出标题的不判断;
|
||||
// - 剧集只看带 SxxEyy 的文件(只有集标题的文件名常被解析成「标题」);
|
||||
// - 同一批里至少有一个文件的标题对得上作品才判断别的文件(全都对不上时多半是作品名的另一种写法,
|
||||
// 例如罗马音、英文译名,交给资源级识别,不拦);
|
||||
// - 「对得上」= 有足够多的共同词:拉丁词(≥3 个字母,去掉常见虚词)或中日韩相邻两字,
|
||||
// 文件标题的词至少一半出现在作品某个名字里。
|
||||
import { parseRelease } from "../release-parser/index.ts";
|
||||
|
||||
const STOP = new Set(["the", "and", "of", "a", "an", "no", "to", "in", "on", "season", "part", "complete", "series"]);
|
||||
const CJK = /[-ヿ㐀-䶿一-鿿가-]/;
|
||||
|
||||
function tokensOf(s: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const t = s.normalize("NFKC").toLowerCase();
|
||||
for (const w of t.match(/[a-z0-9]+(?:'[a-z]+)?/g) ?? []) {
|
||||
const word = w.replace(/'[a-z]+$/, "");
|
||||
if (word.length >= 3 && !STOP.has(word) && !/^\d+$/.test(word)) out.add(word);
|
||||
}
|
||||
// 中日韩:按连续段取相邻两字(单字段整段算一个)
|
||||
for (const run of t.match(new RegExp(`${CJK.source}+`, "gu")) ?? []) {
|
||||
const chars = [...run];
|
||||
if (chars.length === 1) out.add(run);
|
||||
for (let i = 0; i + 1 < chars.length; i++) out.add(chars[i]! + chars[i + 1]!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 一个文件标题和作品名字对得上(共同词占文件标题的一半以上);文件标题没有可比的词时返回 null */
|
||||
export function titleMatchesNames(fileTitle: string, names: readonly string[]): boolean | null {
|
||||
const ft = tokensOf(fileTitle);
|
||||
if (!ft.size) return null;
|
||||
for (const n of names) {
|
||||
const nt = tokensOf(n);
|
||||
if (!nt.size) continue;
|
||||
let shared = 0;
|
||||
for (const w of ft) if (nt.has(w)) shared++;
|
||||
if (shared > 0 && shared * 2 >= ft.size) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface TitleCheckFile {
|
||||
path: string;
|
||||
/** 文件名(不含目录) */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批视频文件里标题明显不是本作品的(混合包里别的作品)。返回 路径 → 解析出的文件标题。
|
||||
* names:作品标题、原名、别名。
|
||||
*/
|
||||
export function titleMismatches(
|
||||
files: readonly TitleCheckFile[],
|
||||
names: readonly string[],
|
||||
mediaType: "movie" | "tv",
|
||||
): Map<string, string> {
|
||||
const usable = names.filter((n) => n?.trim());
|
||||
const judged: { path: string; title: string; ok: boolean }[] = [];
|
||||
for (const f of files) {
|
||||
const p = parseRelease(f.name, { isFile: true });
|
||||
if (mediaType === "tv" && (p.season === undefined || p.seasonInferred || !p.episodes)) continue;
|
||||
const titles = [
|
||||
...new Set([p.titleCn, p.titleEn, p.title, ...(p.altTitles ?? [])].filter((t): t is string => !!t)),
|
||||
];
|
||||
if (!titles.length) continue;
|
||||
const results = titles.map((t) => titleMatchesNames(t, usable));
|
||||
if (results.every((r) => r === null)) continue;
|
||||
judged.push({ path: f.path, title: titles[0]!, ok: results.some((r) => r === true) });
|
||||
}
|
||||
const out = new Map<string, string>();
|
||||
if (!judged.some((j) => j.ok)) return out;
|
||||
for (const j of judged) if (!j.ok) out.set(j.path, j.title);
|
||||
return out;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
// - 剧集:作品目录 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`,
|
||||
// 视频直接在库根(没有作品目录)或电影目录里还有别的作品时,电影图片改用 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`,
|
||||
// 剧集不写 tvshow / 季文件(不能往库根里放 tvshow.nfo)。
|
||||
|
||||
/**
|
||||
@@ -49,8 +49,13 @@ export interface ScrapeVideo {
|
||||
/** 库根内相对路径 */
|
||||
path: string;
|
||||
season?: number | null;
|
||||
/** 首集集号(多集文件按首集,同 MP begin_episode) */
|
||||
/** 首集集号(同 MP begin_episode;剧照、文件命名按首集) */
|
||||
episode?: number | null;
|
||||
/**
|
||||
* 文件覆盖的全部集号(多集文件 E01-E02 / 覆盖多个集单元),含首集、升序;缺省 = 只有首集。
|
||||
* 集 NFO 按 Kodi 约定每集写一个 `<episodedetails>` 块(MP 只写首集)。
|
||||
*/
|
||||
episodes?: readonly number[] | null;
|
||||
}
|
||||
|
||||
export interface ScrapeImages {
|
||||
@@ -74,6 +79,11 @@ export interface ScrapeLayoutInput {
|
||||
* 电影海报 / 背景图);season = 季级(season.nfo / 季海报)。缺省都写。选中单集文件时两者都关,选中季目录时只关 work。
|
||||
*/
|
||||
parts?: { work?: boolean; season?: boolean };
|
||||
/**
|
||||
* 电影:作品目录里还有别的作品的视频(自定义平铺模板把多部电影放在同一目录)。图片改用 `<视频名>-poster` /
|
||||
* `<视频名>-fanart`,不写整个目录共用的 poster.jpg / fanart.jpg。
|
||||
*/
|
||||
sharedDir?: boolean;
|
||||
}
|
||||
|
||||
export interface ScrapeTarget {
|
||||
@@ -83,6 +93,8 @@ export interface ScrapeTarget {
|
||||
role: ScrapeTargetRole;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
/** 集 NFO:文件覆盖的全部集号(多集文件多于一个) */
|
||||
episodes?: number[];
|
||||
/** 这个文件对应的视频(电影 / 集 NFO、剧照) */
|
||||
video?: string;
|
||||
/** 图片:TMDB 图片路径 */
|
||||
@@ -152,7 +164,7 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
|
||||
add({ path: join(dir, `${stemOf(v.path)}.nfo`), kind: "nfo", role: "movie_nfo", video: v.path });
|
||||
}
|
||||
if (!input.options.images || input.parts?.work === false) return out;
|
||||
const inWork = workDir && videos.some((v) => isUnder(v.path, workDir));
|
||||
const inWork = workDir && !input.sharedDir && 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 });
|
||||
@@ -227,12 +239,15 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
|
||||
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);
|
||||
const first = v.episode;
|
||||
const eps = [...new Set([first, ...(v.episodes ?? [])])].sort((a, b) => a - b);
|
||||
add({
|
||||
path: join(dir, `${stem}.nfo`),
|
||||
kind: "nfo",
|
||||
role: "episode_nfo",
|
||||
season: v.season,
|
||||
episode: v.episode,
|
||||
episodes: eps,
|
||||
video: v.path,
|
||||
});
|
||||
const still = img.stills?.get(`${v.season}:${v.episode}`);
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// - 缺值不写元素(MP 写空元素 `<premiered/>`、评分缺省写 0、空 CDATA);
|
||||
// - 简介里的 `]]>` 拆成两段 CDATA(MP 的 minidom 直接抛错);
|
||||
// - 额外写 豆瓣 id(doubanid + uniqueid type=douban)、标语 tagline、集时长 runtime;
|
||||
// - 集 NFO 没有 TMDB 集 id(本地不存),不写 uniqueid / tmdbid,媒体服务器按季号集号匹配。
|
||||
// - 集 NFO 没有 TMDB 集 id(本地不存),不写 uniqueid / tmdbid,媒体服务器按季号集号匹配;
|
||||
// - 多集文件(E01-E02)按 Kodi 约定每集一个 `<episodedetails>` 块写进同一个 NFO(MP 只写首集)。
|
||||
|
||||
export const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original";
|
||||
|
||||
@@ -92,9 +93,9 @@ function render(n: Node, indent: string, out: string[]) {
|
||||
else out.push(`${indent}<${n.tag}${attrs}/>`);
|
||||
}
|
||||
|
||||
function toXml(root: Node): string {
|
||||
function toXml(...roots: Node[]): string {
|
||||
const out = ['<?xml version="1.0" encoding="utf-8"?>'];
|
||||
render(root, "", out);
|
||||
for (const root of roots) render(root, "", out);
|
||||
return `${out.join("\n")}\n`;
|
||||
}
|
||||
|
||||
@@ -248,6 +249,19 @@ export function seasonNfo(s: NfoSeason): string {
|
||||
|
||||
/** 每集 `<视频名>.nfo` */
|
||||
export function episodeNfo(e: NfoEpisode): string {
|
||||
return toXml(episodeNode(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* 多集文件的 `<视频名>.nfo`:Kodi 约定(Jellyfin / Emby 同样读取)每集一个 `<episodedetails>` 块,依次写在同一个文件里,
|
||||
* 只有一个 XML 声明。只有一集时与 episodeNfo 相同。
|
||||
*/
|
||||
export function multiEpisodeNfo(list: readonly NfoEpisode[]): string {
|
||||
if (!list.length) throw new Error("multiEpisodeNfo: 至少一集");
|
||||
return toXml(...list.map(episodeNode));
|
||||
}
|
||||
|
||||
function episodeNode(e: NfoEpisode): Node {
|
||||
const b = new Builder();
|
||||
b.text("title", has(e.title) ? e.title : `第 ${e.episode} 集`);
|
||||
b.plot(e.overview);
|
||||
@@ -259,5 +273,5 @@ export function episodeNfo(e: NfoEpisode): string {
|
||||
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 });
|
||||
return { tag: "episodedetails", children: b.nodes };
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
planOrganize,
|
||||
sidecarLanguageSuffix,
|
||||
sidecarStem,
|
||||
titleMatchesNames,
|
||||
titleMismatches,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const MiB = 1024 ** 2;
|
||||
@@ -407,3 +409,48 @@ describe("原地入库计划", () => {
|
||||
expect(p.requiresApproval).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("文件级标题粗核", () => {
|
||||
const names = ["盗梦空间", "Inception"];
|
||||
it("合集里别的电影判为不符;对得上的、解析不出标题的不拦", () => {
|
||||
const r = titleMismatches(
|
||||
[
|
||||
{ path: "a", name: "Inception.2010.1080p.BluRay.x264-GRP.mkv" },
|
||||
{ path: "b", name: "Interstellar.2014.1080p.BluRay.x264-GRP.mkv" },
|
||||
{ path: "c", name: "盗梦空间.2010.2160p.mkv" },
|
||||
],
|
||||
names,
|
||||
"movie",
|
||||
);
|
||||
expect([...r]).toEqual([["b", "Interstellar"]]);
|
||||
});
|
||||
it("全都对不上时不拦(多半是作品名的另一种写法)", () => {
|
||||
expect(titleMismatches([{ path: "a", name: "Sousou.no.Frieren.S01E01.mkv" }], ["葬送的芙莉莲"], "tv").size).toBe(0);
|
||||
expect(
|
||||
titleMismatches(
|
||||
[
|
||||
{ path: "a", name: "Frieren.S01E01.1080p.WEB-DL.mkv" },
|
||||
{ path: "b", name: "Sousou.no.Frieren.S01E02.1080p.WEB-DL.mkv" },
|
||||
],
|
||||
["葬送的芙莉莲", "Frieren: Beyond Journey's End"],
|
||||
"tv",
|
||||
).size,
|
||||
).toBe(0);
|
||||
});
|
||||
it("剧集只看带 SxxEyy 的文件:只有集标题的文件名不拦", () => {
|
||||
const r = titleMismatches(
|
||||
[
|
||||
{ path: "a", name: "Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv" },
|
||||
{ path: "b", name: "Other.Show.S01E01.1080p.WEB-DL.mkv" },
|
||||
{ path: "c", name: "01 - The Beginning.mkv" },
|
||||
],
|
||||
["人生切割术", "Severance"],
|
||||
"tv",
|
||||
);
|
||||
expect([...r.keys()]).toEqual(["b"]);
|
||||
});
|
||||
it("中文标题按相邻两字比较:同系列相近的标题不拦", () => {
|
||||
expect(titleMatchesNames("哈利波特与密室", ["哈利波特与魔法石"])).toBe(true);
|
||||
expect(titleMatchesNames("星际穿越", ["盗梦空间"])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
episodeNfo,
|
||||
inferWorkDir,
|
||||
isSeasonDirName,
|
||||
multiEpisodeNfo,
|
||||
planScrapeTargets,
|
||||
scrapeKindOf,
|
||||
seasonNfo,
|
||||
@@ -255,3 +256,37 @@ describe("作品类型 → 刮削类型", () => {
|
||||
expect(["movie", "collection", "other"].map(scrapeKindOf)).toEqual(["movie", "movie", "movie"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("多集文件", () => {
|
||||
it("集 NFO 每集一个 <episodedetails> 块,只有一个 XML 声明;一集时与 episodeNfo 相同", () => {
|
||||
const xml = multiEpisodeNfo([
|
||||
{ season: 1, episode: 1, title: "上", airDate: "2022-02-17" },
|
||||
{ season: 1, episode: 2, title: "下", airDate: "2022-02-17" },
|
||||
]);
|
||||
expect(xml.match(/<\?xml /g)).toHaveLength(1);
|
||||
expect(xml.match(/<episodedetails>/g)).toHaveLength(2);
|
||||
expect(xml).toContain(" <title>上</title>\n <aired>2022-02-17</aired>");
|
||||
expect(xml.indexOf("<episode>1</episode>")).toBeLessThan(xml.indexOf("<episode>2</episode>"));
|
||||
const one = { season: 1, episode: 3, title: "单集" };
|
||||
expect(multiEpisodeNfo([one])).toBe(episodeNfo(one));
|
||||
});
|
||||
|
||||
it("布局:多集文件只写一个 NFO(按视频名)与首集剧照,目标带全部集号", () => {
|
||||
const t = planScrapeTargets({
|
||||
kind: "tv",
|
||||
workDir: "剧 (2020)",
|
||||
videos: [{ path: "剧 (2020)/Season 1/剧 - S01E01-E02.mkv", season: 1, episode: 1, episodes: [2, 1] }],
|
||||
images: { stills: new Map([["1:1", "/e1.jpg"]]) },
|
||||
options: { images: true, episodeThumbs: true },
|
||||
parts: { work: false, season: false },
|
||||
});
|
||||
expect(t).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "episode_nfo",
|
||||
path: "剧 (2020)/Season 1/剧 - S01E01-E02.nfo",
|
||||
episodes: [1, 2],
|
||||
}),
|
||||
expect.objectContaining({ role: "episode_thumb", path: "剧 (2020)/Season 1/剧 - S01E01-E02-thumb.jpg" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -143,10 +143,15 @@ export function organizePlanToFileResults(
|
||||
}
|
||||
|
||||
/**
|
||||
* 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段。文件留在原处没人管,
|
||||
* 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段、文件标题明显不是本作品(待确认)。文件留在原处没人管,
|
||||
* 不能算整理成功(Move 成功后会移除下载任务)。样例、过小、已在库等预期跳过不在此列。
|
||||
*/
|
||||
export const UNRESOLVED_EXCLUSIONS = ["episode_unresolved", "no_target_dir", "naming_failed"] as const;
|
||||
export const UNRESOLVED_EXCLUSIONS = [
|
||||
"episode_unresolved",
|
||||
"no_target_dir",
|
||||
"naming_failed",
|
||||
"title_mismatch",
|
||||
] as const;
|
||||
const UNRESOLVED_SKIP = new Set<string>(UNRESOLVED_EXCLUSIONS.map((r) => `excluded:${r}`));
|
||||
|
||||
/** 文件结果的 skip_reason 是否属于"本该入库却被排除" */
|
||||
|
||||
Reference in new issue
Block a user