- POST /files/scrape 改为对目录 / 视频文件刮削:已登记用作品,否则按路径识别(本地 + TMDB), 认不准返回候选,选定后再提交;未登记直接用 TMDB 字段,addToLibrary 顺手建作品 - 作品详情重新刮削、入库后自动刮削改为对作品目录刮削,同一实现 - 迁移 0058_scrape_record 替代 media_scrape;GET /files/scrape-records - 与 MP ScrapingChain 的文件布局差分(7 个场景,3 处登记偏离) Co-Authored-By: Claude Opus 5.5 <[email protected]>
244 lines
9.3 KiB
TypeScript
244 lines
9.3 KiB
TypeScript
// 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 };
|
|
/**
|
|
* SCR-2 就地刮削的范围(对照 MP scrape_metadata 的 init_folder):work = 作品级(剧集 tvshow.nfo / 海报 / 背景图,
|
|
* 电影海报 / 背景图);season = 季级(season.nfo / 季海报)。缺省都写。选中单集文件时两者都关,选中季目录时只关 work。
|
|
*/
|
|
parts?: { work?: boolean; season?: 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?|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 {
|
|
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 || 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;
|
|
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 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 && wantWork) {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
// 季:按视频所在目录(不等于作品目录的才算季目录);没有作品目录时只写季目录里的 season.nfo / poster
|
|
if (wantSeason) {
|
|
const seasons = new Map<number, Set<string>>();
|
|
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>();
|
|
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) {
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
}
|