feat(organize): 整理、季包选文件、刮削、文件管理识别统一套用集数映射;单集沿用资源判定,待确认文件不按原号入库
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
691671a1d2
commit
fe597187dd
12 files changed
+848
-21
No files matched your search
@@ -16,10 +16,12 @@ import {
|
||||
type Transaction,
|
||||
} from "@umt/db";
|
||||
import {
|
||||
type EpisodeResolution,
|
||||
episodeKey,
|
||||
fileKindOf,
|
||||
mapQbState,
|
||||
type PlanStage,
|
||||
parseRelease,
|
||||
parseReleasePath,
|
||||
parseUnitKey,
|
||||
type SlotEvent,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
} from "@umt/domain";
|
||||
import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping";
|
||||
import { withAttemptToken } from "@umt/tasks";
|
||||
import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts";
|
||||
import { errMessage, jsonb } from "../util.ts";
|
||||
import { parsedOf } from "./acquisition.ts";
|
||||
import {
|
||||
@@ -466,17 +469,42 @@ async function selectPackFiles(
|
||||
const u = parseUnitKey(k);
|
||||
return !!u && wildSeasons.has(u.season) && !blocked.has(k) && unitsOverlap([k], [...blocked]).length === 0;
|
||||
};
|
||||
// 集数映射(批次 3):文件名的集号按作品季表 + 规则换成规范季集再算单元键;文件没写季号时补资源标题声称的季号
|
||||
// (原始值,不是规范值,映射时才不会重复换算)。集数待确认的视频视同认不出集号(整包下载);
|
||||
// 映射规则标为特别篇 / 花絮 / 忽略的不下
|
||||
const entity = rel.media_entity_id
|
||||
? await d.ctx.db
|
||||
.selectFrom("media_entity")
|
||||
.select(["id", "kind"])
|
||||
.where("id", "=", rel.media_entity_id)
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
const mapEpisodes = entity ? await createEntityEpisodeMapper(d.ctx.db, entity.id, entity.kind) : null;
|
||||
const relRes = rel.episode_resolution as EpisodeResolution | null;
|
||||
const relRaw = mapEpisodes ? parseRelease(rel.title, rel.subtitle ? { subtitle: rel.subtitle } : {}) : null;
|
||||
const relSeason = relRaw && !relRaw.seasonInferred ? relRaw.season : undefined;
|
||||
const mapOpts = { publishedAt: publishedAtOf(rel), ...(relRes?.method ? { release: relRes } : {}) };
|
||||
const rows = files.map((f) => {
|
||||
const kind = fileKindOf(f.name);
|
||||
const p = parseReleasePath(f.name);
|
||||
let p = parseReleasePath(f.name);
|
||||
let special = false;
|
||||
if (mapEpisodes && kind === "video" && p.episodes) {
|
||||
if ((p.season === undefined || p.seasonInferred) && relSeason !== undefined) {
|
||||
p = { ...p, season: relSeason, seasonInferred: undefined };
|
||||
}
|
||||
const m = mapEpisodes(p, mapOpts);
|
||||
if (m.resolution.method === "unresolved") p = { ...p, episodes: undefined };
|
||||
else if (m.resolution.special) special = true;
|
||||
else p = m.parsed;
|
||||
}
|
||||
const keys: string[] = [];
|
||||
if (p.episodes)
|
||||
if (p.episodes && !special)
|
||||
for (let e = p.episodes.start; e <= p.episodes.end && keys.length < 200; e++)
|
||||
keys.push(episodeKey(p.season ?? parsed.season ?? 1, e));
|
||||
return { index: f.index, name: f.name, kind, keys, wanted: keys.some(wantedKey) };
|
||||
return { index: f.index, name: f.name, kind, keys, special, wanted: keys.some(wantedKey) };
|
||||
});
|
||||
const videos = rows.filter((r) => r.kind === "video");
|
||||
const unmapped = videos.filter((r) => r.keys.length === 0);
|
||||
const unmapped = videos.filter((r) => r.keys.length === 0 && !r.special);
|
||||
if (videos.length === 0 || unmapped.length > 0)
|
||||
return void (await record({
|
||||
mode: "whole",
|
||||
|
||||
@@ -3,11 +3,13 @@ import { basename, dirname, join, relative } from "node:path";
|
||||
import { checkRoot, DEFAULT_ROOT_SENTINEL, FsError, sha256Full, statSafe } from "@umt/adapters";
|
||||
import type { ActorType, OrganizationTrigger, PathBinding } from "@umt/contracts";
|
||||
import { reasonText } from "@umt/contracts";
|
||||
import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow } from "@umt/db";
|
||||
import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, SourceReleaseRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import {
|
||||
buildNamingContext,
|
||||
type PathBinding as DomainBinding,
|
||||
type FileOverride as DomainFileOverride,
|
||||
type EpisodeResolution,
|
||||
type ExistingTarget,
|
||||
fileKindOf,
|
||||
type NewBinding,
|
||||
@@ -24,6 +26,13 @@ import {
|
||||
sidecarStem,
|
||||
} from "@umt/domain";
|
||||
import { organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping";
|
||||
import {
|
||||
createEntityEpisodeMapper,
|
||||
type EntityEpisodeMapper,
|
||||
type FileEpisodeOptions,
|
||||
publishedAtOf,
|
||||
settleEpisodeDecision,
|
||||
} from "../../sources-identity/service/episode-resolution.ts";
|
||||
import { errMessage, hashJson, jsonb, toApi } from "../util.ts";
|
||||
import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
|
||||
import { classifyEntity } from "./classification.ts";
|
||||
@@ -36,7 +45,7 @@ import { successfulOrganizeCounts } from "./organize-history.ts";
|
||||
import { planTransfer } from "./organize-transfer.ts";
|
||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
||||
import type { FrozenPlan, ImportRequest, PlanResult } from "./organize-types.ts";
|
||||
import type { EpisodeFacts, FrozenPlan, ImportRequest, PlanResult } from "./organize-types.ts";
|
||||
import { type FrozenReplacement, freezeReplacement } from "./replacement.ts";
|
||||
import { hnrSatisfied } from "./retention.ts";
|
||||
import { subscriptionLibraryRoot } from "./subscriptions.ts";
|
||||
@@ -623,14 +632,40 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
const naming = buildNamingContext(namingEntity);
|
||||
const facts: FrozenPlan["facts"] = {};
|
||||
const seasons = new Set<number>();
|
||||
// 集数映射(批次 3):文件名解析出的是发布者声称的季集,按作品季表 + 规则换成规范季集再定季目录、命名与单元;
|
||||
// 用户逐文件指定的季 / 集优先,不再映射。结果以精确路径覆写交给 planOrganize(待确认 / special 规则的文件不入库)
|
||||
const episodes = mediaType === "tv" ? await planFileEpisodes(d, entity, rel, dt) : null;
|
||||
const mappedOverrides: Record<string, DomainFileOverride> = {};
|
||||
const unresolvedEpisodes: NonNullable<FrozenPlan["unresolvedEpisodes"]> = {};
|
||||
for (const f of files) {
|
||||
const parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot));
|
||||
let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot));
|
||||
if (parsed.fileKind !== "video") continue;
|
||||
const ov = overrideFor(req.fileOverrides, f.path);
|
||||
if (mediaType === "tv" && ov?.season !== undefined) {
|
||||
parsed.season = ov.season;
|
||||
let resolution: EpisodeResolution | undefined;
|
||||
if (mediaType === "tv" && (ov?.season !== undefined || ov?.episodes)) {
|
||||
if (ov?.season !== undefined) parsed.season = ov.season;
|
||||
if (ov?.episodes) parsed.episodes = ov.episodes;
|
||||
} else if (episodes) {
|
||||
const raw = parsed;
|
||||
const r = episodes.map(raw, episodes.opts);
|
||||
resolution = r.resolution;
|
||||
if (r.resolution.method === "unresolved" || r.resolution.special) {
|
||||
const unresolved = r.resolution.method === "unresolved";
|
||||
mappedOverrides[f.path] = {
|
||||
exclude: unresolved
|
||||
? { reason: "episode_unresolved", detail: episodeText(r.resolution) }
|
||||
: { reason: "special_extra", detail: `rule:${r.resolution.special}` },
|
||||
};
|
||||
unresolvedEpisodes[f.path] = episodeFacts(r.resolution);
|
||||
if (unresolved) episodes.unresolved.push({ path: f.path, resolution: r.resolution, parsed: raw });
|
||||
continue;
|
||||
}
|
||||
parsed = r.parsed;
|
||||
const c = r.resolution.canonical;
|
||||
if (c && (c.season !== (raw.season ?? 1) || !sameRange(c.episodes, raw.episodes))) {
|
||||
mappedOverrides[f.path] = { season: c.season, episodes: { ...c.episodes } };
|
||||
}
|
||||
}
|
||||
if (mediaType === "tv" && ov?.episodes) parsed.episodes = ov.episodes;
|
||||
const season = parsed.season ?? (parsed.episodes || parsed.airDate ? 1 : undefined);
|
||||
if (mediaType === "tv" && season !== undefined) seasons.add(season);
|
||||
facts[f.path] = {
|
||||
@@ -642,8 +677,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
...(season !== undefined ? { season } : {}),
|
||||
...(parsed.episodes ? { episodes: parsed.episodes } : {}),
|
||||
...(ov?.contentUnitIds?.length ? { unitIds: ov.contentUnitIds } : {}),
|
||||
...(resolution && resolution.method !== "skipped" ? { episodeResolution: episodeFacts(resolution) } : {}),
|
||||
};
|
||||
}
|
||||
const overrides =
|
||||
Object.keys(mappedOverrides).length > 0 ? { ...req.fileOverrides, ...mappedOverrides } : req.fileOverrides;
|
||||
const libRoot = { id: root.id, hostPath: root.local_path };
|
||||
const newBindings: NewBinding[] = [];
|
||||
const target: { movieDir?: string; seasonDirs?: Record<number, string> } = {};
|
||||
@@ -755,7 +793,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
},
|
||||
retention: { hnrSatisfied: hnr },
|
||||
minVideoBytes: req.minSizeBytes ?? orgSettings.minSizeBytes ?? d.ports.options.minVideoBytes,
|
||||
...(req.fileOverrides ? { overrides: req.fileOverrides } : {}),
|
||||
...(overrides ? { overrides } : {}),
|
||||
...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG
|
||||
? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG }
|
||||
: {}),
|
||||
@@ -867,6 +905,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
newBindings,
|
||||
sourceRoot,
|
||||
facts,
|
||||
...(Object.keys(unresolvedEpisodes).length ? { unresolvedEpisodes } : {}),
|
||||
...(downgradedFromMove ? { downgradedFromMove } : {}),
|
||||
...(req.slotId ? { slotId: req.slotId } : {}),
|
||||
naming: {
|
||||
@@ -882,8 +921,20 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
targetLibraryRootId: root.id,
|
||||
});
|
||||
const blocking = plan.conflicts.filter((c) => c.blocking);
|
||||
// 没有可入库主视频是因为集数待确认:原因写 episode_unresolved(而不是笼统的 no_main_video)
|
||||
const blockKinds = [
|
||||
...new Set(
|
||||
blocking.map((c) =>
|
||||
c.kind === "no_main_video" && plan.excluded.some((x) => x.reason === "episode_unresolved")
|
||||
? "episode_unresolved"
|
||||
: c.kind,
|
||||
),
|
||||
),
|
||||
];
|
||||
const reason = blocking.length
|
||||
? `blocked:${[...new Set(blocking.map((c) => c.kind))].join(",")}`
|
||||
? blockKinds.length === 1 && blockKinds[0] === "episode_unresolved"
|
||||
? "episode_unresolved"
|
||||
: `blocked:${blockKinds.join(",")}`
|
||||
: downgradedFromMove
|
||||
? `move_downgraded_to_copy:${downgradedFromMove}`
|
||||
: null;
|
||||
@@ -1003,9 +1054,88 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
await emitRecord(d, trx, rec.id);
|
||||
return { recordId: rec.id, status: rec.status, reason };
|
||||
});
|
||||
// 文件集数待确认:并入关联资源的 episode_mapping 待处理(待处理按资源归集;没有关联资源只记整理失败原因)
|
||||
const pending = episodes?.unresolved[0];
|
||||
if (pending && episodes?.release) {
|
||||
await settleEpisodeDecision(d.ctx, db, {
|
||||
sourceReleaseId: episodes.release.id,
|
||||
entityId: entity.id,
|
||||
resolution: pending.resolution,
|
||||
parsed: pending.parsed,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface FileEpisodes {
|
||||
map: EntityEpisodeMapper;
|
||||
opts: FileEpisodeOptions;
|
||||
/** 下载关联的资源(已绑定本作品) */
|
||||
release: SourceReleaseRow | null;
|
||||
unresolved: { path: string; resolution: EpisodeResolution; parsed: ParsedRelease }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 整理计划的集数映射装配:作品季表 + 规则加载一次;下载关联的资源(下载任务直接关联,或经下载 / 导入计划)
|
||||
* 提供发布时间,原始季集与文件相同时直接沿用它的规范季集
|
||||
*/
|
||||
async function planFileEpisodes(
|
||||
d: AoDeps,
|
||||
entity: MediaEntityRow,
|
||||
rel: SourceReleaseRow | undefined,
|
||||
dt: DownloadTaskRow | undefined,
|
||||
): Promise<FileEpisodes> {
|
||||
const db = d.ctx.db;
|
||||
let linked = rel;
|
||||
const planId = dt?.acquisition_plan_id ?? dt?.import_plan_id;
|
||||
if (!linked && planId) {
|
||||
linked = await db
|
||||
.selectFrom("acquisition_plan as p")
|
||||
.innerJoin("source_release as r", "r.id", "p.source_release_id")
|
||||
.selectAll("r")
|
||||
.where("p.id", "=", planId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
const map = await createEntityEpisodeMapper(db, entity.id, entity.kind);
|
||||
const own = linked && linked.media_entity_id === entity.id ? linked : null;
|
||||
const res = own?.episode_resolution as EpisodeResolution | null | undefined;
|
||||
return {
|
||||
map,
|
||||
opts: {
|
||||
...(linked ? { publishedAt: publishedAtOf(linked) } : {}),
|
||||
...(res?.method ? { release: res } : {}),
|
||||
},
|
||||
release: own,
|
||||
unresolved: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sameRange = (a: { start: number; end: number }, b: { start: number; end: number } | undefined) =>
|
||||
!!b && a.start === b.start && a.end === b.end;
|
||||
|
||||
/** 冻结进计划的集数映射摘要(排查用) */
|
||||
function episodeFacts(r: EpisodeResolution): EpisodeFacts {
|
||||
return {
|
||||
method: r.method,
|
||||
source: r.source,
|
||||
canonical: r.canonical,
|
||||
...(r.ruleId ? { ruleId: r.ruleId } : {}),
|
||||
...(r.special ? { special: r.special } : {}),
|
||||
evidence: r.evidence,
|
||||
};
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** 排除明细:原始季集 + 判定依据 */
|
||||
function episodeText(r: EpisodeResolution): string {
|
||||
const { season, episodes, airDate } = r.source;
|
||||
const ep = episodes
|
||||
? `E${pad2(episodes.start)}${episodes.end > episodes.start ? `-E${pad2(episodes.end)}` : ""}`
|
||||
: (airDate ?? "");
|
||||
return `${season !== undefined ? `S${pad2(season)}` : ""}${ep} ${r.evidence.join(",")}`.trim();
|
||||
}
|
||||
|
||||
/** W9-H 保留原文件名:目标目录不变,文件名取源文件名 */
|
||||
export const keptDst = (f: { src: string; dst: string }) => join(dirname(f.dst), basename(f.src));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ActorType, OrganizationRecord, OrganizationTrigger } from "@umt/contracts";
|
||||
import type { ExistingPolicy, NewBinding, OrganizePlan, QualityFacts } from "@umt/domain";
|
||||
import type { EpisodeResolution, ExistingPolicy, NewBinding, OrganizePlan, QualityFacts } from "@umt/domain";
|
||||
import type { FrozenTransfer, TransferSpec } from "./organize-transfer.ts";
|
||||
import type { FrozenReplacement } from "./replacement.ts";
|
||||
|
||||
@@ -69,6 +69,12 @@ export interface FileOverrideInput {
|
||||
contentUnitIds?: string[];
|
||||
}
|
||||
|
||||
/** 冻结计划里的集数映射摘要(排查用) */
|
||||
export type EpisodeFacts = Pick<
|
||||
EpisodeResolution,
|
||||
"method" | "source" | "canonical" | "evidence" | "ruleId" | "special"
|
||||
>;
|
||||
|
||||
/** organization_record.plan 的冻结内容 */
|
||||
export interface FrozenPlan {
|
||||
scrapeAfterImport?: boolean;
|
||||
@@ -104,8 +110,12 @@ export interface FrozenPlan {
|
||||
episodes?: { start: number; end: number };
|
||||
/** 人工指定的内容单元(W5-A2 覆写) */
|
||||
unitIds?: string[];
|
||||
/** 按作品做的集数映射(方式 + 文件名原始季集);人工指定季集或非剧集时没有 */
|
||||
episodeResolution?: EpisodeFacts;
|
||||
}
|
||||
>;
|
||||
/** 集数待确认 / 映射规则标为特别篇 · 花絮 · 忽略、因而不入库的视频:源路径 → 集数映射摘要 */
|
||||
unresolvedEpisodes?: Record<string, EpisodeFacts>;
|
||||
downgradedFromMove?: string;
|
||||
/** W5-A2:手动整理指定的 Slot */
|
||||
slotId?: string;
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
seasonOfDirName,
|
||||
workNfo,
|
||||
} from "@umt/domain";
|
||||
import {
|
||||
createEntityEpisodeMapper,
|
||||
type EntityEpisodeMapper,
|
||||
} from "../../sources-identity/service/episode-resolution.ts";
|
||||
import { errMessage, jsonb } from "../util.ts";
|
||||
import type { AoDeps } from "./common.ts";
|
||||
import {
|
||||
@@ -664,6 +668,7 @@ export async function runScrapeRecord(
|
||||
// ── 字段:本地作品(有元数据)优先 ──
|
||||
let fields: Fields | null = null;
|
||||
let coverage = new Map<string, { season: number; episode: number }>();
|
||||
let mapEpisodes: EntityEpisodeMapper | null = null;
|
||||
if (match.mediaEntityId) {
|
||||
const e = await db
|
||||
.selectFrom("media_entity")
|
||||
@@ -671,6 +676,8 @@ export async function runScrapeRecord(
|
||||
.where("id", "=", match.mediaEntityId)
|
||||
.executeTakeFirst();
|
||||
if (e && e.status !== "merged") {
|
||||
if (kind === "tv" && (e.kind === "series" || e.kind === "variety"))
|
||||
mapEpisodes = await createEntityEpisodeMapper(db, e.id, e.kind);
|
||||
const ids: IdentityMap = {};
|
||||
const idRows = await db
|
||||
.selectFrom("external_identity")
|
||||
@@ -711,6 +718,8 @@ export async function runScrapeRecord(
|
||||
// 季 / 集号:已登记的按覆盖单元;其余按路径解析(季目录名兜底,同 MP 对每集重新 MetaInfoPath)
|
||||
const videos: ScrapeVideo[] = [];
|
||||
const noEpisode: ScrapeFileResult[] = [];
|
||||
// 集数映射(批次 3):未登记的文件按本地剧集作品的季表 + 规则把文件名季集换成规范季集再对集元数据;
|
||||
// 集数待确认的不写分集 NFO(原因 episode_unresolved)
|
||||
for (const v of lay.videos) {
|
||||
if (kind === "movie") {
|
||||
videos.push({ path: v });
|
||||
@@ -722,8 +731,26 @@ export async function runScrapeRecord(
|
||||
continue;
|
||||
}
|
||||
const p = parseReleasePath(v);
|
||||
const season = p.season ?? lay.dirSeason ?? seasonOfDirName(baseOf(dirOf(v))) ?? (p.episodes ? 1 : null);
|
||||
const episode = p.episodes?.start ?? null;
|
||||
let season = p.season ?? lay.dirSeason ?? seasonOfDirName(baseOf(dirOf(v))) ?? (p.episodes ? 1 : null);
|
||||
let episode = p.episodes?.start ?? null;
|
||||
if (mapEpisodes && p.episodes) {
|
||||
const m = mapEpisodes(p);
|
||||
if (m.resolution.method === "unresolved") {
|
||||
noEpisode.push({
|
||||
path: v,
|
||||
kind: "nfo",
|
||||
role: "episode_nfo",
|
||||
action: "skipped",
|
||||
reason: "episode_unresolved",
|
||||
});
|
||||
videos.push({ path: v, season, episode: null });
|
||||
continue;
|
||||
}
|
||||
if (m.resolution.canonical) {
|
||||
season = m.resolution.canonical.season;
|
||||
episode = m.resolution.canonical.episodes.start;
|
||||
}
|
||||
}
|
||||
if (episode === null)
|
||||
noEpisode.push({ path: v, kind: "nfo", role: "episode_nfo", action: "skipped", reason: "scrape_no_episode" });
|
||||
videos.push({ path: v, season, episode });
|
||||
|
||||
@@ -23,6 +23,7 @@ import { localIdentityCandidate } from "@umt/mapping";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { successfulOrganizeCounts } from "../acquisition-organize/services/organize-history.ts";
|
||||
import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts";
|
||||
import { createEntityEpisodeMapper, type EntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts";
|
||||
import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts";
|
||||
import { kindOfName } from "./list.ts";
|
||||
|
||||
@@ -226,6 +227,7 @@ export async function identifyCore(
|
||||
const localCache = new Map<string, IdentityCandidate[]>();
|
||||
const remoteCache = new Map<string, { list: IdentityCandidate[]; warning: string | null; pending?: boolean }>();
|
||||
const entityKinds = new Map<string, string>();
|
||||
const mappers = new Map<string, EntityEpisodeMapper>();
|
||||
// G8:候选海报(TMDB poster_path;本地作品取投影后的 metadata.posterPath)
|
||||
const posters = new Map<string, string>();
|
||||
|
||||
@@ -446,10 +448,28 @@ export async function identifyCore(
|
||||
const best = candidates[0];
|
||||
let contentUnitId: string | null = null;
|
||||
let contentUnit: string | null = null;
|
||||
const season = parsed.season ?? (parsed.episodes ? 1 : undefined);
|
||||
// 集数映射(批次 3,只读):识别到本地剧集作品后,展示与建议单元用该作品映射后的季集;待确认的不给单元
|
||||
let shown = parsed;
|
||||
let unresolved = false;
|
||||
if (best?.mediaEntityId && parsed.episodes) {
|
||||
const kind = entityKinds.get(best.mediaEntityId);
|
||||
if (kind === "series" || kind === "variety") {
|
||||
let map = mappers.get(best.mediaEntityId);
|
||||
if (!map) {
|
||||
map = await createEntityEpisodeMapper(db, best.mediaEntityId, kind);
|
||||
mappers.set(best.mediaEntityId, map);
|
||||
}
|
||||
const m = map(parsed);
|
||||
if (m.resolution.method === "unresolved") {
|
||||
unresolved = true;
|
||||
warnings.push("集数对不上作品的季集,需要人工指定");
|
||||
} else shown = m.parsed;
|
||||
}
|
||||
}
|
||||
const season = unresolved ? undefined : (shown.season ?? (shown.episodes ? 1 : undefined));
|
||||
if (season !== undefined) {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
contentUnit = parsed.episodes ? `S${pad(season)}E${pad(parsed.episodes.start)}` : `S${pad(season)}`;
|
||||
contentUnit = shown.episodes ? `S${pad(season)}E${pad(shown.episodes.start)}` : `S${pad(season)}`;
|
||||
if (best?.mediaEntityId) {
|
||||
let q = db
|
||||
.selectFrom("content_unit")
|
||||
@@ -457,7 +477,7 @@ export async function identifyCore(
|
||||
.where("media_entity_id", "=", best.mediaEntityId)
|
||||
.where("season_number", "=", season)
|
||||
.where("status", "<>", "retired");
|
||||
q = parsed.episodes ? q.where("episode_number", "=", parsed.episodes.start) : q.where("kind", "=", "season");
|
||||
q = shown.episodes ? q.where("episode_number", "=", shown.episodes.start) : q.where("kind", "=", "season");
|
||||
contentUnitId = (await q.executeTakeFirst())?.id ?? null;
|
||||
}
|
||||
}
|
||||
@@ -474,7 +494,7 @@ export async function identifyCore(
|
||||
items.push({
|
||||
root: v.root as FileIdentifyItem["root"],
|
||||
path: v.rel,
|
||||
parsed: parsedOut(parsed),
|
||||
parsed: parsedOut(shown),
|
||||
candidates,
|
||||
suggestion: { mediaEntityId: best?.mediaEntityId ?? null, contentUnitId, contentUnit },
|
||||
existing: { assetId: existing?.media_asset_id ?? null },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { extname } from "node:path";
|
||||
import type { FilesSuggestNameRequest, FilesSuggestNameResult } from "@umt/contracts";
|
||||
import { buildNamingVars, normalizeTitle, parseReleasePath, renderNamingPath } from "@umt/domain";
|
||||
import { effectiveNaming, loadNamingEntity } from "../acquisition-organize/services/naming.ts";
|
||||
import { createEntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts";
|
||||
import { FilesError, type FmDeps, resolveIn } from "./common.ts";
|
||||
|
||||
// G9 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件/目录渲染名字(与整理用同一引擎与变量来源)。
|
||||
@@ -76,7 +77,14 @@ export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Pro
|
||||
const namingEntity = await loadNamingEntity(db, entity, { libraryRoot: root?.name });
|
||||
const kind = namingEntity.kind ?? "movie";
|
||||
const ext = isDir ? "" : extname(r.rel);
|
||||
const vars = buildNamingVars(namingEntity, parsed, isDir ? {} : { fileExt: ext });
|
||||
// 集数映射(批次 3,只读):文件名的季集按作品季表 + 规则换成规范季集再命名;待确认的不给推荐名
|
||||
let named = parsed;
|
||||
if (!isDir && parsed.episodes && (entity.kind === "series" || entity.kind === "variety")) {
|
||||
const m = (await createEntityEpisodeMapper(db, entity.id, entity.kind))(parsed);
|
||||
if (m.resolution.method === "unresolved") return empty("episode_unresolved", entityId);
|
||||
named = m.parsed;
|
||||
}
|
||||
const vars = buildNamingVars(namingEntity, named, isDir ? {} : { fileExt: ext });
|
||||
const out = renderNamingPath(eff.templates[kind], vars, isDir ? { mode: "dirs" } : {});
|
||||
const segments = out.segments.map((s) => ({ text: s.text, role: s.role }));
|
||||
if (!out.ok)
|
||||
|
||||
@@ -131,6 +131,56 @@ export async function resolveReleaseEpisodes(exec: Exec, input: ReleaseEpisodesI
|
||||
return { resolution, parsed: canonicalParsed(input.parsed, resolution) };
|
||||
}
|
||||
|
||||
export interface FileEpisodeOptions {
|
||||
/** 资源发布时间:滞后判定用;文件没有关联资源时不传 */
|
||||
publishedAt?: Date | string | null;
|
||||
/**
|
||||
* 文件所属下载关联的资源的集数解析结果:其原始季集与文件解析出的相同(单集资源的那个文件)时直接沿用,
|
||||
* 保证下载与整理认同一集
|
||||
*/
|
||||
release?: EpisodeResolution | null;
|
||||
}
|
||||
|
||||
/** 按作品映射文件 / 资源解析结果的季集(同步,季表与规则已加载) */
|
||||
export type EntityEpisodeMapper = (parsed: ParsedRelease, opts?: FileEpisodeOptions) => ReleaseEpisodes;
|
||||
|
||||
const EPISODE_KINDS = new Set<MediaKind>(["series", "variety"]);
|
||||
|
||||
/**
|
||||
* 文件侧接入(批次 3):整理计划、季包选文件、文件管理识别、刮削对文件名重新解析出的季集,按同一作品的季表 + 规则
|
||||
* 映射。季表与规则只加载一次;返回的函数纯计算,不停用规则、不开待处理(由调用方决定)。
|
||||
* 非剧集作品不加载,解析结果原样返回(method = skipped)。
|
||||
*/
|
||||
export async function createEntityEpisodeMapper(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
kind: MediaKind,
|
||||
): Promise<EntityEpisodeMapper> {
|
||||
const episodic = EPISODE_KINDS.has(kind);
|
||||
const table: SeasonTable = episodic
|
||||
? await loadSeasonTable(exec, entityId)
|
||||
: { seasons: [], latestSeason: null, ended: false };
|
||||
const rules = episodic ? await loadRules(exec, entityId) : [];
|
||||
return (parsed, opts = {}) => {
|
||||
const rel = opts.release;
|
||||
if (rel && sameSource(rel.source, parsed)) return { resolution: rel, parsed: canonicalParsed(parsed, rel) };
|
||||
const p = opts.publishedAt;
|
||||
const publishedAt = p instanceof Date ? p.toISOString() : (p ?? null);
|
||||
const resolution = resolveEpisodes({ parsed, kind, table, rules, publishedAt });
|
||||
return { resolution, parsed: canonicalParsed(parsed, resolution) };
|
||||
};
|
||||
}
|
||||
|
||||
/** 资源的原始季集与文件解析结果相同(季号缺省都按未给出比较,集区间与播出日期逐项相等) */
|
||||
function sameSource(src: EpisodeResolution["source"], p: ParsedRelease): boolean {
|
||||
if (!src.episodes && !src.airDate) return false;
|
||||
if (src.season !== p.season) return false;
|
||||
const a = src.episodes;
|
||||
const b = p.episodes;
|
||||
if (!!a !== !!b || (a && b && (a.start !== b.start || a.end !== b.end))) return false;
|
||||
return (src.airDate ?? null) === (p.airDate ?? null);
|
||||
}
|
||||
|
||||
/** 规范季集叠加到解析结果上(保留其余字段);canonical 为空时原样返回 */
|
||||
export function canonicalParsed(parsed: ParsedRelease, resolution: EpisodeResolution): ParsedRelease {
|
||||
const c = resolution.canonical;
|
||||
|
||||
Reference in new issue
Block a user