feat(scrape): 任意目录就地刮削,按路径识别作品,每次刮削一条记录(SCR-2)
- 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]>
This commit is contained in:
1 parent
f60254e265
commit
87bf33b42b
29 files changed
+2272
-653
No files matched your search
@@ -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