diff --git a/CHANGELOG.md b/CHANGELOG.md index 430cc7be..96dfb34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ 版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。 +## [Unreleased] + +迁移 `0094`。 + +### 新增 +- 规则页加了「识别词」页签,一处看全所有识别词:上面是全局识别词,下面是写了作品识别词的作品(可搜索,显示规则行数、停用行数、最后修改),点一部作品在右侧抽屉里改,也可以「为作品添加」。 +- 全局识别词:写法和 MoviePilot 的自定义识别词一样(屏蔽词、`A => B` 替换、`前 <> 后 >> EP+1` 集数偏移、替换 + 偏移、`{[tmdbid=…;type=tv;s=1]}` 指定作品),MP 的列表可以直接粘贴。站点 / RSS / 搜索来的资源、整理时的文件名、文件管理识别、下载时选文件、刮削,识别前都会先过一遍;命中的行在集数映射预览里显示「命中识别词」。 +- 作品识别词:一部作品一块文本,一行一条,例如 `S02 >> S01 EP+24`、`S03 @ADWeb @MWeb >> S01 EP`、`S01E13 >> S00E01`、`S01 @某组 >> 忽略`;作品页「集数映射」里改的是同一份。 +- 识别词编辑器有「可视化」和「文本」两种方式,随时切换不丢内容:可视化里一行一张卡,选类型填字段、开关停用、拖动排序、写备注,卡片下面拿近期资源试这一行;文本里有行号、语法着色,写错的行标红。改完先试算(哪些资源会变、哪些仍对不上季表、哪一行写错了),确认再保存。 +- 全局识别词保存后在后台把标题命中改动行的资源重新识别,页签里能看进度。 + +### 调整 +- 作品原来的结构化集数映射规则自动转成作品识别词:同一季同一换算的发布组合成一行,停用的规则变成注释行。 +- 识别词照写的用:算出来的集数对不上作品季表时,资源保持集数待确认、在预览里标「对不上」,不再自动停用规则(原来的「映射规则算出的集数对不上作品季表,规则已停用」没有了)。要停用某一行,把它注释掉或在可视化里关掉开关。 +- 集数待确认的「采用建议」「忽略」和智能体写规则,都是往作品识别词末尾加一行,上面一行注释写清楚来历;用户注释掉过同范围的行,智能体就不再自动写。 +- 发布组比较不分繁简:`綠茶字幕組` 的规则也认 `绿茶字幕组` 的资源。 + ## [0.3.42] - 2026-10-08 ### 修复 diff --git a/README.md b/README.md index adaeffb7..0978530a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ - 浏览器:camofox BrowserProvider、原站视图(截图 + 坐标输入)、接管租约、下载捕获走统一提交。 - 运维:管理员登录(TOTP / Passkey)、API Key 分级、主密钥轮换、定时任务看板、统一任务页、运行状态页、审计、备份与只读对账恢复。 -- 集数映射:识别后按作品季表校验季集号(元数据滞后放行),绝对集号 / 播出日期确定性换算,作用域为作品 + 季 + 发布组的映射规则,模拟预览,待确认进待处理,智能体兜底写规则(写入前强制模拟)。 +- 集数映射:识别后按作品季表校验季集号(元数据滞后放行),绝对集号 / 播出日期确定性换算,识别词(全局识别词与 MoviePilot 自定义识别词同语法,作品识别词写季集换算,可视化 / 文本两种编辑、保存前试算),待确认按组显示原因,智能体兜底把一行写进作品识别词(写入前强制模拟)。 未做:综艺正片覆盖、双版本组合校验、旧库分范围接管、Agent 偏好选片、规则学习与回放、Emby 库存参与决策。 diff --git a/apps/server/src/modules/acquisition-organize/routes/rules.ts b/apps/server/src/modules/acquisition-organize/routes/rules.ts index 3712dbb2..a51dd5cb 100644 --- a/apps/server/src/modules/acquisition-organize/routes/rules.ts +++ b/apps/server/src/modules/acquisition-organize/routes/rules.ts @@ -7,11 +7,12 @@ import { type PreferenceTag, } from "@umt/contracts"; import { type DB, type PreferenceSuppressionRow, type RuleGroupRow, sql, type Transaction } from "@umt/db"; -import { evaluateCandidate, parseRelease, type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain"; +import { evaluateCandidate, type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain"; import { slotSpecFromRow } from "@umt/mapping"; import type { Context } from "hono"; import type { AuthEnv } from "../../../http/auth.ts"; import { ApiException } from "../../../http/errors.ts"; +import { titleParser } from "../../sources-identity/service/identify-words-cache.ts"; import type { ModuleApp } from "../../types.ts"; import type { AoDeps, Exec } from "../services/common.ts"; import { normalizeGroupInput } from "../services/rules.ts"; @@ -811,9 +812,10 @@ export async function previewPolicy(d: AoDeps, rules: unknown) { decision: string; reasons: string[]; }[] = []; + const parse = await titleParser(d.ctx.db); for (const rel of rels) { const parsed = { - ...parseRelease(rel.title, rel.subtitle ? { subtitle: rel.subtitle } : {}), + ...parse(rel.title, rel.subtitle), ...(rel.size_bytes !== null ? { sizeBytes: rel.size_bytes } : {}), }; for (const t of templates) { diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index df0ddec0..93605659 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -35,6 +35,7 @@ import { import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping"; import { withAttemptToken } from "@umt/tasks"; import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts"; +import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts"; import { emitMediaWatched } from "../../sources-identity/service/lifecycle-events.ts"; import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts"; import { errMessage, jsonb } from "../util.ts"; @@ -742,12 +743,15 @@ async function selectPackFiles( : 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 words = await wordsOption(d.ctx.db); + const relRaw = mapEpisodes + ? parseRelease(rel.title, { ...words, ...(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); - let p = parseReleasePath(f.name); + let p = parseReleasePath(f.name, words); let special = false; if (mapEpisodes && kind === "video" && p.episodes) { if ((p.season === undefined || p.seasonInferred) && relSeason !== undefined) { diff --git a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts index a46e18f8..8f065a00 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts @@ -42,6 +42,7 @@ import { settleEpisodeDecision, } from "../../sources-identity/service/episode-resolution.ts"; import type { Layout } from "../../sources-identity/service/episode-structure.ts"; +import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts"; import { errMessage, hashJson, jsonb, toApi } from "../util.ts"; import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts"; import { classifyEntity } from "./classification.ts"; @@ -669,9 +670,12 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise !overrideFor(req.fileOverrides, f.path) && fileKindOf(basename(f.path)) === "video"), ); + const words = await wordsOption(d.ctx.db); for (const f of files) { - let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot)); + let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot), words); if (parsed.fileKind !== "video") continue; + // 全局识别词改了文件名里的季集时,命名器(按原文件名再解析)要拿覆盖值,和不带识别词的解析比 + const plain = parsed.words ? parseReleasePath(segmentsWithin(f.path, sourceRoot)) : parsed; const wrongTitle = mismatched.get(f.path); if (wrongTitle !== undefined) { mappedOverrides[f.path] = { exclude: { reason: "title_mismatch", detail: wrongTitle } }; @@ -699,10 +703,15 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise; /** organization_record.plan 的冻结内容 */ diff --git a/apps/server/src/modules/acquisition-organize/services/rules.ts b/apps/server/src/modules/acquisition-organize/services/rules.ts index b635004a..f005c003 100644 --- a/apps/server/src/modules/acquisition-organize/services/rules.ts +++ b/apps/server/src/modules/acquisition-organize/services/rules.ts @@ -19,7 +19,6 @@ import { normalizeExpr, type ParsedRelease, parseExpr, - parseRelease, parseRuleString, type RuleExpr, type RuleGroupSpec, @@ -32,6 +31,7 @@ import { validateKeywordPattern, } from "@umt/domain"; import { ApiException } from "../../../http/errors.ts"; +import { titleParser } from "../../sources-identity/service/identify-words-cache.ts"; import { hashJson, toApi } from "../util.ts"; import type { AoDeps, Exec } from "./common.ts"; @@ -313,6 +313,7 @@ export async function previewRuleGroup( if (body.draft.ladderRequired !== undefined) spec.ladderRequired = body.draft.ladderRequired; } const catalog = catalogOf(cat.atoms); + const parse = await titleParser(db); const samples: { id: string | null; title: string; @@ -325,7 +326,7 @@ export async function previewRuleGroup( id: null, title: t.title, subtitle: t.subtitle ?? null, - parsed: parseRelease(t.title, t.subtitle ? { subtitle: t.subtitle } : {}), + parsed: parse(t.title, t.subtitle), ctx: t.subtitle ? { subtitle: t.subtitle } : {}, }); // 指定资源 → 只取这些;否则取最近 N 条(只粘贴标题且没给 limit 时不取) diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index feb4b43d..2fbb5496 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -37,6 +37,7 @@ import { createEntityEpisodeMapper, type EntityEpisodeMapper, } from "../../sources-identity/service/episode-resolution.ts"; +import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts"; import { errMessage, jsonb } from "../util.ts"; import type { AoDeps } from "./common.ts"; import { emitScrapeFinished } from "./lifecycle-events.ts"; @@ -1045,6 +1046,7 @@ export async function runScrapeRecord( const dated = new Map(); // 集数映射(批次 3):未登记的文件按本地剧集作品的季表 + 规则把文件名季集换成规范季集再对集元数据; // 综艺按播出日期对上季表里的集 + const words = await wordsOption(d.ctx.db); for (const v of lay.videos) { if (kind === "movie") { videos.push({ path: v }); @@ -1055,7 +1057,7 @@ export async function runScrapeRecord( videos.push({ path: v, season: cov.season, episode: cov.episode, episodes: cov.episodes }); continue; } - const p = parseReleasePath(v); + const p = parseReleasePath(v, words); let season = p.season ?? lay.dirSeason ?? seasonOfDirName(baseOf(dirOf(v))) ?? (p.episodes ? 1 : null); let range = p.episodes ?? null; if (mapEpisodes && (p.episodes || p.airDate)) { diff --git a/apps/server/src/modules/acquisition-organize/services/takeover.ts b/apps/server/src/modules/acquisition-organize/services/takeover.ts index 03b5c533..04f72bef 100644 --- a/apps/server/src/modules/acquisition-organize/services/takeover.ts +++ b/apps/server/src/modules/acquisition-organize/services/takeover.ts @@ -6,12 +6,14 @@ import { mergeParsed, type OrganizePlan, type ParsedRelease, + type ParseOptions, parseRelease, parseReleasePath, } from "@umt/domain"; import { vstatSafe as statSafe } from "../../../storage/vfs.ts"; import type { DecisionHook, DecisionHookActor, DecisionResolveBody } from "../../decision-hooks.ts"; import { addAliasIfAbsent } from "../../sources-identity/service/entities.ts"; +import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts"; import type { NameIdentifyResult } from "../ports.ts"; import { jsonb } from "../util.ts"; import { type AoDeps, type Exec, ensureTaskRow, hostPathOf, loadInstances, markTask } from "./common.ts"; @@ -42,10 +44,14 @@ export function pickMainVideo(files: readonly QbFileLike[]): string | null { * 种子名 + 主视频文件名 → 解析结果。主视频路径(qB 文件名含种子根目录)按 文件 → 上级 → 上上级 目录合并; * 种子名作为再上一级兜底(补标题 / 年份 / 季);文件名解析不出标题时直接用种子名。 */ -export function takeoverParsed(torrentName: string, mainFile: string | null): ParsedRelease { - const byName = parseRelease(torrentName, { isFile: fileKindOf(torrentName) !== undefined }); +export function takeoverParsed( + torrentName: string, + mainFile: string | null, + opts: Pick = {}, +): ParsedRelease { + const byName = parseRelease(torrentName, { ...opts, isFile: fileKindOf(torrentName) !== undefined }); if (!mainFile) return byName; - const byPath = parseReleasePath(mainFile); + const byPath = parseReleasePath(mainFile, opts); if (!byPath.title || byPath.auxiliaryOnly) return byName; // 多文件种子:根目录就是种子名,parseReleasePath 已合并过;单文件种子文件名≈种子名,再合并一次无害 return mergeParsed(byPath, byName, "parent_dir"); @@ -65,7 +71,7 @@ export async function identifyTakeover( files: readonly QbFileLike[], ): Promise { const mainFile = pickMainVideo(files); - const parsed = takeoverParsed(dt.name, mainFile); + const parsed = takeoverParsed(dt.name, mainFile, await wordsOption(d.ctx.db)); const result = await d.ports.identify({ parsed, evidence: { downloadTaskId: dt.id, torrentName: dt.name, mainFile }, diff --git a/apps/server/src/modules/agent/investigate.ts b/apps/server/src/modules/agent/investigate.ts index 17ca1ece..57f938e7 100644 --- a/apps/server/src/modules/agent/investigate.ts +++ b/apps/server/src/modules/agent/investigate.ts @@ -142,7 +142,7 @@ function suggestionFor(d: DecisionRow, reason: string): string { if (d.kind === "identity_ambiguous") return `${reason}。可在媒体库「未识别」里手动指定作品,或为 identity.resolve 发放持续授权。`; if (d.kind === "episode_mapping") - return `${reason}。智能体模拟通过的规则会作为建议留在作品的集数映射里,可在那里确认;或为 episode_mapping.apply_rule 发放持续授权。`; + return `${reason}。智能体模拟通过的那一行作品识别词会作为建议留在作品的集数映射里,可在那里确认;或为 episode_mapping.apply_rule 发放持续授权。`; return ( `${reason}。可在下载详情里「强制重新校验」(download.recheck),或为 download.recheck / task.retry 发放持续授权;` + `换源、删除需人工决定。` @@ -255,11 +255,11 @@ export async function autoInvestigate( ep ? `作品 ${String(ep.mediaEntityId ?? d.subject_id ?? "")},来源季 ${ep.season ?? "未知"},发布组 ${ep.releaseGroup ?? "无"}。` + (mode === "recover" - ? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试草稿规则;" + - "模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 写一次规则(带理由);" + - "拿不准就不写,只把模拟通过的规则留作建议。不逐集处理,是否解决由系统核实。" - : "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试草稿规则;" + - "模拟通过的规则会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。") + ? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试一行作品识别词(如 S02 @发布组 >> S01 EP+12);" + + "模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 把这一行写进作品识别词一次(带理由);" + + "拿不准就不写,只把模拟通过的那一行留作建议。不逐集处理,是否解决由系统核实。" + : "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试一行作品识别词;" + + "模拟通过的那一行会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。") : mode === "recover" ? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" + "task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。" @@ -469,7 +469,7 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[ facts[`decision:${id}`] = d ? { status: d.status } : null; states.push(!d ? "failed" : d.status === "resolved" ? "recovered" : "pending"); } else if (a.capabilityId === "episode_mapping.apply_rule") { - // 事项内资源都已不是待确认(规则写入后的重算会把事项自动关闭)即视为恢复 + // 事项内资源都已不是待确认(写进作品识别词后的重算会把事项自动关闭)即视为恢复 const id = String(a.input.decisionId ?? ""); const d = id ? await ctx.db.selectFrom("decision").select(["status", "evidence"]).where("id", "=", id).executeTakeFirst() diff --git a/apps/server/src/modules/file-manager/identify.ts b/apps/server/src/modules/file-manager/identify.ts index 0842d608..71fddfea 100644 --- a/apps/server/src/modules/file-manager/identify.ts +++ b/apps/server/src/modules/file-manager/identify.ts @@ -24,6 +24,7 @@ import { vlstat as stat, vreaddirents } from "../../storage/vfs.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 { wordsOption } from "../sources-identity/service/identify-words-cache.ts"; import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts"; import { kindOfName } from "./list.ts"; @@ -370,6 +371,7 @@ export async function identifyCore( }; const items: FileIdentifyItem[] = []; + const words = await wordsOption(d.ctx.db); for (const v of videos) { const size = await stat(v.abs) .then((st) => Number(st.size)) @@ -381,7 +383,7 @@ export async function identifyCore( lastStatus: (rec?.last_status ?? null) as OrganizationStatus | null, successfulCount: successfulCounts.get(v.abs) ?? 0, }; - const parsed0 = parseReleasePath(segments(v.rel)); + const parsed0 = parseReleasePath(segments(v.rel), words); const parsed: ParsedRelease = { ...parsed0, ...(parsed0.year === undefined && body.hints?.year ? { year: body.hints.year } : {}), diff --git a/apps/server/src/modules/file-manager/suggest-name.ts b/apps/server/src/modules/file-manager/suggest-name.ts index d923fd25..d81785a3 100644 --- a/apps/server/src/modules/file-manager/suggest-name.ts +++ b/apps/server/src/modules/file-manager/suggest-name.ts @@ -3,6 +3,7 @@ import type { FilesSuggestNameRequest, FilesSuggestNameResult } from "@umt/contr 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 { wordsOption } from "../sources-identity/service/identify-words-cache.ts"; import { FilesError, type FmDeps, resolveIn } from "./common.ts"; // G9 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件/目录渲染名字(与整理用同一引擎与变量来源)。 @@ -31,7 +32,7 @@ export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Pro const r = await resolveIn(d, body.root, body.path); if (r.rel === "") throw new FilesError("bad_request", "登记目录本身不能生成推荐名"); const isDir = !!r.lst?.isDirectory(); - const parsed = parseReleasePath(segmentsOf(r.rel)); + const parsed = parseReleasePath(segmentsOf(r.rel), await wordsOption(db)); let entityId = body.mediaEntityId ?? null; if (!entityId && r.def.libraryRootId) { diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index 5d4d2769..a4ea1758 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -11,8 +11,16 @@ import type { SearchSiteCoverage, } from "@umt/contracts"; import { type SiteRow, sql } from "@umt/db"; -import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, parseRelease } from "@umt/domain"; +import { + applyFacets, + type FacetInput, + normalizeTitle, + type ParsedRelease, + parseFacetParam, + parseRelease, +} from "@umt/domain"; import type { SourcesRuntime } from "../sources-identity/runtime.ts"; +import { titleParser } from "../sources-identity/service/identify-words-cache.ts"; import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts"; import { siteCan } from "../sources-identity/site-kinds.ts"; import { raceAbort } from "./media.ts"; @@ -186,8 +194,9 @@ export class ReleaseFanout { rt.ctx.logger.warn({ err: { message: (e as Error).message }, siteId: site.id }, "搜索结果写入资源表失败"); } await this.resolveUnmatched(rt, [...ids.values()]); + const parse = await titleParser(rt.ctx.db); const items = found.map((r) => { - const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null); + const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null, parse); this.raws.set(base.key, r); return base; }); @@ -251,8 +260,14 @@ function mergeSite(site: SiteRow, parts: SiteResult[]): SiteResult { }; } -function toBase(site: Pick, r: AdapterRelease, sourceReleaseId: string | null): BaseRelease { - const p = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}); +function toBase( + site: Pick, + r: AdapterRelease, + sourceReleaseId: string | null, + parse: (title: string, subtitle?: string | null) => ParsedRelease = (t, s) => + parseRelease(t, s ? { subtitle: s } : {}), +): BaseRelease { + const p = parse(r.title, r.subtitle); const pr = r.promotion; return { key: `${site.id}:${r.siteReleaseId}`, diff --git a/apps/server/src/modules/sources-identity/routes/decisions.ts b/apps/server/src/modules/sources-identity/routes/decisions.ts index 6b4ae783..a7100a3a 100644 --- a/apps/server/src/modules/sources-identity/routes/decisions.ts +++ b/apps/server/src/modules/sources-identity/routes/decisions.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts"; import { sql } from "@umt/db"; -import { type ParsedRelease, parseRelease } from "@umt/domain"; +import type { ParsedRelease } from "@umt/domain"; import { z } from "zod"; import { ApiException } from "../../../http/errors.ts"; import { decisionHookFor } from "../../decision-hooks.ts"; @@ -17,6 +17,7 @@ import { EPISODE_DECISION_CONFIRM_AGENT, EPISODE_DECISION_IGNORE_RULE, } from "../service/episode-resolution.ts"; +import { parseTitle } from "../service/identify-words-cache.ts"; import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts"; import { groupMemberIds } from "../service/identity-decisions.ts"; @@ -166,7 +167,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { .select(["title", "subtitle"]) .where("id", "=", srId) .executeTakeFirst(); - const parsed = r ? parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}) : null; + const parsed = r ? await parseTitle(db(), r.title, r.subtitle) : null; if (parsed && !releaseFitsEntity(parsed, entity)) { kept++; continue; diff --git a/apps/server/src/modules/sources-identity/routes/episode-mapping.ts b/apps/server/src/modules/sources-identity/routes/episode-mapping.ts index 6b03131f..24510a0f 100644 --- a/apps/server/src/modules/sources-identity/routes/episode-mapping.ts +++ b/apps/server/src/modules/sources-identity/routes/episode-mapping.ts @@ -3,28 +3,41 @@ import type { ModuleApp } from "../../types.ts"; import { route } from "../http.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { episodeStructureSeasons, listEpisodeGroups, setEpisodeGroup } from "../service/episode-groups.ts"; +import { previewEpisodeMapping } from "../service/episode-mapping-admin.ts"; import { - createRule, - deleteRule, - listRules, - previewEpisodeMapping, - updateRule, -} from "../service/episode-mapping-admin.ts"; + dryRunEntityWords, + dryRunGlobalWords, + getEntityWords, + getGlobalWords, + listEntityWords, + saveEntityWords, + saveGlobalWords, + tryWordsLine, +} from "../service/identify-words.ts"; -// 集数映射(docs/plan/episode-resolution.md 批次 4):规则增删改(写入后按作品重算)、模拟预览(纯读)。 +// 识别词(docs/design/identify-words.md):全局 / 作品识别词读写、试算、单行示例。 +// 集数映射预览(纯读)。 // 剧集组:作品的季集结构(TMDB 默认季 / 某个剧集组)列表、季卡片预览、切换。 const T = ["episode-mapping"]; const E = Api.episodeMapping; +const W = Api.identifyWords; +const TW = ["identify-words"]; const G = Api.episodeGroups; export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) { const ctx = () => rt().ctx; - route(app, { ...E.listRules, tags: T }, async ({ params }) => ({ items: await listRules(ctx().db, params.id) })); - route(app, { ...E.createRule, tags: T }, ({ params, body, actor }) => createRule(ctx(), params.id, body, actor)); - route(app, { ...E.updateRule, tags: T }, ({ params, body, actor }) => updateRule(ctx(), params.id, body, actor)); - route(app, { ...E.deleteRule, tags: T }, ({ params, actor }) => deleteRule(ctx(), params.id, actor)); + route(app, { ...W.getGlobal, tags: TW }, () => getGlobalWords(ctx().db)); + route(app, { ...W.dryRunGlobal, tags: TW }, ({ body }) => dryRunGlobalWords(ctx().db, body.text)); + route(app, { ...W.saveGlobal, tags: TW }, ({ body, actor }) => saveGlobalWords(ctx(), body, actor)); + route(app, { ...W.listEntities, tags: TW }, ({ query }) => listEntityWords(ctx().db, query.q)); + route(app, { ...W.try, tags: TW }, ({ body }) => tryWordsLine(ctx().db, body)); + route(app, { ...W.getEntity, tags: TW }, ({ params }) => getEntityWords(ctx().db, params.id)); + route(app, { ...W.dryRunEntity, tags: TW }, ({ params, body }) => dryRunEntityWords(ctx().db, params.id, body.text)); + route(app, { ...W.saveEntity, tags: TW }, ({ params, body, actor }) => + saveEntityWords(ctx(), params.id, body, actor), + ); route(app, { ...E.preview, tags: T }, ({ params, body }) => previewEpisodeMapping(ctx().db, params.id, body)); route(app, { ...G.list, tags: T }, ({ params }) => listEpisodeGroups(rt(), params.id)); diff --git a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts index 7f266b58..ea923b2c 100644 --- a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts +++ b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts @@ -1,27 +1,22 @@ -import type { - EpisodeMappingPreview, - EpisodeMappingPreviewGroup, - EpisodeMappingPreviewInput, - EpisodeMappingPreviewItem, - EpisodeMappingRuleCreate, - EpisodeMappingRuleRow, - EpisodeMappingRuleSpec, - EpisodeMappingRuleUpdate, - EpisodeMappingRuleWriteResult, - EpisodeResolutionMethod, - EpisodeResultBrief, - EpisodeSimulationItem, - EpisodeSimulationSummary, - EpisodeSuggestion, - EpisodeValidationState, +import { + compileEntityWords, + type EpisodeMappingPreview, + type EpisodeMappingPreviewGroup, + type EpisodeMappingPreviewInput, + type EpisodeMappingPreviewItem, + type EpisodeResolutionMethod, + type EpisodeResultBrief, + type EpisodeSimulationItem, + type EpisodeSimulationSummary, + type EpisodeSuggestion, + type EpisodeValidationState, + parseEntityWordLine, } from "@umt/contracts"; -import type { DecisionRow, EpisodeMappingRuleEvidenceLevel, EpisodeMappingRuleRow as RuleDbRow } from "@umt/db"; +import type { DecisionRow } from "@umt/db"; import { type EpisodeMappingRule, type EpisodeResolution, - evalEpisodeOffset, - normalizeOffsetExpression, - parseRelease, + groupKey, resolveEpisodes, type SeasonTable, type SuggestSample, @@ -29,7 +24,7 @@ import { } from "@umt/domain"; import { ApiException } from "../../../http/errors.ts"; import type { ServerContext } from "../../types.ts"; -import { jsonb, toApi } from "../rows.ts"; +import { jsonb } from "../rows.ts"; import { type Actor, audit, type Exec } from "./common.ts"; import { EPISODE_DECISION_CONFIRM, @@ -41,289 +36,46 @@ import { publishedAtOf, reresolveEntityEpisodes, sameResolution, + scopeLine, } from "./episode-resolution.ts"; import { entityEpisodeGroup } from "./episode-structure.ts"; +import { appendEntityWords } from "./identify-words.ts"; +import { titleParser } from "./identify-words-cache.ts"; -// 集数映射的接口层(docs/plan/episode-resolution.md 批次 4):规则写入校验、写入后按作品重算、模拟预览、 -// 待处理 episode_mapping 的「确认建议规则 / 忽略并建忽略规则」。判定语义全在 domain,这里只装配与校验。 +// 集数映射的接口层:按作品识别词试算预览、智能体与集数待确认事项共用的「模拟一行」、 +// 集数待确认事项的「确认建议 / 忽略」(把一行写进作品识别词再重算)。判定语义全在 domain,这里只装配。 const EPISODE_KINDS = new Set(["series", "variety"]); const PREVIEW_LIMIT = 100; /** 有季 / 组过滤时多取一些再在内存里筛(季与组要按原标题重解析才知道) */ const PREVIEW_SCAN = 1000; -export interface NormalizedRule { - season: number | null; - release_group: string | null; - kind: EpisodeMappingRuleSpec["kind"]; - expression: string | null; - target_season: number | null; - table_entries: NonNullable | null; - special: NonNullable | null; -} - const bad = (msg: string) => new ApiException("bad_request", msg); -/** 校验并规范化规则内容:按 kind 只保留对应字段;表达式要能解析,表格区间要合法且不重叠 */ -export function normalizeRuleSpec(spec: EpisodeMappingRuleSpec): NormalizedRule { - const group = spec.releaseGroup?.trim() || null; - const out: NormalizedRule = { - season: spec.season ?? null, - release_group: group, - kind: spec.kind, - expression: null, - target_season: null, - table_entries: null, - special: null, - }; - if (spec.kind === "expression") { - const raw = spec.expression?.trim(); - if (!raw) throw bad("偏移表达式不能为空"); - let expr: string; - try { - expr = normalizeOffsetExpression(raw); - evalEpisodeOffset(expr, 1); - } catch (e) { - throw bad(`偏移表达式无法解析:${(e as Error).message}`); - } - out.expression = expr; - out.target_season = spec.targetSeason ?? null; - } else if (spec.kind === "table") { - const entries = spec.tableEntries ?? []; - if (!entries.length) throw bad("表格规则至少要有一行"); - entries.forEach((e, i) => { - const row = `第 ${i + 1} 行`; - if (e.from.start < 0 || e.from.end < e.from.start) throw bad(`表格${row}:来源集号区间不合法`); - if (e.to.season < 0 || e.to.episode < 1) throw bad(`表格${row}:目标季集不合法`); - }); - const sorted = [...entries].sort((a, b) => a.from.start - b.from.start); - for (let i = 1; i < sorted.length; i++) { - if (sorted[i]!.from.start <= sorted[i - 1]!.from.end) - throw bad(`表格里的来源区间 ${sorted[i - 1]!.from.start}-${sorted[i - 1]!.from.end} 与后一行重叠`); - } - out.table_entries = entries; - } else { - if (!spec.special) throw bad("要选映射成特别篇、花絮还是忽略"); - out.special = spec.special; - } - return out; -} - -const scopeText = (season: number | null, group: string | null) => - `${season === null ? "任意季" : `第 ${season} 季`} · ${group ?? "任意发布组"}`; - -/** 同作品 + 季 + 发布组(忽略大小写)已有启用的规则 → conflict */ -export async function assertScopeFree( - exec: Exec, - entityId: string, - season: number | null, - group: string | null, - exceptId: string | null, -): Promise { - let q = exec - .selectFrom("episode_mapping_rule") - .select(["id", "release_group"]) - .where("media_entity_id", "=", entityId) - .where("status", "=", "active"); - q = season === null ? q.where("season", "is", null) : q.where("season", "=", season); - if (exceptId) q = q.where("id", "<>", exceptId); - const rows = await q.execute(); - const hit = rows.find((r) => (r.release_group?.trim().toLowerCase() || null) === (group?.toLowerCase() ?? null)); - if (hit) throw new ApiException("conflict", `「${scopeText(season, group)}」已有一条启用的规则,先停用或修改那一条`); -} - -const isUniqueViolation = (e: unknown) => (e as { code?: string }).code === "23505"; - -async function loadEpisodicEntity(exec: Exec, entityId: string) { +export async function loadEpisodicEntity(exec: Exec, entityId: string) { const e = await exec .selectFrom("media_entity") - .select(["id", "kind", "status", "merged_into_id"]) + .select(["id", "kind", "status", "merged_into_id", "title"]) .where("id", "=", entityId) .executeTakeFirst(); if (!e) throw new ApiException("not_found", "作品不存在"); return e; } -export async function listRules(exec: Exec, entityId: string): Promise { - await loadEpisodicEntity(exec, entityId); - const rows = await exec - .selectFrom("episode_mapping_rule") - .selectAll() - .where("media_entity_id", "=", entityId) - .orderBy("status") - .orderBy("created_at", "desc") - .execute(); - return rows.map((r) => toApi(r)); -} - -/** 写入一条规则(不重算,调用方在同一事务里接着重算);证据等级缺省 manual(人工建 / 人工确认) */ -export async function insertRule( - exec: Exec, - entityId: string, - n: NormalizedRule, - actor: Actor, - evidence: Record, - evidenceLevel: EpisodeMappingRuleEvidenceLevel = "manual", -): Promise { - await assertScopeFree(exec, entityId, n.season, n.release_group, null); - const row = await exec - .insertInto("episode_mapping_rule") - .values({ - media_entity_id: entityId, - ...n, - table_entries: n.table_entries ? jsonb(n.table_entries) : null, - evidence: jsonb(evidence), - evidence_level: evidenceLevel, - created_by_type: actor.type, - created_by_id: actor.id, - }) - .returningAll() - .executeTakeFirstOrThrow(); - await audit( - exec, - actor, - "episode_mapping_rule.created", - { type: "episode_mapping_rule", id: row.id }, - { - mediaEntityId: entityId, - ...n, - }, - ); - return row; -} - -export async function withConflict(fn: () => Promise): Promise { - try { - return await fn(); - } catch (e) { - if (isUniqueViolation(e)) throw new ApiException("conflict", "同一季和发布组已有一条启用的规则"); - throw e; - } -} - -export async function createRule( - ctx: ServerContext, - entityId: string, - body: EpisodeMappingRuleCreate, - actor: Actor, -): Promise { - const entity = await loadEpisodicEntity(ctx.db, entityId); - if (!EPISODE_KINDS.has(entity.kind)) throw bad("只有剧集和综艺能设置集数映射"); - const n = normalizeRuleSpec(body); - return withConflict(() => - ctx.db.transaction().execute(async (trx) => { - const row = await insertRule(trx, entityId, n, actor, body.evidence ?? {}); - const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId); - return { rule: toApi(await reloadRule(trx, row.id)), reresolve }; - }), - ); -} - -const reloadRule = (exec: Exec, id: string) => - exec.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); - -function specOfRow(r: RuleDbRow): EpisodeMappingRuleSpec { - return { - season: r.season, - releaseGroup: r.release_group, - kind: r.kind, - expression: r.expression, - targetSeason: r.target_season, - tableEntries: (r.table_entries as EpisodeMappingRuleSpec["tableEntries"]) ?? null, - special: r.special, - }; -} - -export async function updateRule( - ctx: ServerContext, - id: string, - body: EpisodeMappingRuleUpdate, - actor: Actor, -): Promise { - const cur = await ctx.db.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirst(); - if (!cur) throw new ApiException("not_found", "映射规则不存在"); - const { status, ...patch } = body; - const merged: EpisodeMappingRuleSpec = { ...specOfRow(cur) }; - for (const [k, v] of Object.entries(patch)) if (v !== undefined) (merged as Record)[k] = v; - const n = normalizeRuleSpec(merged); - const nextStatus = status ?? cur.status; - return withConflict(() => - ctx.db.transaction().execute(async (trx) => { - if (nextStatus === "active") await assertScopeFree(trx, cur.media_entity_id, n.season, n.release_group, id); - const disabledReason = - nextStatus === "active" - ? null - : status === "disabled" && cur.status === "active" - ? JSON.stringify({ manual: true, by: `${actor.type}:${actor.id ?? ""}`, at: new Date().toISOString() }) - : cur.disabled_reason; - await trx - .updateTable("episode_mapping_rule") - .set({ - ...n, - table_entries: n.table_entries ? jsonb(n.table_entries) : null, - status: nextStatus, - disabled_reason: disabledReason, - updated_at: new Date(), - }) - .where("id", "=", id) - .execute(); - await audit( - trx, - actor, - "episode_mapping_rule.updated", - { type: "episode_mapping_rule", id }, - { - mediaEntityId: cur.media_entity_id, - status: nextStatus, - ...n, - }, - ); - const reresolve = await reresolveEntityEpisodes(ctx, trx, cur.media_entity_id); - return { rule: toApi(await reloadRule(trx, id)), reresolve }; - }), - ); -} - -export async function deleteRule(ctx: ServerContext, id: string, actor: Actor): Promise { - return ctx.db.transaction().execute(async (trx) => { - const cur = await trx.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirst(); - if (!cur) throw new ApiException("not_found", "映射规则不存在"); - await trx.deleteFrom("episode_mapping_rule").where("id", "=", id).execute(); - await audit( - trx, - actor, - "episode_mapping_rule.deleted", - { type: "episode_mapping_rule", id }, - { - mediaEntityId: cur.media_entity_id, - ...specOfRow(cur), - }, - ); - const reresolve = await reresolveEntityEpisodes(ctx, trx, cur.media_entity_id); - return { rule: toApi(cur), reresolve }; - }); +/** 一行作品识别词 → 规则;写错或不是规则行时报 400 */ +export function rulesOfLine(line: string): EpisodeMappingRule[] { + const p = parseEntityWordLine(line); + if (p.kind === "blank" || p.kind === "comment") throw bad("要写一行规则,如 S02 >> S01 EP+12"); + if (p.error) throw bad(`这一行写法不对:${p.error}`); + return compileEntityWords(line).rules; } // ── 模拟预览 ── -const DRAFT_ID = "draft"; - -export function domainRule(n: NormalizedRule, id: string): EpisodeMappingRule { - return { - id, - season: n.season, - releaseGroup: n.release_group, - kind: n.kind, - ...(n.expression !== null ? { expression: n.expression } : {}), - ...(n.target_season !== null ? { targetSeason: n.target_season } : {}), - ...(n.table_entries ? { table: n.table_entries } : {}), - ...(n.special !== null ? { special: n.special } : {}), - }; -} - +/** 规则范围相同(季 + 发布组,发布组按 groupKey 比较) */ export const sameScope = (a: EpisodeMappingRule, b: EpisodeMappingRule) => a.season === b.season && - (a.releaseGroup?.trim().toLowerCase() ?? null) === (b.releaseGroup?.trim().toLowerCase() ?? null); + (a.releaseGroup ? groupKey(a.releaseGroup) : null) === (b.releaseGroup ? groupKey(b.releaseGroup) : null); export function validationOf(r: EpisodeResolution): EpisodeValidationState { if (r.special) return "special"; @@ -372,10 +124,15 @@ function groupSuggestion( return s ? { season, releaseGroup, ...s } : null; } +/** + * 预览:近期资源按原标题(套全局识别词)重解析,用作品识别词(input.words 是草稿原文;extraRules 追加一行试算)重算。 + * 纯读,不写库。 + */ export async function previewEpisodeMapping( exec: Exec, entityId: string, input: EpisodeMappingPreviewInput, + extraRules: readonly EpisodeMappingRule[] = [], ): Promise { const entity = await loadEpisodicEntity(exec, entityId); const table = await loadSeasonTable(exec, entityId); @@ -383,12 +140,8 @@ export async function previewEpisodeMapping( const seasons = seasonSummary(table, group ? { groupId: group.id, name: group.name } : null); if (!EPISODE_KINDS.has(entity.kind)) return { applicable: false, seasons, groups: [], items: [], changed: 0 }; - let rules = await loadRules(exec, entityId); - if (input.replaceRuleId) rules = rules.filter((r) => r.id !== input.replaceRuleId); - if (input.draft) { - const draft = domainRule(normalizeRuleSpec(input.draft), DRAFT_ID); - rules = [...rules.filter((r) => !sameScope(r, draft)), draft]; - } + let rules = input.words !== undefined ? compileEntityWords(input.words).rules : await loadRules(exec, entityId); + if (extraRules.length) rules = [...rules.filter((r) => !extraRules.some((x) => sameScope(r, x))), ...extraRules]; const limit = input.limit ?? PREVIEW_LIMIT; const filtered = input.season != null || !!input.releaseGroup?.trim(); @@ -403,13 +156,14 @@ export async function previewEpisodeMapping( .limit(filtered ? PREVIEW_SCAN : limit) .execute(); - const wantGroup = input.releaseGroup?.trim().toLowerCase() || null; + const wantGroup = input.releaseGroup?.trim() ? groupKey(input.releaseGroup) : null; + const parse = await titleParser(exec); const items: (EpisodeMappingPreviewItem & { preview: EpisodeResolution })[] = []; for (const row of rows) { if (items.length >= limit) break; - const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {}); + const raw = parse(row.title, row.subtitle); const group = raw.releaseGroup?.name ?? null; - if (wantGroup && group?.toLowerCase() !== wantGroup) continue; + if (wantGroup && (group ? groupKey(group) : null) !== wantGroup) continue; const publishedAt = publishedAtOf(row).toISOString(); const preview = resolveEpisodes({ parsed: raw, kind: entity.kind, table, rules, publishedAt }); const current = (row.episode_resolution as EpisodeResolution | null) ?? null; @@ -425,6 +179,7 @@ export async function previewEpisodeMapping( releaseGroup: group, current, preview, + words: raw.words?.applied ?? [], validation: validationOf(preview), changed: !sameResolution(current, preview), }); @@ -432,7 +187,7 @@ export async function previewEpisodeMapping( const byGroup = new Map(); for (const it of items) { - const key = it.releaseGroup?.toLowerCase() ?? ""; + const key = it.releaseGroup ? groupKey(it.releaseGroup) : ""; const g = byGroup.get(key) ?? { name: it.releaseGroup, items: [] }; g.items.push(it); byGroup.set(key, g); @@ -454,7 +209,7 @@ export async function previewEpisodeMapping( return { applicable: true, seasons, groups, items, changed: items.filter((i) => i.changed).length }; } -// ── 模拟一条草稿规则(批次 5:智能体 simulate / apply_rule、确认智能体建议共用) ── +// ── 模拟一行作品识别词(智能体 simulate / apply_rule、确认智能体建议共用) ── /** 模拟最多扫多少条近期资源(有发布组时按组筛) */ const SIMULATE_LIMIT = 300; @@ -474,26 +229,32 @@ export const briefOf = (r: EpisodeResolution | null | undefined): EpisodeResultB const settled = (r: EpisodeResolution | null) => !!r && r.method !== "unresolved" && r.method !== "skipped"; /** - * 用 previewEpisodeMapping 试算草稿规则(纯读):sampleIds = 事项内资源;没有事项时取范围内当前待确认的资源。 + * 用 previewEpisodeMapping 试算一行作品识别词(纯读;同范围的已有行被它替换):sampleIds = 事项内资源; + * 没有事项时取范围内当前待确认的资源。 * ok = 样本全部解决(非待确认、校验非 fail)且没有让当前已解决的资源变成待确认。 */ export async function simulateEpisodeRule( exec: Exec, entityId: string, - spec: EpisodeMappingRuleSpec, + line: string, sampleIds: readonly string[] | null, ): Promise { - const n = normalizeRuleSpec(spec); - const p = await previewEpisodeMapping(exec, entityId, { - releaseGroup: n.release_group, - draft: spec, - limit: SIMULATE_LIMIT, - }); + const rules = rulesOfLine(line); + const groups = [...new Set(rules.map((r) => r.releaseGroup))]; + const p = await previewEpisodeMapping( + exec, + entityId, + { ...(groups.length === 1 ? { releaseGroup: groups[0] } : {}), limit: SIMULATE_LIMIT }, + rules, + ); if (!p.applicable) throw bad("只有剧集和综艺能设置集数映射"); + const season = rules[0]?.season ?? null; + const groupKeys = new Set(rules.map((r) => (r.releaseGroup ? groupKey(r.releaseGroup) : null))); const inScope = (i: EpisodeMappingPreviewItem) => { const src = i.preview.source; - const season = src.season ?? (src.episodes ? 1 : null); - return n.season === null || season === n.season; + const s = src.season ?? (src.episodes ? 1 : null); + const g = i.releaseGroup ? groupKey(i.releaseGroup) : null; + return (season === null || s === season) && (groupKeys.has(null) || groupKeys.has(g)); }; const ids = new Set( sampleIds ?? p.items.filter((i) => i.current?.method === "unresolved" && inScope(i)).map((i) => i.sourceReleaseId), @@ -549,13 +310,12 @@ export async function simulateEpisodeRule( return { summary, failures, items: out }; } -// ── 待处理 episode_mapping ── +// ── 集数待确认事项 ── /** - * 处理 episode_mapping 事项:确认建议规则(建 expression 规则)/ 确认智能体建议(批次 5,建规则前再强制模拟一次)/ - * 忽略并建 special=ignore 规则。 - * 同一事务里:建规则 → 按用户的选择关闭事项 → 按作品重算(资源解决后不会再挂到已关闭的事项上; - * 仍待确认的资源会另开一条新事项)。 + * 处理 episode_mapping 事项:确认建议规则 / 确认智能体建议(写之前再强制模拟一次)/ 忽略并写忽略规则。 + * 都是把一行追加进作品识别词;同一事务里:写识别词 → 按用户的选择关闭事项 → 按作品重算 + * (资源解决后不会再挂到已关闭的事项上;仍待确认的资源会另开一条新事项)。 */ export async function resolveEpisodeDecision( ctx: ServerContext, @@ -568,79 +328,74 @@ export async function resolveEpisodeDecision( const entityId = ev.mediaEntityId ?? (d.subject_type === "media_entity" ? d.subject_id : null); if (!entityId) throw bad("事项缺少作品"); const scope = { season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null }; - let spec: EpisodeMappingRuleSpec; + let line: string; + let comment: string; if (optionKey === EPISODE_DECISION_CONFIRM) { - if (!ev.suggestion) throw new ApiException("conflict", "这条事项没有建议规则,到作品页的集数映射里手动建"); - spec = { - ...scope, - kind: "expression", - expression: ev.suggestion.expression, - targetSeason: ev.suggestion.targetSeason, - }; + if (!ev.suggestion) throw new ApiException("conflict", "这条事项没有建议规则,到作品页的集数映射里手动写"); + line = scopeLine(scope, { kind: "map", expr: ev.suggestion.expression, targetSeason: ev.suggestion.targetSeason }); + comment = "按发布时间对播出日期推出的建议"; } else if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) { - if (!ev.agentSuggestion?.rule) throw new ApiException("conflict", "这条事项没有智能体建议"); - spec = ev.agentSuggestion.rule; + if (!ev.agentSuggestion?.line) throw new ApiException("conflict", "这条事项没有智能体建议"); + line = ev.agentSuggestion.line; + comment = `智能体建议${ev.agentSuggestion.reasons.length ? `:${ev.agentSuggestion.reasons.join(";")}` : ""}`; } else if (optionKey === EPISODE_DECISION_IGNORE_RULE) { - spec = { ...scope, kind: "special", special: "ignore" }; + line = scopeLine(scope, { kind: "special", special: "ignore" }); + comment = "集数待确认时选了忽略"; } else { throw bad(`未知选项 ${optionKey}`); } - const n = normalizeRuleSpec(spec); + rulesOfLine(line); const by = `${actor.type}:${actor.id ?? ""}`; const status = optionKey === EPISODE_DECISION_IGNORE_RULE ? "dismissed" : "resolved"; - return withConflict(() => - ctx.db.transaction().execute(async (trx) => { - const cur = await trx - .selectFrom("decision") - .selectAll() - .where("id", "=", d.id) - .forUpdate() - .executeTakeFirstOrThrow(); - if (!["open", "investigating", "awaiting_user"].includes(cur.status)) - throw new ApiException("conflict", `事项已是 ${cur.status}`); - let simulation: unknown = null; - if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) { - // 智能体建议可能是一阵子之前模拟的:建规则前按当前资源再模拟一次,不通过就不写 - const ids = ((cur.evidence ?? {}) as { sourceReleaseIds?: string[] }).sourceReleaseIds ?? []; - const sim = await simulateEpisodeRule(trx, entityId, spec, ids); - if (!sim.summary.ok) - throw new ApiException("conflict", `智能体建议的规则现在模拟不通过:${sim.failures.join(";")}`); - simulation = sim.summary; - } - const rule = await insertRule(trx, entityId, n, actor, { - decisionId: d.id, - ...(optionKey === EPISODE_DECISION_CONFIRM ? { suggestion: ev.suggestion } : {}), - ...(optionKey === EPISODE_DECISION_CONFIRM_AGENT - ? { - agentSuggestion: { reasons: ev.agentSuggestion?.reasons ?? [], runId: ev.agentSuggestion?.runId ?? null }, - simulation, - } - : {}), - }); - await trx - .updateTable("decision") - .set({ - status, - resolution: jsonb({ optionKey, note, by, execution: { ruleId: rule.id } }), - resolved_by: by, - resolved_at: new Date(), - }) - .where("id", "=", d.id) - .execute(); - await ctx.outbox.emit( - trx, - "decision.updated", - { decisionId: d.id, status }, - { idempotencyKey: `decision.updated:${d.id}:${status}` }, - ); - await audit(trx, actor, "decision.resolved", { type: "decision", id: d.id }, { optionKey, status }); - const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId); - return trx - .updateTable("decision") - .set({ resolution: jsonb({ optionKey, note, by, execution: { ruleId: rule.id, reresolve } }) }) - .where("id", "=", d.id) - .returningAll() - .executeTakeFirstOrThrow(); - }), - ); + return ctx.db.transaction().execute(async (trx) => { + const cur = await trx + .selectFrom("decision") + .selectAll() + .where("id", "=", d.id) + .forUpdate() + .executeTakeFirstOrThrow(); + if (!["open", "investigating", "awaiting_user"].includes(cur.status)) + throw new ApiException("conflict", `事项已是 ${cur.status}`); + let simulation: unknown = null; + if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) { + // 智能体建议可能是一阵子之前模拟的:写之前按当前资源再模拟一次,不通过就不写 + const ids = ((cur.evidence ?? {}) as { sourceReleaseIds?: string[] }).sourceReleaseIds ?? []; + const sim = await simulateEpisodeRule(trx, entityId, line, ids); + if (!sim.summary.ok) + throw new ApiException("conflict", `智能体建议的规则现在模拟不通过:${sim.failures.join(";")}`); + simulation = sim.summary; + } + const appended = await appendEntityWords(trx, entityId, [line], actor, comment); + await trx + .updateTable("decision") + .set({ + status, + resolution: jsonb({ optionKey, note, by, execution: { line, lineNumber: appended.lineNumber, simulation } }), + resolved_by: by, + resolved_at: new Date(), + }) + .where("id", "=", d.id) + .execute(); + await ctx.outbox.emit( + trx, + "decision.updated", + { decisionId: d.id, status }, + { idempotencyKey: `decision.updated:${d.id}:${status}` }, + ); + await audit(trx, actor, "decision.resolved", { type: "decision", id: d.id }, { optionKey, status, line }); + const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId); + return trx + .updateTable("decision") + .set({ + resolution: jsonb({ + optionKey, + note, + by, + execution: { line, lineNumber: appended.lineNumber, simulation, reresolve }, + }), + }) + .where("id", "=", d.id) + .returningAll() + .executeTakeFirstOrThrow(); + }); } diff --git a/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts b/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts index b71aaa42..5dfc5ce0 100644 --- a/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts +++ b/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts @@ -1,29 +1,25 @@ -import type { - DecisionOption, - EpisodeInspectRelease, - EpisodeMappingApplyRuleInput, - EpisodeMappingApplyRuleOutput, - EpisodeMappingInspectInput, - EpisodeMappingInspectOutput, - EpisodeMappingRuleSpec, - EpisodeMappingSimulateInput, - EpisodeMappingSimulateOutput, - EpisodeSuggestion, +import { + compileEntityWords, + type DecisionOption, + type EpisodeInspectRelease, + type EpisodeMappingApplyRuleInput, + type EpisodeMappingApplyRuleOutput, + type EpisodeMappingInspectInput, + type EpisodeMappingInspectOutput, + type EpisodeMappingSimulateInput, + type EpisodeMappingSimulateOutput, + type EpisodeSuggestion, + parseEntityWordLine, + printEntityWord, + splitLines, } from "@umt/contracts"; import { type DecisionRow, sql } from "@umt/db"; -import { type EpisodeResolution, parseRelease, type SuggestSample, suggestRule } from "@umt/domain"; +import { type EpisodeResolution, groupKey, type ParsedRelease, type SuggestSample, suggestRule } from "@umt/domain"; import { ApiException } from "../../../http/errors.ts"; import type { ServerContext } from "../../types.ts"; import { jsonb } from "../rows.ts"; import type { Actor, Exec } from "./common.ts"; -import { - briefOf, - insertRule, - normalizeRuleSpec, - type RuleSimulation, - simulateEpisodeRule, - withConflict, -} from "./episode-mapping-admin.ts"; +import { briefOf, type RuleSimulation, rulesOfLine, simulateEpisodeRule } from "./episode-mapping-admin.ts"; import { type EpisodeAgentSuggestion, type EpisodeDecisionEvidence, @@ -32,10 +28,13 @@ import { publishedAtOf, reresolveEntityEpisodes, } from "./episode-resolution.ts"; +import { appendEntityWords } from "./identify-words.ts"; +import { titleParser } from "./identify-words-cache.ts"; -// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):确定性流程都失败、开了 episode_mapping 待处理后, -// 智能体 inspect(上下文包)→ simulate(试草稿规则)→ apply_rule(服务端强制再模拟,通过才写规则并按作品重算)。 -// 规则写入后同类资源由规则自动处理,智能体不逐集调用。没有写规则授权时,模拟通过的规则作为「智能体建议」留在事项上。 +// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):确定性流程都失败、开了 episode_mapping 事项后, +// 智能体 inspect(上下文包,含作品识别词原文)→ simulate(试一行作品识别词)→ apply_rule(服务端强制再模拟, +// 通过才把这一行连同理由注释追加进作品识别词,并按作品重算)。写进去之后同类资源由这一行自动处理,智能体不逐集调用。 +// 没有写规则授权时,模拟通过的那一行作为「智能体建议」留在事项上,由用户确认。 const OPEN = ["open", "investigating", "awaiting_user"] as const; const EPISODIC = new Set(["series", "variety"]); @@ -115,8 +114,11 @@ type ReleaseRow = { episode_resolution: unknown; }; -function releaseView(row: ReleaseRow): EpisodeInspectRelease & { publishedAtMs: number } { - const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {}); +function releaseView( + row: ReleaseRow, + parse: (title: string, subtitle?: string | null) => ParsedRelease, +): EpisodeInspectRelease & { publishedAtMs: number } { + const raw = parse(row.title, row.subtitle); const res = (row.episode_resolution as EpisodeResolution | null) ?? null; const published = publishedAtOf(row); return { @@ -162,8 +164,10 @@ export async function inspectEpisodeMapping( .orderBy("id") .limit(NEIGHBOR_SCAN) .execute(); - const group = scope.releaseGroup?.toLowerCase() ?? null; - const views = recent.map(releaseView).filter((v) => (v.releaseGroup?.toLowerCase() ?? null) === group); + const parse = await titleParser(exec); + const view = (r: ReleaseRow) => releaseView(r, parse); + const group = scope.releaseGroup ? groupKey(scope.releaseGroup) : null; + const views = recent.map(view).filter((v) => (v.releaseGroup ? groupKey(v.releaseGroup) : null) === group); let members: ReturnType[]; if (scope.sampleIds) { @@ -175,7 +179,7 @@ export async function inspectEpisodeMapping( .where("id", "in", ids) .execute() : []; - members = rows.map(releaseView); + members = rows.map(view); } else { // 没有事项:这个范围里当前待确认的资源 members = views.filter( @@ -206,31 +210,21 @@ export async function inspectEpisodeMapping( })), })); - const ruleRows = await exec - .selectFrom("episode_mapping_rule") - .selectAll() + const wordsRow = await exec + .selectFrom("identify_words") + .select("text") .where("media_entity_id", "=", scope.entityId) - .orderBy("status") - .orderBy("created_at", "desc") - .execute(); - const rules = ruleRows.map((r) => { - const ev = (r.evidence ?? {}) as { reasons?: unknown }; - return { - id: r.id, - season: r.season, - releaseGroup: r.release_group, - kind: r.kind, - expression: r.expression, - targetSeason: r.target_season, - tableEntries: (r.table_entries as EpisodeMappingRuleSpec["tableEntries"]) ?? null, - special: r.special, - status: r.status, - disabledReason: r.disabled_reason, - evidenceLevel: r.evidence_level, - byAgent: r.created_by_type === "agent", - reasons: Array.isArray(ev.reasons) ? ev.reasons.filter((x): x is string => typeof x === "string") : [], - }; - }); + .executeTakeFirst(); + const wordsText = wordsRow?.text ?? ""; + const words = { + text: wordsText, + lines: compileEntityWords(wordsText).lines.map((l) => ({ + line: l.line, + text: l.text, + kind: l.kind, + error: l.error ?? null, + })), + }; // 确定性建议:事项内待确认样本按发布时间对播出日期(与待处理里的建议同一推导) const samples: SuggestSample[] = members @@ -252,7 +246,6 @@ export async function inspectEpisodeMapping( status: d.status, title: d.title, summary: d.summary, - failedRules: ((d.evidence ?? {}) as { failedRules?: string[] }).failedRules ?? [], } : null, releases: members.slice(0, RELEASE_CAP).map(strip), @@ -260,46 +253,39 @@ export async function inspectEpisodeMapping( seasons, latestSeason: table.latestSeason, ended: table.ended, - rules, + words, suggestion, truncated: { releases: members.length > RELEASE_CAP, neighbors: neighbors.length > RELEASE_CAP }, }; } -/** 草稿规则的季 / 发布组没给(undefined)时用事项的;显式 null 表示任意 */ -function withScope(rule: EpisodeMappingRuleSpec, scope: EpisodeScope): EpisodeMappingRuleSpec { - return { - ...rule, - season: rule.season === undefined ? scope.season : rule.season, - releaseGroup: rule.releaseGroup === undefined ? scope.releaseGroup : rule.releaseGroup, - }; -} - -/** 规范化后的规则内容(回给调用方 / 存进建议) */ -function specOf(rule: EpisodeMappingRuleSpec): EpisodeMappingRuleSpec { - const n = normalizeRuleSpec(rule); - return { - season: n.season, - releaseGroup: n.release_group, - kind: n.kind, - expression: n.expression, - targetSeason: n.target_season, - tableEntries: n.table_entries, - special: n.special, - }; +/** 规范写法的一行(按解析结果重新打印;写错时报 400) */ +function canonicalLine(line: string): string { + rulesOfLine(line); + const p = parseEntityWordLine(line); + return printEntityWord({ + kind: p.kind, + season: p.season ?? null, + groups: p.groups ?? [], + ...(p.targetSeason !== undefined ? { targetSeason: p.targetSeason } : {}), + ...(p.expr !== undefined ? { expr: p.expr } : {}), + ...(p.from ? { from: p.from } : {}), + ...(p.to ? { to: p.to } : {}), + ...(p.special ? { special: p.special } : {}), + }); } /** 模拟通过的规则作为智能体建议写进事项证据,并补「确认智能体建议」选项(不写规则) */ async function recordAgentSuggestion( exec: Exec, d: DecisionRow, - spec: EpisodeMappingRuleSpec, + line: string, reasons: string[], sim: RuleSimulation, runId: string | null, ): Promise { const suggestion: EpisodeAgentSuggestion = { - rule: spec, + line, reasons, simulation: sim.summary, samples: sim.items @@ -333,52 +319,54 @@ async function recordAgentSuggestion( } /** - * episode_mapping.simulate:试草稿规则(复用模拟预览)。recordFor 给了(智能体会话 + 有事项)且模拟通过时, - * 规则与理由作为智能体建议留在事项上。 + * episode_mapping.simulate:试一行作品识别词(复用模拟预览,同范围的已有行被它替换)。record 给了(智能体会话 + 有事项) + * 且模拟通过时,这一行与理由作为智能体建议留在事项上。 */ export async function simulateForAgent( exec: Exec, input: EpisodeMappingSimulateInput, record: { runId: string | null } | null, ): Promise { - const scope = await resolveScope(exec, input, input.rule.season, input.rule.releaseGroup); - const spec = specOf(withScope(input.rule, scope)); - const sim = await simulateEpisodeRule(exec, scope.entityId, spec, scope.sampleIds); + const line = canonicalLine(input.line); + const rule = rulesOfLine(line)[0]!; + const scope = await resolveScope(exec, input, rule.season, rule.releaseGroup); + const sim = await simulateEpisodeRule(exec, scope.entityId, line, scope.sampleIds); let suggestionRecorded = false; if (record && scope.decision && sim.summary.ok) suggestionRecorded = await recordAgentSuggestion( exec, scope.decision, - spec, + line, input.reasons ?? [], sim, record.runId, ); - return { rule: spec, summary: sim.summary, failures: sim.failures, items: sim.items, suggestionRecorded }; + return { line, summary: sim.summary, failures: sim.failures, items: sim.items, suggestionRecorded }; } -async function manuallyDisabled(exec: Exec, entityId: string, spec: EpisodeMappingRuleSpec): Promise { - let q = exec - .selectFrom("episode_mapping_rule") - .select(["release_group", "disabled_reason"]) +/** 作品识别词里有同范围、被注释掉的行 = 用户停用过这个范围的规则 */ +async function disabledByUser(exec: Exec, entityId: string, line: string): Promise { + const row = await exec + .selectFrom("identify_words") + .select("text") .where("media_entity_id", "=", entityId) - .where("status", "=", "disabled"); - q = spec.season == null ? q.where("season", "is", null) : q.where("season", "=", spec.season); - const group = spec.releaseGroup?.trim().toLowerCase() || null; - return (await q.execute()).some((r) => { - if ((r.release_group?.trim().toLowerCase() || null) !== group) return false; - try { - return (JSON.parse(r.disabled_reason ?? "{}") as { manual?: boolean }).manual === true; - } catch { - return false; - } + .executeTakeFirst(); + if (!row) return false; + const want = rulesOfLine(line).map((r) => `${r.season ?? "*"}|${r.releaseGroup ? groupKey(r.releaseGroup) : ""}`); + return splitLines(row.text).some((l) => { + const t = l.trim(); + if (!t.startsWith("#")) return false; + const c = compileEntityWords(t.replace(/^#\s?/, "")); + return ( + !c.errors.length && + c.rules.some((r) => want.includes(`${r.season ?? "*"}|${r.releaseGroup ? groupKey(r.releaseGroup) : ""}`)) + ); }); } /** * episode_mapping.apply_rule:同一事务里锁事项 → 强制模拟(样本全部解决、不让已解决的变待确认,否则拒绝不写)→ - * 写规则(created_by_type = agent;样本 ≥ 3 且全部「对得上」记 multi_evidence,否则 needs_review)→ 按作品重算。 - * 资源都解决时事项由重算自动关闭(批次 2 的摘除逻辑)。 + * 把这一行连同理由注释追加进作品识别词 → 按作品重算。资源都解决时事项由重算自动关闭(批次 2 的摘除逻辑)。 */ export async function applyAgentEpisodeRule( ctx: ServerContext, @@ -386,51 +374,41 @@ export async function applyAgentEpisodeRule( actor: Actor, runId: string | null, ): Promise { - return withConflict(() => - ctx.db.transaction().execute(async (trx) => { - const locked = await trx - .selectFrom("decision") - .selectAll() - .where("id", "=", input.decisionId) - .forUpdate() - .executeTakeFirst(); - if (!locked) throw new ApiException("not_found", "事项不存在"); - if (locked.kind !== "episode_mapping") throw bad("这条事项不是集数待确认"); - if (!(OPEN as readonly string[]).includes(locked.status)) - throw new ApiException("conflict", `事项已是 ${locked.status},不用再写规则`); - const scope = scopeOfDecision(locked); - const spec = specOf(withScope(input.rule, scope)); - // 用户手动停用过同作用域的规则 = 撤销过智能体(或别人)的判断:不再自动写,留给人 - if (await manuallyDisabled(trx, scope.entityId, spec)) - throw new ApiException( - "conflict", - "这个范围的规则被手动停用过,不自动写规则;需要时到作品页的集数映射里手动建", - ); - const sim = await simulateEpisodeRule(trx, scope.entityId, spec, scope.sampleIds); - if (!sim.summary.ok) throw new ApiException("conflict", `模拟没通过,没有写规则:${sim.failures.join(";")}`); - const level = - sim.summary.samples >= 3 && sim.summary.passed === sim.summary.samples ? "multi_evidence" : "needs_review"; - const rule = await insertRule( - trx, - scope.entityId, - normalizeRuleSpec(spec), - actor, - { - decisionId: locked.id, - reasons: input.reasons, - samples: sim.summary.samples, - simulation: sim.summary, - runId, - }, - level, + const line = canonicalLine(input.line); + return ctx.db.transaction().execute(async (trx) => { + const locked = await trx + .selectFrom("decision") + .selectAll() + .where("id", "=", input.decisionId) + .forUpdate() + .executeTakeFirst(); + if (!locked) throw new ApiException("not_found", "事项不存在"); + if (locked.kind !== "episode_mapping") throw bad("这条事项不是集数待确认"); + if (!(OPEN as readonly string[]).includes(locked.status)) + throw new ApiException("conflict", `事项已是 ${locked.status},不用再写规则`); + const scope = scopeOfDecision(locked); + // 用户把同范围的行注释掉过 = 撤销过智能体(或别人)的判断:不再自动写,留给人 + if (await disabledByUser(trx, scope.entityId, line)) + throw new ApiException( + "conflict", + "这个范围的规则在作品识别词里被注释掉了,不自动写;需要时到作品页的集数映射里改", ); - const reresolve = await reresolveEntityEpisodes(ctx, trx, scope.entityId); - const after = await trx - .selectFrom("decision") - .select("status") - .where("id", "=", locked.id) - .executeTakeFirstOrThrow(); - return { ruleId: rule.id, evidenceLevel: level, summary: sim.summary, reresolve, decisionStatus: after.status }; - }), - ); + const sim = await simulateEpisodeRule(trx, scope.entityId, line, scope.sampleIds); + if (!sim.summary.ok) throw new ApiException("conflict", `模拟没通过,没有写规则:${sim.failures.join(";")}`); + const comment = `智能体${runId ? `(会话 ${runId.slice(0, 8)})` : ""}:${input.reasons.join(";")}`; + const r = await appendEntityWords(trx, scope.entityId, [line], actor, comment); + const reresolve = await reresolveEntityEpisodes(ctx, trx, scope.entityId); + const after = await trx + .selectFrom("decision") + .select("status") + .where("id", "=", locked.id) + .executeTakeFirstOrThrow(); + return { + appended: r.appended, + lineNumber: r.lineNumber, + summary: sim.summary, + reresolve, + decisionStatus: after.status, + }; + }); } diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index 0453c9dc..85cbe3b4 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -1,18 +1,17 @@ -import type { - DecisionOption, - EpisodeMappingRuleSpec, - EpisodeResultBrief, - EpisodeSimulationSummary, - EvidenceLevel, - MediaKind, +import { + compileEntityWords, + type DecisionOption, + type EpisodeResultBrief, + type EpisodeSimulationSummary, + type EvidenceLevel, + type MediaKind, + printEntityWord, } from "@umt/contracts"; import { type SourceReleaseRow, sql } from "@umt/db"; import { type EpisodeMappingRule, - type EpisodeMappingTableEntry, type EpisodeResolution, type ParsedRelease, - parseRelease, resolveEpisodes, type SeasonInfo, type SeasonTable, @@ -25,12 +24,14 @@ import { jsonb } from "../rows.ts"; import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { ensureUnits } from "./entities.ts"; import { type Layout, seasonOrigins } from "./episode-structure.ts"; +import { titleParser } from "./identify-words-cache.ts"; import { isEntityWatched } from "./watched.ts"; // 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把 // 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库: // - 季表:季单元 episode_total + 集单元 air_date + 作品 metadata.airStatus(Ended / Canceled 视为完结) -// - 规则:episode_mapping_rule(active);规则算出越界 → 停用(disabled_reason 写证据)+ 待处理 +// - 规则:作品识别词(identify_words,docs/design/identify-words.md)编译出的规则;照写的用,算出来对不上季表的资源 +// 待确认,规则不停用(要停用就在识别词里注释掉那一行) // - 待确认(unresolved):资源照常绑定作品,不建集单元;待处理 episode_mapping 按作品 + 来源季 + 发布组合并, // evidence.sourceReleaseIds 累加,附 suggestRule 的建议;资源后来解决了就从中摘除,摘空即关闭 @@ -112,25 +113,14 @@ function airStatusOf(metadata: unknown): string { return typeof raw === "string" ? raw : ""; } -/** 作品下启用的映射规则 */ +/** 作品识别词编译出的映射规则(注释掉的行、写错的行不参与) */ export async function loadRules(exec: Exec, entityId: string): Promise { - const rows = await exec - .selectFrom("episode_mapping_rule") - .selectAll() + const row = await exec + .selectFrom("identify_words") + .select("text") .where("media_entity_id", "=", entityId) - .where("status", "=", "active") - .orderBy("id") - .execute(); - return rows.map((r) => ({ - id: r.id, - season: r.season, - releaseGroup: r.release_group, - kind: r.kind, - ...(r.expression !== null ? { expression: r.expression } : {}), - ...(r.target_season !== null ? { targetSeason: r.target_season } : {}), - ...(Array.isArray(r.table_entries) ? { table: r.table_entries as unknown as EpisodeMappingTableEntry[] } : {}), - ...(r.special !== null ? { special: r.special } : {}), - })); + .executeTakeFirst(); + return row ? compileEntityWords(row.text).rules : []; } export interface ReleaseEpisodesInput { @@ -310,7 +300,7 @@ export async function createStructureEpisodeMapper( const p = opts.publishedAt; const publishedAt = p instanceof Date ? p.toISOString() : (p ?? null); const byRule = current(parsed, { publishedAt }).resolution; - if (byRule.method === "rule" || byRule.ruleFailed) { + if (byRule.method === "rule" || byRule.ruleId) { if (!byRule.canonical) return { resolution: { ...byRule, evidence: [...byRule.evidence, tag] }, parsed }; const moved = toTarget(byRule.canonical); const resolution: EpisodeResolution = moved @@ -354,7 +344,7 @@ export function unitsAllowed(resolution: EpisodeResolution): boolean { } /** - * 识别绑定时的集数解析:解析 → 规则失效时停用规则 → 按规范结果建单元(待确认 / special 不建)。 + * 识别绑定时的集数解析:解析 → 按规范结果建单元(待确认 / special 不建)。 * 调用方随后把 parsed / episodeResolution / units.primary 写进 source_release,再调 settleEpisodeDecision。 */ export async function prepareReleaseEpisodes( @@ -362,28 +352,12 @@ export async function prepareReleaseEpisodes( input: ReleaseEpisodesInput, ): Promise { const r = await resolveReleaseEpisodes(exec, input); - if (r.resolution.ruleFailed) await disableFailedRule(exec, r.resolution); const units = unitsAllowed(r.resolution) ? await ensureUnits(exec, input.entityId, input.kind, r.parsed) : { unitIds: [], primary: null }; return { ...r, units }; } -async function disableFailedRule(exec: Exec, resolution: EpisodeResolution): Promise { - const src = resolution.source; - const reason = JSON.stringify({ - source: src, - evidence: resolution.evidence, - at: new Date().toISOString(), - }); - await exec - .updateTable("episode_mapping_rule") - .set({ status: "disabled", disabled_reason: reason }) - .where("id", "=", resolution.ruleFailed!) - .where("status", "=", "active") - .execute(); -} - /** 发布时间:站点发布时间,没有用首次发现时间 */ export const publishedAtOf = (r: Pick): Date => r.source_published_at ?? r.first_seen_at; @@ -424,18 +398,14 @@ export async function settleEpisodeDecision( .where("dedupe_key", "=", target) .where("status", "in", [...OPEN]) .executeTakeFirst(); - const prev = (existing?.evidence ?? {}) as { sourceReleaseIds?: string[]; failedRules?: string[] }; + const prev = (existing?.evidence ?? {}) as { sourceReleaseIds?: string[] }; const ids = [...new Set([...(prev.sourceReleaseIds ?? []), sourceReleaseId])]; - const failedRules = [ - ...new Set([...(prev.failedRules ?? []), ...(resolution.ruleFailed ? [resolution.ruleFailed] : [])]), - ]; await writeEpisodeDecision(ctx, exec, { key: target, entityId, season: sourceSeasonOf(resolution), releaseGroup: input.parsed.releaseGroup?.name ?? null, ids, - failedRules, latest: resolution, }); } @@ -446,7 +416,6 @@ interface EpisodeDecisionState { season: number | null; releaseGroup: string | null; ids: string[]; - failedRules: string[]; /** 最近进来的那条资源的解析结果(摘要里的原因) */ latest: EpisodeResolution | null; } @@ -490,7 +459,7 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe const seasonText = s.season === null ? "季未知" : `第 ${s.season} 季`; const groupText = s.releaseGroup ? ` · ${s.releaseGroup}` : ""; const title = `集数待确认:${entity?.title ?? "未知作品"} ${seasonText}${groupText}`; - const summary = `${sourceText(latest)}${reasonText(latest, s.failedRules.length > 0)}${ + const summary = `${sourceText(latest)}${reasonText(latest)}${ releases.length > 1 ? `(共 ${releases.length} 条资源)` : "" }`; await openDecision(ctx, exec, { @@ -513,7 +482,6 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe source: r.res?.source ?? null, evidence: r.res?.evidence ?? [], })), - ...(s.failedRules.length ? { failedRules: s.failedRules } : {}), suggestion, ...kept, }, @@ -529,9 +497,9 @@ export const EPISODE_DECISION_IGNORE_RULE = "ignore_rule"; /** 刷新事项时从旧证据里保留的字段 */ const KEEP_EVIDENCE = ["agentSuggestion", "autoRecover", "agentInvestigation"] as const; -/** 智能体建议(批次 5):模拟通过的草稿规则 + 理由,由用户在待处理里一键确认 */ +/** 智能体建议(批次 5):模拟通过的一行作品识别词 + 理由,用户确认后写进作品识别词 */ export interface EpisodeAgentSuggestion { - rule: EpisodeMappingRuleSpec; + line: string; reasons: string[]; simulation: EpisodeSimulationSummary; /** 样本套用前后(最多 10 条) */ @@ -548,15 +516,24 @@ export interface EpisodeDecisionEvidence { agentSuggestion?: EpisodeAgentSuggestion | null; } -/** 规则内容一句话(选项说明用) */ -export function ruleSpecText(r: EpisodeMappingRuleSpec): string { - if (r.kind === "expression") - return `按 ${r.expression ?? ""}${r.targetSeason != null ? ` 换算到第 ${r.targetSeason} 季` : ""}`; - if (r.kind === "table") return `按对照表(${r.tableEntries?.length ?? 0} 行)`; - return `映射为${r.special === "special" ? "特别篇" : r.special === "extra" ? "花絮" : "忽略"}`; +/** 事项范围(来源季 + 发布组)对应的作品识别词行 */ +export function scopeLine( + scope: { season: number | null; releaseGroup: string | null }, + target: { kind: "map"; expr: string; targetSeason: number | null } | { kind: "special"; special: "ignore" }, +): string { + const groups = scope.releaseGroup ? [scope.releaseGroup] : []; + return target.kind === "map" + ? printEntityWord({ + kind: "map", + season: scope.season, + groups, + expr: target.expr, + targetSeason: target.targetSeason, + }) + : printEntityWord({ kind: "special", season: scope.season, groups, special: target.special }); } -/** 开 / 刷新 episode_mapping 待处理时附的选项(批次 2 的 writeEpisodeDecision 调用) */ +/** 开 / 刷新 episode_mapping 事项时附的选项:每个选项写进作品识别词的那一行放在 input.line */ export function episodeDecisionOptions(ev: EpisodeDecisionEvidence): DecisionOption[] { const scope = { season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null }; const out: DecisionOption[] = []; @@ -564,23 +541,29 @@ export function episodeDecisionOptions(ev: EpisodeDecisionEvidence): DecisionOpt out.push({ key: EPISODE_DECISION_CONFIRM_AGENT, label: "确认智能体建议", - description: `${ruleSpecText(ev.agentSuggestion.rule)},建规则前按当前资源再模拟一次,通过才写入并重算`, - input: { ...ev.agentSuggestion.rule }, + description: `把「${ev.agentSuggestion.line}」写进作品识别词,写之前按当前资源再模拟一次,通过才写并重算`, + input: { line: ev.agentSuggestion.line }, }); } if (ev.suggestion) { + const line = scopeLine(scope, { + kind: "map", + expr: ev.suggestion.expression, + targetSeason: ev.suggestion.targetSeason, + }); out.push({ key: EPISODE_DECISION_CONFIRM, label: "确认建议规则", - description: `按 ${ev.suggestion.expression} 换算到第 ${ev.suggestion.targetSeason} 季,建规则后重算这部作品的资源`, - input: { ...scope, expression: ev.suggestion.expression, targetSeason: ev.suggestion.targetSeason }, + description: `把「${line}」写进作品识别词,再重算这部作品的资源`, + input: { line }, }); } + const ignore = scopeLine(scope, { kind: "special", special: "ignore" }); out.push({ key: EPISODE_DECISION_IGNORE_RULE, - label: "忽略并建忽略规则", - description: "这个发布组这一季的资源以后都不参与集数解析,也不再提醒", - input: scope, + label: "忽略并写忽略规则", + description: `把「${ignore}」写进作品识别词:这个发布组这一季的资源以后都不参与集数解析,也不再提醒`, + input: { line: ignore }, }); return out; } @@ -598,8 +581,8 @@ function sourceText(res: EpisodeResolution | null): string { const pad = (n: number) => String(n).padStart(2, "0"); -function reasonText(res: EpisodeResolution | null, ruleFailed: boolean): string { - if (res?.ruleFailed || (ruleFailed && !res)) return "映射规则算出的集数对不上作品季表,规则已停用"; +function reasonText(res: EpisodeResolution | null): string { + if (res?.ruleText) return `按作品识别词「${res.ruleText}」算出的集数对不上作品季表`; if (res?.evidence.some((e) => e.startsWith("conversion:ambiguous"))) return "换算出多个可能的季集,无法确定"; return "集数超出作品季表,也换算不出对应的季集"; } @@ -622,7 +605,6 @@ async function detachFromEpisodeDecisions( if (d.dedupe_key === keep) continue; const ev = (d.evidence ?? {}) as { sourceReleaseIds?: string[]; - failedRules?: string[]; mediaEntityId?: string; season?: number | null; releaseGroup?: string | null; @@ -639,7 +621,6 @@ async function detachFromEpisodeDecisions( season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null, ids, - failedRules: ev.failedRules ?? [], latest: null, }); } @@ -699,10 +680,11 @@ export async function reresolveEntityEpisodes( const rows = await q.orderBy("first_seen_at").execute(); if (!rows.length) return out; const table = await loadSeasonTable(exec, entityId); - let rules = await loadRules(exec, entityId); + const rules = await loadRules(exec, entityId); + const parse = await titleParser(exec); for (const row of rows) { out.releases++; - const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {}); + const raw = parse(row.title, row.subtitle); const r = await prepareReleaseEpisodes(exec, { entityId, kind: entity.kind, @@ -711,8 +693,6 @@ export async function reresolveEntityEpisodes( table, rules, }); - // 规则刚被停用:后面的资源不再用它 - if (r.resolution.ruleFailed) rules = rules.filter((x) => x.id !== r.resolution.ruleFailed); const before = row.episode_resolution as EpisodeResolution | null; const changed = !sameResolution(before, r.resolution); if (changed) out.changed++; diff --git a/apps/server/src/modules/sources-identity/service/identify-words-cache.ts b/apps/server/src/modules/sources-identity/service/identify-words-cache.ts new file mode 100644 index 00000000..1ea659f9 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/identify-words-cache.ts @@ -0,0 +1,62 @@ +import { + type CompiledGlobalWord, + compileGlobalWords, + type ParsedRelease, + type ParseOptions, + parseRelease, +} from "@umt/domain"; +import type { Exec } from "./common.ts"; + +// 全局识别词的进程内缓存(docs/design/identify-words.md「在哪里生效」):所有按标题 / 文件名识别的地方解析前都要套用。 +// 三个进程各缓存一份,最多 5 秒按版本号核对一次;本进程保存后立即失效。后台重算任务开始前也强制重读。 + +const TTL_MS = 5_000; + +let cached: { at: number; version: number; words: readonly CompiledGlobalWord[] } | null = null; + +/** 当前的全局识别词(已编译) */ +export async function globalWords(exec: Exec): Promise { + if (cached && Date.now() - cached.at < TTL_MS) return cached.words; + const row = await exec + .selectFrom("identify_words") + .select(["text", "version"]) + .where("media_entity_id", "is", null) + .executeTakeFirst(); + const version = row?.version ?? 0; + const words = cached && cached.version === version ? cached.words : compileGlobalWords(row?.text ?? ""); + cached = { at: Date.now(), version, words }; + return words; +} + +/** 保存后、或测试里直接改了表时调用 */ +export function invalidateGlobalWords(): void { + cached = null; +} + +/** 标题 + 副标题按全局识别词解析(资源识别、搜索、站点抓取共用) */ +export async function parseTitle( + exec: Exec, + title: string, + subtitle?: string | null, + opts: Omit = {}, +): Promise { + const words = await globalWords(exec); + return parseRelease(title, { ...opts, ...(subtitle ? { subtitle } : {}), ...(words.length ? { words } : {}) }); +} + +/** 批量解析用:先取一次识别词,返回同步的解析函数 */ +export async function titleParser( + exec: Exec, +): Promise< + (title: string, subtitle?: string | null, opts?: Omit) => ParsedRelease +> { + const words = await globalWords(exec); + return (title, subtitle, opts = {}) => + parseRelease(title, { ...opts, ...(subtitle ? { subtitle } : {}), ...(words.length ? { words } : {}) }); +} + +/** 文件路径解析用的选项(parseReleasePath 的 opts) */ +export async function wordsOption(exec: Exec): Promise> { + const words = await globalWords(exec); + return words.length ? { words } : {}; +} diff --git a/apps/server/src/modules/sources-identity/service/identify-words.ts b/apps/server/src/modules/sources-identity/service/identify-words.ts new file mode 100644 index 00000000..8ed39838 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/identify-words.ts @@ -0,0 +1,567 @@ +import { randomUUID } from "node:crypto"; +import { + compileEntityWords, + type EntityWordsDryRun, + type EntityWordsList, + type EntityWordsSaveResult, + type EntityWordsView, + type GlobalWordsDryRun, + type GlobalWordsDryRunItem, + type GlobalWordsSaveResult, + type IdentifyWordsBlock, + type IdentifyWordsSave, + parseGlobalWords, + splitLines, + type WordsParsedBrief, + type WordsReresolveProgress, + type WordsTryInput, + type WordsTryResult, +} from "@umt/contracts"; +import { type IdentifyWordsRow, sql } from "@umt/db"; +import { + applyGlobalWords, + type CompiledGlobalWord, + compileGlobalWords, + type ParsedRelease, + parseRelease, + resolveEpisodes, + selectRule, + wordTouches, +} from "@umt/domain"; +import { ApiException } from "../../../http/errors.ts"; +import type { ServerContext } from "../../types.ts"; +import { jsonb } from "../rows.ts"; +import { type Actor, audit, type Exec } from "./common.ts"; +import { previewEpisodeMapping, rulesOfLine, validationOf } from "./episode-mapping-admin.ts"; +import { loadSeasonTable, publishedAtOf, reresolveEntityEpisodes } from "./episode-resolution.ts"; +import { globalWords, invalidateGlobalWords } from "./identify-words-cache.ts"; + +// 识别词(docs/design/identify-words.md):全局一块 + 每部作品一块,原文整块存在 identify_words。 +// - 全局识别词(MP 自定义识别词原样):所有按标题 / 文件名识别的地方解析前套用(identify-words-cache.ts); +// 保存后后台扫一遍资源,标题 / 副标题命中改动行(新增或去掉的行)的重新识别,进度写在 reresolve 上 +// - 作品识别词(季集换算):编译成规则给集数解析用;保存后按作品重算 +// 保存都带读到的版本,别人先改过返回 409。 + +export const WORDS_RERESOLVE_TASK = "reresolveIdentifyWords"; + +/** 全局试算扫多少条近期资源、最多列多少条 */ +const DRY_RUN_SCAN = 1000; +const DRY_RUN_ITEMS = 100; +/** 示例:找命中这一行的近期资源时最多扫多少条 */ +const TRY_SCAN = 2000; +/** 后台重算每批资源数 */ +const RERESOLVE_BATCH = 500; +/** 审计里最多记多少行改动 */ +const AUDIT_LINES = 50; + +const bad = (msg: string) => new ApiException("bad_request", msg); +const by = (row: Pick | undefined) => + row?.updated_by_type ? `${row.updated_by_type}:${row.updated_by_id ?? ""}` : null; + +function blockOf(row: IdentifyWordsRow | undefined, entityId: string | null): IdentifyWordsBlock { + return { + mediaEntityId: entityId, + text: row?.text ?? "", + version: row?.version ?? 0, + updatedAt: row?.updated_at ? row.updated_at.toISOString() : null, + updatedBy: by(row), + reresolve: (row?.reresolve as WordsReresolveProgress | null) ?? null, + }; +} + +const rowQuery = (exec: Exec, entityId: string | null) => { + const q = exec.selectFrom("identify_words").selectAll(); + return entityId ? q.where("media_entity_id", "=", entityId) : q.where("media_entity_id", "is", null); +}; + +/** 生效行的增减(全局按原文比较;作品按去掉首尾空白比较) */ +function diffLines(before: string[], after: string[]): { added: string[]; removed: string[] } { + const a = new Set(before); + const b = new Set(after); + return { added: after.filter((l) => !a.has(l)), removed: before.filter((l) => !b.has(l)) }; +} + +/** 参与套用的行(写错的行也算:与 compileGlobalWords 一致) */ +const globalLines = (text: string) => + parseGlobalWords(text) + .filter((w) => w.kind !== "blank" && w.kind !== "comment") + .map((w) => w.text); +const entityLines = (text: string) => + splitLines(text) + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + +/** 写一块识别词(调用方在事务里);版本不符 → 409 */ +async function writeBlock( + trx: Exec, + entityId: string | null, + text: string, + expectedVersion: number | null, + actor: Actor, + extra: { reresolve?: unknown } = {}, +): Promise<{ row: IdentifyWordsRow; before: string }> { + const cur = await rowQuery(trx, entityId).forUpdate().executeTakeFirst(); + const version = cur?.version ?? 0; + if (expectedVersion !== null && expectedVersion !== version) + throw new ApiException("conflict", "识别词已经被别处改过,刷新后再改"); + const values = { + text, + version: version + 1, + updated_by_type: actor.type, + updated_by_id: actor.id, + ...(extra.reresolve !== undefined ? { reresolve: jsonb(extra.reresolve) } : {}), + }; + const row = cur + ? await trx + .updateTable("identify_words") + .set(values) + .where("id", "=", cur.id) + .returningAll() + .executeTakeFirstOrThrow() + : await trx + .insertInto("identify_words") + .values({ media_entity_id: entityId, ...values }) + .returningAll() + .executeTakeFirstOrThrow(); + const before = cur?.text ?? ""; + const d = entityId + ? diffLines(entityLines(before), entityLines(text)) + : diffLines(globalLines(before), globalLines(text)); + await audit( + trx, + actor, + "identify_words.updated", + { type: "identify_words", id: row.id }, + { + mediaEntityId: entityId, + version: row.version, + added: d.added.slice(0, AUDIT_LINES), + removed: d.removed.slice(0, AUDIT_LINES), + }, + ); + return { row, before }; +} + +// ── 全局 ── + +export async function getGlobalWords(exec: Exec): Promise { + return blockOf(await rowQuery(exec, null).executeTakeFirst(), null); +} + +function brief(p: ParsedRelease): WordsParsedBrief { + return { + title: p.title ?? null, + year: p.year ?? null, + season: p.season ?? null, + episodes: p.episodes ?? null, + releaseGroup: p.releaseGroup?.name ?? null, + explicitIds: p.explicitIds.map((x) => ({ provider: x.provider, id: x.id })), + }; +} + +const sameBrief = (a: WordsParsedBrief, b: WordsParsedBrief) => JSON.stringify(a) === JSON.stringify(b); + +const parseWith = (title: string, subtitle: string | null, words: readonly CompiledGlobalWord[]) => + parseRelease(title, { ...(subtitle ? { subtitle } : {}), ...(words.length ? { words } : {}) }); + +/** 试算全局识别词:逐行错误、增减的行、近期资源里识别结果会变的(纯读) */ +export async function dryRunGlobalWords(exec: Exec, text: string): Promise { + const errors = parseGlobalWords(text) + .filter((w) => w.error) + .map((w) => ({ line: w.line, message: w.error! })); + const cur = (await rowQuery(exec, null).executeTakeFirst())?.text ?? ""; + const { added, removed } = diffLines(globalLines(cur), globalLines(text)); + const changed = compileGlobalWords([...added, ...removed].join("\n")); + const out: GlobalWordsDryRun = { errors, added, removed, scanned: 0, matched: 0, items: [] }; + if (!changed.length) return out; + const before = compileGlobalWords(cur); + const after = compileGlobalWords(text); + const rows = await exec + .selectFrom("source_release as r") + .leftJoin("media_entity as e", "e.id", "r.media_entity_id") + .select(["r.id", "r.title", "r.subtitle", "r.media_entity_id", "e.title as entity_title"]) + .orderBy("r.first_seen_at", "desc") + .orderBy("r.id") + .limit(DRY_RUN_SCAN) + .execute(); + for (const r of rows) { + out.scanned++; + if (!changed.some((w) => wordTouches(w, r.title, r.subtitle))) continue; + out.matched++; + const b = parseWith(r.title, r.subtitle, before); + const a = parseWith(r.title, r.subtitle, after); + const item: GlobalWordsDryRunItem = { + sourceReleaseId: r.id, + title: r.title, + subtitle: r.subtitle, + mediaEntityId: r.media_entity_id, + entityTitle: r.entity_title ?? null, + words: a.words?.applied ?? [], + before: brief(b), + after: brief(a), + }; + if (!sameBrief(item.before, item.after) && out.items.length < DRY_RUN_ITEMS) out.items.push(item); + } + return out; +} + +/** 保存全局识别词;生效行有增减时在后台重新识别命中改动行的资源 */ +export async function saveGlobalWords( + ctx: ServerContext, + body: IdentifyWordsSave, + actor: Actor, +): Promise { + const result = await ctx.db.transaction().execute(async (trx) => { + const cur = (await rowQuery(trx, null).executeTakeFirst())?.text ?? ""; + const { added, removed } = diffLines(globalLines(cur), globalLines(body.text)); + let progress: (WordsReresolveProgress & { changed: string[] }) | undefined; + if (added.length || removed.length) { + const total = await trx + .selectFrom("source_release") + .select((eb) => eb.fn.countAll().as("n")) + .executeTakeFirstOrThrow(); + progress = { + status: "queued", + total: Number(total.n), + scanned: 0, + matched: 0, + queued: 0, + startedAt: new Date().toISOString(), + finishedAt: null, + changed: [...added, ...removed], + }; + } + const { row } = await writeBlock(trx, null, body.text, body.expectedVersion, actor, { + ...(progress ? { reresolve: progress } : {}), + }); + return { row, progress }; + }); + invalidateGlobalWords(); + if (result.progress) { + await ctx.engine.enqueue( + WORDS_RERESOLVE_TASK, + { version: result.row.version }, + { idempotencyKey: `${WORDS_RERESOLVE_TASK}:${result.row.version}`, traceId: randomUUID() }, + ); + } + return { block: blockOf(result.row, null) }; +} + +export interface ReresolveState { + last: string | null; + scanned: number; + matched: number; + queued: number; +} + +/** + * 后台重算一批:标题 / 副标题命中改动行的资源重新识别(身份与集数都重来;人工锁定的不动)。 + * 返回 null 表示这一版已被更新的保存取代或已经扫完。 + */ +export async function reresolveWordsBatch( + ctx: ServerContext, + version: number, + state: ReresolveState, +): Promise { + const row = await rowQuery(ctx.db, null).executeTakeFirst(); + if (!row || row.version !== version) return null; + const progress = (row.reresolve ?? {}) as WordsReresolveProgress & { changed?: string[] }; + const changed = compileGlobalWords((progress.changed ?? []).join("\n")); + invalidateGlobalWords(); + let q = ctx.db + .selectFrom("source_release") + .select(["id", "title", "subtitle", "resolution_locked"]) + .orderBy("id") + .limit(RERESOLVE_BATCH); + if (state.last) q = q.where("id", ">", state.last); + const rows = await q.execute(); + const next: ReresolveState = { ...state, last: rows.at(-1)?.id ?? state.last }; + for (const r of rows) { + next.scanned++; + if (!changed.some((w) => wordTouches(w, r.title, r.subtitle))) continue; + next.matched++; + if (r.resolution_locked) continue; + await ctx.engine.enqueue( + "resolveObservation", + { sourceReleaseId: r.id, force: true, trigger: "review" }, + { idempotencyKey: `words:${version}:${r.id}`, traceId: randomUUID() }, + ); + next.queued++; + } + const done = rows.length < RERESOLVE_BATCH; + await ctx.db + .updateTable("identify_words") + .set({ + reresolve: jsonb({ + ...progress, + status: done ? "done" : "running", + scanned: next.scanned, + matched: next.matched, + queued: next.queued, + finishedAt: done ? new Date().toISOString() : null, + }), + }) + .where("id", "=", row.id) + .where("version", "=", version) + .execute(); + return done ? null : next; +} + +/** 后台任务失败时记在进度上 */ +export async function markReresolveFailed(ctx: ServerContext, version: number, error: string): Promise { + const row = await rowQuery(ctx.db, null).executeTakeFirst(); + if (!row || row.version !== version) return; + await ctx.db + .updateTable("identify_words") + .set({ + reresolve: jsonb({ + ...((row.reresolve ?? {}) as object), + status: "failed", + error, + finishedAt: new Date().toISOString(), + }), + }) + .where("id", "=", row.id) + .execute(); +} + +// ── 作品 ── + +const EPISODIC = new Set(["series", "variety"]); + +async function loadEntity(exec: Exec, entityId: string) { + const e = await exec + .selectFrom("media_entity") + .select(["id", "title", "kind"]) + .where("id", "=", entityId) + .executeTakeFirst(); + if (!e) throw new ApiException("not_found", "作品不存在"); + return e; +} + +export async function listEntityWords(exec: Exec, q: string | undefined): Promise { + let query = exec + .selectFrom("identify_words as w") + .innerJoin("media_entity as e", "e.id", "w.media_entity_id") + .select([ + "w.media_entity_id", + "w.text", + "w.updated_at", + "w.updated_by_type", + "w.updated_by_id", + "e.title", + "e.year", + ]) + .where("w.media_entity_id", "is not", null) + .where("w.text", "<>", "") + .orderBy("w.updated_at", "desc"); + if (q?.trim()) query = query.where("e.title", "ilike", `%${q.trim().replace(/[%_\\]/g, (c) => `\\${c}`)}%`); + const rows = await query.limit(500).execute(); + return { + items: rows.map((r) => { + const c = compileEntityWords(r.text); + const disabled = c.lines.filter((l) => l.kind === "comment" && isCommentedRule(l.text)).length; + return { + mediaEntityId: r.media_entity_id!, + title: r.title, + year: r.year, + rules: c.lines.filter((l) => l.kind !== "blank" && l.kind !== "comment" && !l.error).length, + disabled, + errors: c.errors.length, + updatedAt: r.updated_at.toISOString(), + updatedBy: by(r), + }; + }), + }; +} + +/** 注释行的内容是一行合法的作品识别词(停用的规则) */ +function isCommentedRule(line: string): boolean { + const body = line.trimStart().replace(/^#\s?/, ""); + const c = compileEntityWords(body); + return c.rules.length > 0 && c.errors.length === 0; +} + +export async function getEntityWords(exec: Exec, entityId: string): Promise { + const e = await loadEntity(exec, entityId); + const row = await rowQuery(exec, entityId).executeTakeFirst(); + const groups = await exec + .selectFrom("source_release") + .select(sql`parsed -> 'releaseGroup' ->> 'name'`.as("g")) + .where("media_entity_id", "=", entityId) + .orderBy("first_seen_at", "desc") + .limit(500) + .execute(); + const known = new Map(); + for (const r of groups) { + const g = typeof r.g === "string" ? r.g.trim() : ""; + if (g && !known.has(g.toLowerCase())) known.set(g.toLowerCase(), g); + } + return { + block: blockOf(row, entityId), + title: e.title, + applicable: EPISODIC.has(e.kind), + knownGroups: [...known.values()].sort((a, b) => a.localeCompare(b)), + }; +} + +export async function dryRunEntityWords(exec: Exec, entityId: string, text: string): Promise { + const c = compileEntityWords(text); + const preview = await previewEpisodeMapping(exec, entityId, { words: text }); + return { + errors: c.errors, + rules: c.rules.length, + preview, + changed: preview.items.filter((i) => i.changed).length, + mismatched: preview.items.filter((i) => i.preview.method === "unresolved").length, + }; +} + +export async function saveEntityWords( + ctx: ServerContext, + entityId: string, + body: IdentifyWordsSave, + actor: Actor, +): Promise { + const e = await loadEntity(ctx.db, entityId); + if (!EPISODIC.has(e.kind) && compileEntityWords(body.text).rules.length) throw bad("只有剧集和综艺能写季集换算"); + const c = compileEntityWords(body.text); + if (c.errors.length) { + const first = c.errors[0]!; + throw bad(`第 ${first.line} 行:${first.message}`); + } + return ctx.db.transaction().execute(async (trx) => { + const { row } = await writeBlock(trx, entityId, body.text, body.expectedVersion, actor); + const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId); + return { block: blockOf(row, entityId), reresolve }; + }); +} + +/** + * 往作品识别词末尾追加几行(集数待确认的确认 / 忽略、智能体写规则),上方加一行注释说明来历。 + * 调用方在事务里,随后自己重算。返回第一条规则行的行号。 + */ +export async function appendEntityWords( + trx: Exec, + entityId: string, + lines: string[], + actor: Actor, + comment: string, +): Promise<{ text: string; lineNumber: number; appended: string }> { + const cur = await rowQuery(trx, entityId).executeTakeFirst(); + const base = (cur?.text ?? "").replace(/\n+$/, ""); + const head = base ? `${base}\n` : ""; + const note = `# ${new Date().toISOString().slice(0, 10)} ${comment}`.replace(/\n/g, " "); + const appended = [note, ...lines].join("\n"); + const text = `${head}${appended}\n`; + const c = compileEntityWords(text); + if (c.errors.length) { + const first = c.errors[0]!; + throw new ApiException("conflict", `写进作品识别词后第 ${first.line} 行有问题:${first.message}`); + } + await writeBlock(trx, entityId, text, null, actor); + return { text, lineNumber: (base ? splitLines(base).length : 0) + 2, appended }; +} + +// ── 实时示例 ── + +/** 拿一个标题试一行识别词;没给标题就从近期资源里找这一行命中的 */ +export async function tryWordsLine(exec: Exec, input: WordsTryInput): Promise { + const empty: WordsTryResult = { + sample: null, + matched: false, + error: null, + titleAfter: null, + subtitleAfter: null, + parsed: null, + episode: null, + validation: null, + }; + if (input.scope === "global") { + const g = parseGlobalWords(input.line)[0]; + if (!g || g.kind === "blank" || g.kind === "comment") return { ...empty, error: "这一行不是识别词" }; + if (g.error) return { ...empty, error: g.error }; + const words = compileGlobalWords(input.line); + let sample: WordsTryResult["sample"] = input.title + ? { sourceReleaseId: null, title: input.title, subtitle: input.subtitle ?? null } + : null; + if (!sample) { + const rows = await exec + .selectFrom("source_release") + .select(["id", "title", "subtitle"]) + .orderBy("first_seen_at", "desc") + .limit(TRY_SCAN) + .execute(); + const hit = rows.find((r) => wordTouches(words[0]!, r.title, r.subtitle)); + if (!hit) return empty; + sample = { sourceReleaseId: hit.id, title: hit.title, subtitle: hit.subtitle }; + } + const p = parseWith(sample.title, sample.subtitle, words); + const after = applyTitle(sample.title, sample.subtitle, words); + return { + ...empty, + sample, + matched: !!p.words?.applied.length, + titleAfter: after.title, + subtitleAfter: after.subtitle, + parsed: brief(p), + }; + } + + if (!input.mediaEntityId) throw bad("要给作品"); + const e = await loadEntity(exec, input.mediaEntityId); + let rules: ReturnType; + try { + rules = rulesOfLine(input.line); + } catch (err) { + return { ...empty, error: (err as Error).message }; + } + const words = await globalWords(exec); + const table = await loadSeasonTable(exec, e.id); + const run = (title: string, subtitle: string | null, publishedAt: string | null) => { + const parsed = parseWith(title, subtitle, words); + const season = parsed.season ?? (parsed.episodes ? 1 : null); + const hit = selectRule(rules, season, parsed.releaseGroup?.name ?? null); + const res = resolveEpisodes({ parsed, kind: e.kind, table, rules, publishedAt }); + return { parsed, hit, res }; + }; + let sample: WordsTryResult["sample"] = null; + let result: ReturnType | null = null; + if (input.title) { + sample = { sourceReleaseId: null, title: input.title, subtitle: input.subtitle ?? null }; + result = run(input.title, input.subtitle ?? null, null); + } else { + const rows = await exec + .selectFrom("source_release") + .select(["id", "title", "subtitle", "source_published_at", "first_seen_at"]) + .where("media_entity_id", "=", e.id) + .orderBy((eb) => eb.fn.coalesce("source_published_at", "first_seen_at"), "desc") + .limit(TRY_SCAN) + .execute(); + for (const r of rows) { + const x = run(r.title, r.subtitle, publishedAtOf(r).toISOString()); + if (x.hit) { + sample = { sourceReleaseId: r.id, title: r.title, subtitle: r.subtitle }; + result = x; + break; + } + } + } + if (!sample || !result) return empty; + const applied = applyTitle(sample.title, sample.subtitle, words); + return { + ...empty, + sample, + matched: !!result.hit, + titleAfter: applied.title, + subtitleAfter: applied.subtitle, + parsed: brief(result.parsed), + episode: result.res, + validation: validationOf(result.res), + }; +} + +function applyTitle(title: string, subtitle: string | null, words: readonly CompiledGlobalWord[]) { + const w = applyGlobalWords(title, subtitle, words); + return { title: w.title, subtitle: w.subtitle }; +} diff --git a/apps/server/src/modules/sources-identity/service/identity-admin.ts b/apps/server/src/modules/sources-identity/service/identity-admin.ts index 5140916e..be90be11 100644 --- a/apps/server/src/modules/sources-identity/service/identity-admin.ts +++ b/apps/server/src/modules/sources-identity/service/identity-admin.ts @@ -7,7 +7,7 @@ import type { ReleaseIdentityAssign, } from "@umt/contracts"; import { type ExternalIdentityRow, sql } from "@umt/db"; -import { type EpisodeResolution, parseRelease } from "@umt/domain"; +import type { EpisodeResolution } from "@umt/domain"; import { toParsedColumns } from "@umt/mapping"; import { ApiException } from "../../../http/errors.ts"; import { jsonb } from "../rows.ts"; @@ -28,6 +28,7 @@ import { publishedAtOf, settleEpisodeDecision, } from "./episode-resolution.ts"; +import { parseTitle } from "./identify-words-cache.ts"; import { detachFromIdentityDecisions } from "./identity-decisions.ts"; import { clearTmdbMetadata } from "./metadata.ts"; import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts"; @@ -86,7 +87,7 @@ export async function assignReleaseIdentity( const { ctx } = rt; const release = await ctx.db.selectFrom("source_release").selectAll().where("id", "=", srId).executeTakeFirst(); if (!release) throw new ApiException("not_found", "资源不存在"); - const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {}); + const parsed = await parseTitle(ctx.db, release.title, release.subtitle); const site = ((release.raw as Record)?._site ?? {}) as { categoryName?: string }; const variety = looksLikeVariety(parsed, site.categoryName ?? release.category_raw); diff --git a/apps/server/src/modules/sources-identity/service/organize-metadata.ts b/apps/server/src/modules/sources-identity/service/organize-metadata.ts index b9462f6a..155734e1 100644 --- a/apps/server/src/modules/sources-identity/service/organize-metadata.ts +++ b/apps/server/src/modules/sources-identity/service/organize-metadata.ts @@ -1,12 +1,13 @@ import { recallTmdbCandidates } from "@umt/adapters"; import type { OrganizeMetadataOutput } from "@umt/contracts"; -import { parseRelease, resolveIdentity } from "@umt/domain"; +import { resolveIdentity } from "@umt/domain"; import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { type Actor, audit } from "./common.ts"; import { detailScreenshotUrl, loadDetailPage } from "./detail-page.ts"; import { applyEnrichment, type EnrichDeps, type EnrichPage, suggestEnrichment } from "./enrich.ts"; import type { Candidate } from "./enrich-sources.ts"; +import { parseTitle } from "./identify-words-cache.ts"; import { assignReleaseIdentity } from "./identity-admin.ts"; import { type EnrichmentFieldWrite, project, writeEnrichmentFields } from "./metadata.ts"; import { resolveRelease } from "./resolve.ts"; @@ -200,7 +201,7 @@ export async function resolveOrganizeMetadata( verbatim: true, by: "rule", }); - const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {}); + const parsed = await parseTitle(rt.ctx.db, release.title, release.subtitle); const kind = parsed.mediaTypeHint === "tv" || parsed.season !== undefined || diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index a1148433..6849d4bd 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -8,7 +8,6 @@ import { normalizeTitle, PARSER_VERSION, type ParsedRelease, - parseRelease, queryTitles, type RankedCandidate, resolveIdentity, @@ -47,6 +46,7 @@ import { unitsAllowed, } from "./episode-resolution.ts"; import { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.ts"; +import { parseTitle } from "./identify-words-cache.ts"; import { detachFromIdentityDecisions, identityGroupKey, @@ -488,7 +488,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P const parsed = await rewriteHook( ctx, "name.recognize", - parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {}), + await parseTitle(ctx.db, release.title, release.subtitle), () => ({ title: release.title, subtitle: release.subtitle ?? null, diff --git a/apps/server/src/modules/sources-identity/service/snapshot.ts b/apps/server/src/modules/sources-identity/service/snapshot.ts index b43ae3a2..8981e2fb 100644 --- a/apps/server/src/modules/sources-identity/service/snapshot.ts +++ b/apps/server/src/modules/sources-identity/service/snapshot.ts @@ -2,7 +2,6 @@ import { createHash } from "node:crypto"; import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters"; import { lifecycleKey, type SiteSnapshotKind } from "@umt/contracts"; import { type SiteRow, sql } from "@umt/db"; -import { parseRelease } from "@umt/domain"; import { siteReleaseToSourceRelease } from "@umt/mapping"; import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; @@ -10,6 +9,7 @@ import { siteCan } from "../site-kinds.ts"; import { enqueueRelogin } from "./account.ts"; import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { hasDetailPage, saveDetailPage } from "./detail-page.ts"; +import { parseTitle } from "./identify-words-cache.ts"; import { emitSiteReachability } from "./lifecycle-events.ts"; // 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。 @@ -41,7 +41,7 @@ export async function upsertSiteRelease( site: Pick, r: SiteRelease, ): Promise<{ id: string; inserted: boolean }> { - const parsed = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}); + const parsed = await parseTitle(trx, r.title, r.subtitle); const c = siteReleaseToSourceRelease(r, { siteId: site.id, parsed }); const row = ( await sql<{ diff --git a/apps/server/src/modules/sources-identity/tasks.ts b/apps/server/src/modules/sources-identity/tasks.ts index 1053cdc5..d4a562f1 100644 --- a/apps/server/src/modules/sources-identity/tasks.ts +++ b/apps/server/src/modules/sources-identity/tasks.ts @@ -5,6 +5,12 @@ import type { SourcesRuntime } from "./runtime.ts"; import { refreshSiteAccount } from "./service/account.ts"; import { AUTO_ENRICH_TASK, runAutoEnrich } from "./service/enrich.ts"; import { ingestFeed } from "./service/feeds.ts"; +import { + markReresolveFailed, + type ReresolveState, + reresolveWordsBatch, + WORDS_RERESOLVE_TASK, +} from "./service/identify-words.ts"; import { refreshMetadata } from "./service/metadata.ts"; import { prewarmMetadata } from "./service/metadata-prewarm.ts"; import { resolveRelease } from "./service/resolve.ts"; @@ -69,6 +75,30 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu retryPolicy: { maximumAttempts: 1 }, handler: (t) => t.step.run("enrich", () => runAutoEnrich(rt().ctx, t.input.poolEntryId, t.runId, t.abortSignal)), }), + // 全局识别词保存后:分批扫资源,标题 / 副标题命中改动行的重新识别(进度写在 identify_words.reresolve) + defineTask({ + name: WORDS_RERESOLVE_TASK, + version: 1, + lane: "network", + inputSchema: z.object({ version: z.number().int() }), + retryPolicy: { maximumAttempts: 3, initialInterval: "5s", backoffCoefficient: 2, maximumInterval: "60s" }, + handler: async (t) => { + let state: ReresolveState | null = { last: null, scanned: 0, matched: 0, queued: 0 }; + try { + for (let i = 0; state; i++) { + const cur: ReresolveState = state; + state = await t.step.run( + `batch:${i}`, + (): Promise => reresolveWordsBatch(rt().ctx, t.input.version, cur), + ); + } + } catch (e) { + await markReresolveFailed(rt().ctx, t.input.version, (e as Error).message); + throw e; + } + return { done: true }; + }, + }), defineTask({ name: "refreshMetadata", version: 1, diff --git a/apps/server/test/acquisition-organize/episode-resolution-files.test.ts b/apps/server/test/acquisition-organize/episode-resolution-files.test.ts index f8dd342b..ae9c61c7 100644 --- a/apps/server/test/acquisition-organize/episode-resolution-files.test.ts +++ b/apps/server/test/acquisition-organize/episode-resolution-files.test.ts @@ -8,6 +8,7 @@ import { submitDownload, syncDownloads } from "../../src/modules/acquisition-org import { type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts"; import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts"; +import { setWordsText } from "../support/identify-words.ts"; import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; // 文件侧集数映射(docs/plan/episode-resolution.md 批次 3):整理计划与季包选文件对文件名重新解析出的季集, @@ -264,27 +265,32 @@ describe.skipIf(!hasPg)("整理与季包选文件按作品映射集数(真实 PG expect(decision.evidence).toMatchObject({ sourceReleaseIds: [rel.id] }); }); - it("有 active 规则 EP-12(→ 第 2 季)时整理按规则", async () => { + it("作品识别词 `S01 @ANi >> S02 EP-12` 时整理按这一行", async () => { const { id } = await series("规则作品"); - const rule = await h.db - .insertInto("episode_mapping_rule") - .values({ - media_entity_id: id, - season: 1, - release_group: "ANi", - kind: "expression", - expression: "EP-12", - target_season: 2, - created_by_type: "user", - created_by_id: "admin", - }) - .returning("id") - .executeTakeFirstOrThrow(); + await setWordsText(h.db, id, "S01 @ANi >> S02 EP-12\n"); const plan = await dryRun(id, "rule"); const [m] = mains(plan); expect(m).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } }); expect(m!.dst).toMatch(/S02E02/); - expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "rule", ruleId: rule.id }); + expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ + method: "rule", + ruleLine: 1, + ruleText: "S01 @ANi >> S02 EP-12", + }); + }); + + it("全局识别词也套到整理时的文件名上:`Rule Show - <> \\[1080P >> EP-12` 把 - 14 认成第 2 集", async () => { + const { id } = await series("全局识别词作品"); + await setWordsText(h.db, null, "Rule Show - <> \\[1080P >> EP-12\n"); + try { + const plan = await dryRun(id, "rule"); + const [m] = mains(plan); + expect(m).toMatchObject({ season: 1, episodes: { start: 2, end: 2 } }); + expect(m!.dst).toMatch(/S01E02/); + expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "raw" }); + } finally { + await setWordsText(h.db, null, ""); + } }); it("原号本来就对(S01E05):整理行为不变", async () => { diff --git a/apps/server/test/file-manager/episode-mapping-identify.test.ts b/apps/server/test/file-manager/episode-mapping-identify.test.ts index 4d96c5e3..a35d3b21 100644 --- a/apps/server/test/file-manager/episode-mapping-identify.test.ts +++ b/apps/server/test/file-manager/episode-mapping-identify.test.ts @@ -1,6 +1,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { setWordsText } from "../support/identify-words.ts"; import { createFmHarness, type FmHarness, pgAvailable, READ_KEY, seedEntity } from "./harness.ts"; // 文件管理识别 / 推荐名按作品映射集数(docs/plan/episode-resolution.md 批次 3,只读):识别到本地剧集作品后, @@ -97,4 +98,34 @@ describe.skipIf(skip)("文件管理识别与推荐名按作品映射集数", () ); expect(bad.body.data).toMatchObject({ name: null, reason: "episode_unresolved", mediaEntityId: show }); }); + + it("全局识别词与作品识别词都生效:`- 40` 经全局识别词减 15 后是 25,再按作品季表认作 S02E13", async () => { + await setWordsText(h.db, null, "Kusuriya no Hitorigoto - <> \\[1080P >> EP-15\n"); + try { + const r = await h.api( + "POST", + "/files/identify", + { items: [{ root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv" }], minSizeBytes: 0 }, + READ_KEY, + ); + expect(r.status).toBe(200); + const [it40] = r.body.data.items; + expect(it40.parsed).toMatchObject({ season: 2, episodes: [13] }); + expect(it40.suggestion).toMatchObject({ mediaEntityId: show, contentUnit: "S02E13", contentUnitId: s2e13 }); + + // 再加作品识别词:全局识别词先改文件名(25),作品识别词再换算(25 - 24 → S02E01) + await setWordsText(h.db, show, "S01 @ANi >> S02 EP-24\n"); + const mapped = await h.api( + "POST", + "/files/identify", + { items: [{ root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv" }], minSizeBytes: 0 }, + READ_KEY, + ); + expect(mapped.body.data.items[0].parsed).toMatchObject({ season: 2, episodes: [1] }); + expect(mapped.body.data.items[0].suggestion).toMatchObject({ contentUnit: "S02E01" }); + } finally { + await setWordsText(h.db, null, ""); + await setWordsText(h.db, show, ""); + } + }); }); diff --git a/apps/server/test/file-manager/organize-episode-structure.test.ts b/apps/server/test/file-manager/organize-episode-structure.test.ts index 214ae06f..96f0c682 100644 --- a/apps/server/test/file-manager/organize-episode-structure.test.ts +++ b/apps/server/test/file-manager/organize-episode-structure.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts"; import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts"; import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { setWordsText } from "../support/identify-words.ts"; import { createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, seedDownload, seedEntity } from "./harness.ts"; // 整理时指定季集结构(docs/runbook/organize.md「整理时指定剧集组」):作品是 TMDB 默认季(S1 = 25 集), @@ -271,18 +272,7 @@ describe.skipIf(skip)("整理时指定季集结构", () => { it("作品的映射规则按作品结构写:临时结构下规则结果经 TMDB 原季集换到指定结构", async () => { // 作品现在是「制作季」:ABS 组按 EP-24 换到组的第 2 季(- 26 → 组 S02E02 = 默认 S01E14) - await h.db - .insertInto("episode_mapping_rule") - .values({ - media_entity_id: show, - season: 1, - release_group: "ABS", - kind: "expression", - expression: "EP-24", - target_season: 2, - created_by_type: "user", - }) - .execute(); + await setWordsText(h.db, show, "S01 @ABS >> S02 EP-24\n"); await writeFile(join(h.dl, "[ABS] Split Cour - 26 [1080p].mkv"), "abs".padEnd(64, "x")); const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), diff --git a/apps/server/test/sources-identity/episode-groups.test.ts b/apps/server/test/sources-identity/episode-groups.test.ts index 45673ea9..9ce5b789 100644 --- a/apps/server/test/sources-identity/episode-groups.test.ts +++ b/apps/server/test/sources-identity/episode-groups.test.ts @@ -259,8 +259,9 @@ describe.skipIf(!pg)("剧集组", () => { }); it("集数映射按组:规则目标是组内季集,按组的季表校验;新资源按组解析", async () => { - const rule = await call(`/media-entities/${entityId}/episode-mapping-rules`, { - body: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-12", targetSeason: 2 }, + const rule = await call(`/media-entities/${entityId}/identify-words`, { + method: "PUT", + body: { text: "S01 @Lilith-Raws >> S02 EP-12\n", expectedVersion: 0 }, }); expect(rule.status, JSON.stringify(rule.body)).toBe(200); const lil = (await resolve("[Lilith-Raws] Split Cour - 20 [Baha][WEB-DL][1080p]")).id; @@ -269,7 +270,7 @@ describe.skipIf(!pg)("剧集组", () => { canonical: { season: 2, episodes: { start: 8, end: 8 } }, }); expect((await release(lil)).content_unit_id).toBe(await unitAt(2, 8)); - // 越过组内 S2 的 13 集:规则校验不过 → 待确认、规则停用 + // 越过组内 S2 的 13 集:规则校验不过 → 待确认(规则照常生效,不停用) const bad = (await resolve("[Lilith-Raws] Split Cour - 27 [Baha][WEB-DL][1080p]")).id; expect((await release(bad)).episode_resolution).toMatchObject({ method: "unresolved" }); diff --git a/apps/server/test/sources-identity/episode-mapping-agent.test.ts b/apps/server/test/sources-identity/episode-mapping-agent.test.ts index 7be8bfd7..0976fdaf 100644 --- a/apps/server/test/sources-identity/episode-mapping-agent.test.ts +++ b/apps/server/test/sources-identity/episode-mapping-agent.test.ts @@ -15,7 +15,8 @@ import { resolveRelease } from "../../src/modules/sources-identity/service/resol import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts"; // 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):episode_mapping.inspect 的上下文包与上限、 -// simulate、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则写规则 + 重算 + 事项关闭)、 +// simulate(试一行作品识别词)、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则把这一行 +// 连同理由注释追加进作品识别词 + 重算 + 事项关闭)、 // 后台调查接入(没有持续授权降级只读并留智能体建议 → 待处理一键确认;有授权时恢复动作按业务事实核实)。 // 不调真实模型:按假模型会走的调用顺序直接经注册中心调用能力。 @@ -50,7 +51,8 @@ const KUSURIYA: TmdbFixture = { } as TmdbFixture; const AIR = { 40: "2025-03-14T15:00:00Z", 41: "2025-03-21T15:00:00Z", 42: "2025-03-28T15:00:00Z" } as const; -const EP30 = { kind: "expression", expression: "EP-30", targetSeason: 2 } as const; +/** 作品识别词:第 1 季连续编号减 30 换到第 2 季 */ +const ep30 = (group: string, expr = "EP-30") => `S01 @${group} >> S02 ${expr}`; describe.skipIf(!pg)("集数映射的智能体兜底", () => { let h: Harness; @@ -110,8 +112,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { .where("dedupe_key", "=", `episode_mapping:${kId}:1:${group.toLowerCase()}`) .orderBy("created_at", "desc") .executeTakeFirstOrThrow(); - const rulesOf = (group: string) => - h.db.selectFrom("episode_mapping_rule").selectAll().where("release_group", "=", group).execute(); + /** 作品识别词里提到这个发布组的行(含注释) */ + const wordsText = async () => + (await h.db.selectFrom("identify_words").select("text").where("media_entity_id", "=", kId).executeTakeFirst()) + ?.text ?? ""; + const rulesOf = async (group: string) => (await wordsText()).split("\n").filter((l) => l.includes(`@${group}`)); const methods = async (ids: string[]) => (await h.db.selectFrom("source_release").select(["id", "episode_resolution"]).where("id", "in", ids).execute()).map( (r) => (r.episode_resolution as { method: string }).method, @@ -143,7 +148,8 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { const out = await invoke("episode_mapping.inspect", { decisionId: d.id }); expect(out.entity).toMatchObject({ id: kId, kind: "series" }); expect(out.scope).toEqual({ season: 1, releaseGroup: "Lilith-Raws" }); - expect(out.decision).toMatchObject({ id: d.id, status: "open", failedRules: [] }); + expect(out.decision).toMatchObject({ id: d.id, status: "open" }); + expect(out.decision).not.toHaveProperty("failedRules"); expect(out.releases).toHaveLength(3); expect(out.releases[0]).toMatchObject({ releaseGroup: "Lilith-Raws", @@ -160,7 +166,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { const s2 = out.seasons.find((s: Any) => s.season === 2); expect(s2.total).toBe(13); expect(s2.episodes[9]).toMatchObject({ episode: 10, airDate: "2025-03-14", title: "第 10 集" }); - expect(out).toMatchObject({ latestSeason: 2, ended: true, rules: [] }); + expect(out).toMatchObject({ + latestSeason: 2, + ended: true, + words: { text: "", lines: [{ line: 1, kind: "blank" }] }, + }); expect(out.suggestion).toMatchObject({ season: 1, releaseGroup: "Lilith-Raws", @@ -198,15 +208,16 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { it("simulate:草稿越界 → 没通过并写明原因;EP-30 → 样本全部解决;纯读,控制台调用不留建议", async () => { const d = await decisionOf("Lilith-Raws"); - const bad = await invoke("episode_mapping.simulate", { decisionId: d.id, rule: { ...EP30, expression: "EP-20" } }); + const bad = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Lilith-Raws", "EP-20") }); expect(bad.summary).toMatchObject({ samples: 3, resolved: 0, ok: false }); expect(bad.failures.join("")).toContain("仍对不上季表"); const ok = await invoke("episode_mapping.simulate", { decisionId: d.id, - rule: EP30, + line: "s01 @Lilith-Raws >> s02 ep - 30", reasons: ["发布时间对上播出日期"], }); - expect(ok.rule).toMatchObject({ season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 }); + // 回给规范写法 + expect(ok.line).toBe("S01 @Lilith-Raws >> S02 EP-30"); expect(ok.summary).toMatchObject({ samples: 3, resolved: 3, passed: 3, regressions: 0, missing: 0, ok: true }); expect(ok.failures).toEqual([]); const i40 = ok.items.find((i: Any) => i.sourceReleaseId === lilith[0]); @@ -226,7 +237,7 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { await expect( invoke("episode_mapping.apply_rule", { decisionId: d.id, - rule: { ...EP30, expression: "EP-20" }, + line: ep30("Lilith-Raws", "EP-20"), reasons: ["试试"], }), ).rejects.toThrow(/模拟没通过/); @@ -239,11 +250,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { await resolve("[Sakurato] Kusuriya no Hitorigoto - 05 [WEB-DL][1080p]", "2023-11-19T15:00:00Z"); const ids = await continuous("Sakurato", [40, 41]); const d = await decisionOf("Sakurato"); - const sim = await invoke("episode_mapping.simulate", { decisionId: d.id, rule: EP30 }); + const sim = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Sakurato") }); expect(sim.summary).toMatchObject({ samples: 2, resolved: 2, regressions: 1, ok: false }); expect(sim.failures.join("")).toContain("已对上的资源变成待确认"); await expect( - invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["连续编号"] }), + invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Sakurato"), reasons: ["连续编号"] }), ).rejects.toThrow(/已对上的资源变成待确认/); expect(await rulesOf("Sakurato")).toHaveLength(0); expect(await methods(ids)).toEqual(["unresolved", "unresolved"]); @@ -270,18 +281,26 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id); const sim = await invoke( "episode_mapping.simulate", - { decisionId: d.id, rule: EP30, reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"] }, + { + decisionId: d.id, + line: ep30("Lilith-Raws"), + reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"], + }, run.id, ); expect(sim).toMatchObject({ suggestionRecorded: true, summary: { ok: true } }); await expect( - invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["对上播出日期"] }, run.id), + invoke( + "episode_mapping.apply_rule", + { decisionId: d.id, line: ep30("Lilith-Raws"), reasons: ["对上播出日期"] }, + run.id, + ), ).rejects.toThrow(/授权/); expect(await rulesOf("Lilith-Raws")).toHaveLength(0); const withSug = await decisionOf("Lilith-Raws"); expect((withSug.evidence as Any).agentSuggestion).toMatchObject({ - rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 }, + line: "S01 @Lilith-Raws >> S02 EP-30", reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"], simulation: { samples: 3, resolved: 3, ok: true }, runId: run.id, @@ -311,14 +330,15 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { status: "resolved", resolution: { optionKey: "confirm_agent_suggestion", execution: { reresolve: { resolved: 4 } } }, }); - const rules = await rulesOf("Lilith-Raws"); - expect(rules).toHaveLength(1); - expect(rules[0]).toMatchObject({ expression: "EP-30", created_by_type: "user", evidence_level: "manual" }); - expect(rules[0]!.evidence).toMatchObject({ - decisionId: d.id, - agentSuggestion: { runId: run.id }, + expect(body.body.data.resolution.execution).toMatchObject({ + line: "S01 @Lilith-Raws >> S02 EP-30", simulation: { samples: 4, ok: true }, }); + // 写进作品识别词:上面一行注释写明来历与理由 + expect((await wordsText()).trimEnd().split("\n")).toEqual([ + expect.stringMatching(/^# \d{4}-\d{2}-\d{2} 智能体建议:40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期$/), + "S01 @Lilith-Raws >> S02 EP-30", + ]); expect(await methods([...lilith, more.id])).toEqual(["rule", "rule", "rule", "rule"]); }); @@ -332,7 +352,7 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { evidence: JSON.stringify({ ...(d.evidence as object), agentSuggestion: { - rule: { season: 1, releaseGroup: "Kirara", kind: "expression", expression: "EP-20", targetSeason: 2 }, + line: "S01 @Kirara >> S02 EP-20", reasons: ["旧建议"], simulation: { samples: 2, @@ -379,37 +399,38 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { expect(run.goal).toContain("episode_mapping.apply_rule"); await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id); - expect((await invoke("episode_mapping.simulate", { decisionId: d.id, rule: EP30 }, run.id)).summary.ok).toBe(true); + expect( + (await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Nekomoe") }, run.id)).summary.ok, + ).toBe(true); const out = await invoke( "episode_mapping.apply_rule", - { decisionId: d.id, rule: EP30, reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"] }, + { + decisionId: d.id, + line: ep30("Nekomoe"), + reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"], + }, run.id, ); expect(out).toMatchObject({ - evidenceLevel: "multi_evidence", summary: { samples: 3, resolved: 3, ok: true }, reresolve: { resolved: 3 }, decisionStatus: "resolved", }); - const rules = await rulesOf("Nekomoe"); - expect(rules).toHaveLength(1); - expect(rules[0]).toMatchObject({ - season: 1, - kind: "expression", - expression: "EP-30", - target_season: 2, - status: "active", - created_by_type: "agent", - created_by_id: run.id, - evidence_level: "multi_evidence", - }); - expect(rules[0]!.evidence).toMatchObject({ - decisionId: d.id, - reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"], - samples: 3, - simulation: { ok: true }, - runId: run.id, - }); + // 追加在已有内容后面,注释写明是智能体、哪个会话、理由 + const lines = (await wordsText()).trimEnd().split("\n"); + expect(lines.slice(-2)).toEqual([ + `# ${new Date().toISOString().slice(0, 10)} 智能体(会话 ${run.id.slice(0, 8)}):发布时间对上第 2 季第 10-12 集播出日期;同组 S02E09 已按原号对上`, + "S01 @Nekomoe >> S02 EP-30", + ]); + expect(out.appended).toBe(lines.slice(-2).join("\n")); + expect(out.lineNumber).toBe(lines.length); + const audit = await h.db + .selectFrom("audit_event") + .select(["actor_type", "actor_id"]) + .where("action", "=", "identify_words.updated") + .orderBy("at", "desc") + .executeTakeFirstOrThrow(); + expect(audit).toMatchObject({ actor_type: "agent", actor_id: run.id }); expect(await methods(ids)).toEqual(["rule", "rule", "rule"]); // 系统核实:事项内资源都已不是待确认 @@ -433,18 +454,25 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => { expect((closed.evidence as Any).autoRecover).toMatchObject({ outcome: "recovered", recoverAttempted: true }); }); - it("用户手动停用智能体写的规则后:资源回到待确认,同作用域不再自动写", async () => { - const rule = (await rulesOf("Nekomoe"))[0]!; + it("用户把智能体写的那一行注释掉后:资源回到待确认,同范围不再自动写", async () => { + const cur = await json(await h.req(`/media-entities/${kId}/identify-words`)); + const text = (cur.body.data.block.text as string).replace( + "S01 @Nekomoe >> S02 EP-30", + "# S01 @Nekomoe >> S02 EP-30", + ); const off = await json( - await h.req(`/episode-mapping-rules/${rule.id}`, { method: "PATCH", body: { status: "disabled" } }), + await h.req(`/media-entities/${kId}/identify-words`, { + method: "PUT", + body: { text, expectedVersion: cur.body.data.block.version }, + }), ); expect(off.status, JSON.stringify(off.body)).toBe(200); const d = await decisionOf("Nekomoe"); expect(d.status).toBe("open"); await expect( - invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["再写一次"] }), - ).rejects.toThrow(/手动停用/); - expect((await rulesOf("Nekomoe")).map((r) => r.status)).toEqual(["disabled"]); + invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Nekomoe"), reasons: ["再写一次"] }), + ).rejects.toThrow(/注释掉/); + expect(await rulesOf("Nekomoe")).toEqual(["# S01 @Nekomoe >> S02 EP-30"]); }); it("恢复核实:写规则没解决事项内资源时不算恢复", async () => { diff --git a/apps/server/test/sources-identity/episode-mapping-api.test.ts b/apps/server/test/sources-identity/episode-mapping-api.test.ts deleted file mode 100644 index f4a89997..00000000 --- a/apps/server/test/sources-identity/episode-mapping-api.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; -import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; -import { createHarness, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts"; - -// 集数映射接口(docs/plan/episode-resolution.md 批次 4):规则增删改与唯一约束、写入校验、模拟预览(纯读)、 -// 写入后按作品重算、待处理 episode_mapping 的确认建议 / 忽略并建忽略规则。 - -const pg = await pgAvailable(); - -const weekly = (n: number, start: string): TmdbFixtureEpisode[] => - Array.from({ length: n }, (_, k) => ({ - episode_number: k + 1, - air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10), - names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` }, - })); - -// 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集) -const KUSURIYA: TmdbFixture = { - type: "tv", - id: 990101, - original_title: "Kusuriya no Hitorigoto", - original_language: "ja", - origin_country: ["JP"], - date: "2023-10-22", - titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" }, - popularity: 30, - alternative_titles: [], - external_ids: {}, - rich: { status: "Ended" }, - seasons: [ - { season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") }, - { season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") }, - ], -} as TmdbFixture; - -describe.skipIf(!pg)("集数映射接口", () => { - let h: Harness; - let tmdb: MockTmdb; - let siteId: string; - let n = 0; - let kId: string; - let lilith40: string; - let lilith41: string; - - beforeAll(async () => { - tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] }); - h = await createHarness("episode_mapping_api", { tmdbUrl: tmdb.url }); - siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; - }); - afterAll(async () => { - await h?.close(); - await tmdb?.close(); - }); - - const resolve = async (title: string, publishedAt: string | null = null) => { - const r = await h.db - .insertInto("source_release") - .values({ - site_id: siteId, - site_release_id: String(810000 + ++n), - title, - subtitle: "药屋少女的呢喃", - source_published_at: publishedAt ? new Date(publishedAt) : null, - }) - .returningAll() - .executeTakeFirstOrThrow(); - const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); - expect(res.outcome, JSON.stringify(res)).toBe("auto"); - return { id: r.id, entityId: res.mediaEntityId! }; - }; - // biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb - const er = async (id: string): Promise => - ( - await h.db - .selectFrom("source_release") - .select("episode_resolution") - .where("id", "=", id) - .executeTakeFirstOrThrow() - ).episode_resolution; - const openDecisions = () => - h.db - .selectFrom("decision") - .selectAll() - .where("kind", "=", "episode_mapping") - .where("status", "in", ["open", "investigating", "awaiting_user"]) - .execute(); - const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) => - json(await h.req(path, init)); - const draft = { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 }; - - it("准备:季表就绪,Lilith-Raws 连续编号 40 / 41 待确认并开待处理(带建议规则与处理选项)", async () => { - kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId; - expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed"); - // 发布时间贴着 S02E10 / S02E11 的播出日期(2025-03-14 / 03-21) - lilith40 = ( - await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p]", "2025-03-14T15:00:00Z") - ).id; - lilith41 = ( - await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p]", "2025-03-21T15:00:00Z") - ).id; - expect((await er(lilith40)).method).toBe("unresolved"); - const ds = await openDecisions(); - expect(ds).toHaveLength(1); - expect(ds[0]!.evidence).toMatchObject({ suggestion: { expression: "EP-30", targetSeason: 2 } }); - expect((ds[0]!.options as { key: string; input: unknown }[]).map((o) => o.key)).toEqual([ - "confirm_suggestion", - "ignore_rule", - ]); - expect((ds[0]!.options as { input: unknown }[])[0]!.input).toEqual({ - season: 1, - releaseGroup: "Lilith-Raws", - expression: "EP-30", - targetSeason: 2, - }); - }); - - it("写入校验:表达式解析不了、表格区间重叠、special 没选类型都拒绝;只读 Key 不能写", async () => { - const path = `/media-entities/${kId}/episode-mapping-rules`; - const r1 = await call(path, { body: { kind: "expression", expression: "EP+abc" } }); - expect(r1.status).toBe(400); - expect(r1.body.error.message).toContain("偏移表达式无法解析"); - const r2 = await call(path, { - body: { - kind: "table", - tableEntries: [ - { from: { start: 26, end: 28 }, to: { season: 2, episode: 1 } }, - { from: { start: 28, end: 28 }, to: { season: 2, episode: 2 } }, - ], - }, - }); - expect(r2.status).toBe(400); - expect(r2.body.error.message).toContain("重叠"); - expect((await call(path, { body: { kind: "special" } })).status).toBe(400); - expect((await call(path, { body: { kind: "expression", expression: "-12" }, key: READ_KEY })).status).toBe(403); - expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0); - }); - - it("模拟预览:季表摘要、按发布组统计与建议;草稿规则把待确认变成按规则 S02E10,纯读不落库", async () => { - const base = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: {} }); - expect(base.status).toBe(200); - expect(base.body.data.applicable).toBe(true); - expect(base.body.data.seasons).toMatchObject({ - ended: true, - latestSeason: 2, - seasons: expect.arrayContaining([ - { season: 1, total: 12, aired: 12 }, - { season: 2, total: 13, aired: 13 }, - ]), - }); - // biome-ignore lint/suspicious/noExplicitAny: 测试断言 - const lilith = base.body.data.groups.find((g: any) => g.releaseGroup === "Lilith-Raws"); - expect(lilith).toMatchObject({ - total: 2, - pending: 2, - methods: [{ method: "unresolved", count: 2 }], - suggestion: { season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 }, - }); - expect(base.body.data.changed).toBe(0); - - const r = await call(`/media-entities/${kId}/episode-mapping/preview`, { - body: { releaseGroup: "lilith-raws", draft }, - }); - expect(r.status).toBe(200); - const items = r.body.data.items; - expect(items).toHaveLength(2); - // biome-ignore lint/suspicious/noExplicitAny: 测试断言 - const i40 = items.find((x: any) => x.sourceReleaseId === lilith40); - expect(i40).toMatchObject({ - releaseGroup: "Lilith-Raws", - current: { method: "unresolved", source: { season: 1, episodes: { start: 40, end: 40 } } }, - preview: { method: "rule", ruleId: "draft", canonical: { season: 2, episodes: { start: 10, end: 10 } } }, - validation: "pass", - changed: true, - }); - expect(r.body.data.changed).toBe(2); - expect(r.body.data.groups).toEqual([ - expect.objectContaining({ releaseGroup: "Lilith-Raws", pending: 0, methods: [{ method: "rule", count: 2 }] }), - ]); - - // 草稿算出越界 → 校验不通过 - const badDraft = await call(`/media-entities/${kId}/episode-mapping/preview`, { - body: { releaseGroup: "Lilith-Raws", draft: { ...draft, expression: "EP-20" } }, - }); - expect(badDraft.body.data.items[0]).toMatchObject({ validation: "fail", preview: { ruleFailed: "draft" } }); - // 季过滤:规范季 2 也算命中(ANi 01 是第 1 季,不在其中) - const s2 = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: { season: 2, draft } }); - expect(s2.body.data.items.map((x: { sourceReleaseId: string }) => x.sourceReleaseId).sort()).toEqual( - [lilith40, lilith41].sort(), - ); - - expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0); - expect((await er(lilith40)).method).toBe("unresolved"); - expect(await openDecisions()).toHaveLength(1); - }); - - let ruleA: string; - it("待处理「确认建议规则」:建规则 + 重算,资源解决,事项按用户选择关闭", async () => { - const d = (await openDecisions())[0]!; - const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_suggestion" } }); - expect(r.status, JSON.stringify(r.body)).toBe(200); - expect(r.body.data).toMatchObject({ - status: "resolved", - resolution: { optionKey: "confirm_suggestion", execution: { reresolve: { resolved: 2 } } }, - }); - const rules = await h.db.selectFrom("episode_mapping_rule").selectAll().execute(); - expect(rules).toHaveLength(1); - expect(rules[0]).toMatchObject({ - season: 1, - release_group: "Lilith-Raws", - kind: "expression", - expression: "EP-30", - target_season: 2, - status: "active", - created_by_type: "user", - }); - ruleA = rules[0]!.id; - expect(await er(lilith40)).toMatchObject({ - method: "rule", - ruleId: ruleA, - canonical: { season: 2, episodes: { start: 10, end: 10 } }, - }); - expect(await openDecisions()).toHaveLength(0); - // 资源接口照常带出集数解析结果 - const rel = await call(`/source-releases/${lilith41}`); - expect(rel.body.data.episodeResolution).toMatchObject({ - method: "rule", - canonical: { season: 2, episodes: { start: 11, end: 11 } }, - }); - }); - - it("规则增改停删:同作用域只能一条启用(发布组忽略大小写),每次写入都重算并返回受影响数量", async () => { - const path = `/media-entities/${kId}/episode-mapping-rules`; - const dup = await call(path, { body: { ...draft, releaseGroup: "lilith-raws", expression: "EP-29" } }); - expect(dup.status).toBe(409); - expect(dup.body.error.message).toContain("已有一条启用的规则"); - - // 修改表达式:两条都变 - const upd = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { expression: "-31" } }); - expect(upd.status, JSON.stringify(upd.body)).toBe(200); - expect(upd.body.data.rule).toMatchObject({ expression: "EP-31", status: "active" }); - expect(upd.body.data.reresolve).toMatchObject({ changed: 2, resolved: 0 }); - expect((await er(lilith40)).canonical).toEqual({ season: 2, episodes: { start: 9, end: 9 } }); - - // 停用:资源回到待确认、重新开事项;停用原因记为手动 - const off = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "disabled" } }); - expect(off.body.data.rule.status).toBe("disabled"); - expect(JSON.parse(off.body.data.rule.disabledReason)).toMatchObject({ manual: true }); - expect((await er(lilith40)).method).toBe("unresolved"); - expect(await openDecisions()).toHaveLength(1); - - // 停用后同作用域可以再建一条(表格规则);待处理随资源解决自动关闭 - const b = await call(path, { - body: { - season: 1, - releaseGroup: "LILITH-RAWS", - kind: "table", - tableEntries: [ - { from: { start: 40, end: 40 }, to: { season: 2, episode: 10 } }, - { from: { start: 41, end: 41 }, to: { season: 2, episode: 11 } }, - ], - }, - }); - expect(b.status, JSON.stringify(b.body)).toBe(200); - expect(b.body.data.reresolve).toMatchObject({ resolved: 2 }); - expect(await openDecisions()).toHaveLength(0); - const ruleB = b.body.data.rule.id as string; - - // 再启用 A 与 B 冲突 - expect( - (await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "active" } })).status, - ).toBe(409); - - // 列表:含停用的与停用原因 - const list = await call(path); - expect(list.body.data.items.map((x: { id: string; status: string }) => [x.id, x.status])).toEqual( - expect.arrayContaining([ - [ruleA, "disabled"], - [ruleB, "active"], - ]), - ); - - // 删除 B:资源回到待确认 - const del = await call(`/episode-mapping-rules/${ruleB}`, { method: "DELETE" }); - expect(del.status).toBe(200); - expect(del.body.data.reresolve).toMatchObject({ changed: 2 }); - expect(await h.db.selectFrom("episode_mapping_rule").select("id").where("id", "=", ruleB).execute()).toHaveLength( - 0, - ); - expect((await er(lilith41)).method).toBe("unresolved"); - expect(await openDecisions()).toHaveLength(1); - }); - - it("待处理「忽略并建忽略规则」:建 special=ignore 规则,事项记为已忽略,资源不再待确认", async () => { - const d = (await openDecisions())[0]!; - // A 仍停用,同作用域没有启用的规则 - const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "ignore_rule" } }); - expect(r.status, JSON.stringify(r.body)).toBe(200); - expect(r.body.data.status).toBe("dismissed"); - const rule = await h.db - .selectFrom("episode_mapping_rule") - .selectAll() - .where("status", "=", "active") - .executeTakeFirstOrThrow(); - expect(rule).toMatchObject({ kind: "special", special: "ignore", season: 1, release_group: "Lilith-Raws" }); - expect(await er(lilith40)).toMatchObject({ method: "rule", special: "ignore", canonical: null }); - expect(await openDecisions()).toHaveLength(0); - }); -}); diff --git a/apps/server/test/sources-identity/episode-resolution.test.ts b/apps/server/test/sources-identity/episode-resolution.test.ts index 1bd82d8b..f8721824 100644 --- a/apps/server/test/sources-identity/episode-resolution.test.ts +++ b/apps/server/test/sources-identity/episode-resolution.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { reresolveEntityEpisodes } from "../../src/modules/sources-identity/service/episode-resolution.ts"; import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { setWordsText } from "../support/identify-words.ts"; import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; // 集数解析接入识别(docs/plan/episode-resolution.md 批次 2):识别绑定作品后按季表 / 规则换算季集再建单元; @@ -91,6 +92,7 @@ describe.skipIf(!pg)("集数解析接入识别", () => { expect(res.outcome, JSON.stringify(res)).toBe("auto"); return { id: r.id, res, row: await row(r.id) }; }; + const setWords = (entityId: string, text: string) => setWordsText(h.db, entityId, text); const row = (id: string) => h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); // biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb @@ -201,71 +203,41 @@ describe.skipIf(!pg)("集数解析接入识别", () => { expect(ds[0]!.summary).toContain("共 2 条资源"); }); - it("有 active 规则 `EP-12`(季 2、组 ANi)→ rule;规则算出越界 → 规则停用并开待处理", async () => { - const rule = await h.db - .insertInto("episode_mapping_rule") - .values({ - media_entity_id: kId, - season: 2, - release_group: "ANi", - kind: "expression", - expression: "EP-12", - created_by_type: "user", - created_by_id: "admin", - }) - .returning("id") - .executeTakeFirstOrThrow(); - // 同作用域(发布组忽略大小写)只能有一条 active - await expect( - h.db - .insertInto("episode_mapping_rule") - .values({ - media_entity_id: kId, - season: 2, - release_group: "ani", - kind: "expression", - expression: "EP", - created_by_type: "user", - }) - .execute(), - ).rejects.toThrow(); - + it("作品识别词 `S02 @ANi >> EP-12` → rule;算出越界 → 待确认,这一行照常生效(不自动停用)", async () => { + await setWords(kId, "# 第二季接着第一季编号\nS02 @ANi >> EP-12\n"); const ok = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 13 [1080P][Baha][WEB-DL]"); expect(er(ok.row)).toMatchObject({ method: "rule", - ruleId: rule.id, + ruleLine: 2, + ruleText: "S02 @ANi >> EP-12", canonical: { season: 2, episodes: { start: 1, end: 1 } }, }); expect(await unitOf(ok.row.content_unit_id)).toMatchObject({ season_number: 2, episode_number: 1 }); const bad = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 30 [1080P][Baha][WEB-DL]"); - expect(er(bad.row)).toMatchObject({ method: "unresolved", ruleFailed: rule.id }); + expect(er(bad.row)).toMatchObject({ method: "unresolved", ruleText: "S02 @ANi >> EP-12" }); + expect(er(bad.row)).not.toHaveProperty("ruleFailed"); expect(bad.row.content_unit_id).toBeNull(); - const after = await h.db - .selectFrom("episode_mapping_rule") - .selectAll() - .where("id", "=", rule.id) + // 识别词原文没被改动 + const w = await h.db + .selectFrom("identify_words") + .select(["text", "version"]) + .where("media_entity_id", "=", kId) .executeTakeFirstOrThrow(); - expect(after.status).toBe("disabled"); - expect(after.disabled_reason).toContain("rule:validate_fail"); + expect(w.text).toBe("# 第二季接着第一季编号\nS02 @ANi >> EP-12\n"); const d = (await openEpisodeDecisions()).find((x) => x.dedupe_key === `episode_mapping:${kId}:2:ani`); - expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id], failedRules: [rule.id] }); - expect(d?.summary).toContain("规则已停用"); + expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id] }); + expect(d?.evidence).not.toHaveProperty("failedRules"); + expect(d?.summary).toContain("按作品识别词「S02 @ANi >> EP-12」算出的集数对不上作品季表"); + expect(d?.summary).not.toContain("停用"); + + // 越界之后同一行对新资源照样生效 + const again = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 14 [1080P][Baha][WEB-DL]"); + expect(er(again.row)).toMatchObject({ method: "rule", canonical: { season: 2, episodes: { start: 2, end: 2 } } }); }); it("新增规则后按作品重算:之前待确认的资源解决、建出单元、待处理关闭", async () => { - await h.db - .insertInto("episode_mapping_rule") - .values({ - media_entity_id: kId, - season: 1, - release_group: "Lilith-Raws", - kind: "expression", - expression: "EP-30", - target_season: 2, - created_by_type: "user", - }) - .execute(); + await setWords(kId, "S02 @ANi >> EP-12\nS01 @Lilith-Raws >> S02 EP-30\n"); const decisionId = (await openEpisodeDecisions()).find((x) => x.dedupe_key.endsWith(":1:lilith-raws"))!.id; const r = await h.db.transaction().execute((trx) => reresolveEntityEpisodes(h.ctx, trx, kId)); expect(r.resolved).toBe(2); diff --git a/apps/server/test/sources-identity/identify-words-api.test.ts b/apps/server/test/sources-identity/identify-words-api.test.ts new file mode 100644 index 00000000..07ef19bf --- /dev/null +++ b/apps/server/test/sources-identity/identify-words-api.test.ts @@ -0,0 +1,378 @@ +import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { reresolveWordsBatch } from "../../src/modules/sources-identity/service/identify-words.ts"; +import { invalidateGlobalWords } from "../../src/modules/sources-identity/service/identify-words-cache.ts"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { upsertSiteRelease } from "../../src/modules/sources-identity/service/snapshot.ts"; +import { createHarness, EXEC_KEY, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts"; + +// 识别词接口(docs/design/identify-words.md):作品识别词读写 / 试算 / 版本冲突、集数待确认事项的确认与忽略(写一行进识别词)、 +// 规则照写的用(对不上季表只待确认,不自动停用)、全局识别词试算 / 保存 / 后台重算范围、单行示例。 + +const pg = await pgAvailable(); + +const weekly = (n: number, start: string): TmdbFixtureEpisode[] => + Array.from({ length: n }, (_, k) => ({ + episode_number: k + 1, + air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10), + names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` }, + })); + +// 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集) +const KUSURIYA: TmdbFixture = { + type: "tv", + id: 990101, + original_title: "Kusuriya no Hitorigoto", + original_language: "ja", + origin_country: ["JP"], + date: "2023-10-22", + titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" }, + popularity: 30, + alternative_titles: [], + external_ids: {}, + rich: { status: "Ended" }, + seasons: [ + { season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") }, + { season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") }, + ], +} as TmdbFixture; + +describe.skipIf(!pg)("识别词接口", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + let kId: string; + let lilith40: string; + let lilith41: string; + + beforeAll(async () => { + invalidateGlobalWords(); + tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] }); + h = await createHarness("identify_words_api", { tmdbUrl: tmdb.url }); + siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const insert = (title: string, publishedAt: string | null = null, subtitle = "药屋少女的呢喃") => + h.db + .insertInto("source_release") + .values({ + site_id: siteId, + site_release_id: String(810000 + ++n), + title, + subtitle, + source_published_at: publishedAt ? new Date(publishedAt) : null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + const resolve = async (title: string, publishedAt: string | null = null) => { + const r = await insert(title, publishedAt); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + return { id: r.id, entityId: res.mediaEntityId! }; + }; + // biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb + const er = async (id: string): Promise => + ( + await h.db + .selectFrom("source_release") + .select("episode_resolution") + .where("id", "=", id) + .executeTakeFirstOrThrow() + ).episode_resolution; + const openDecisions = () => + h.db + .selectFrom("decision") + .selectAll() + .where("kind", "=", "episode_mapping") + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .execute(); + const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) => + json(await h.req(path, init)); + const entityWords = async () => (await call(`/media-entities/${kId}/identify-words`)).body.data; + const saveEntity = (text: string, expectedVersion: number, key?: string) => + call(`/media-entities/${kId}/identify-words`, { + method: "PUT", + body: { text, expectedVersion }, + ...(key ? { key } : {}), + }); + + it("准备:Lilith-Raws 连续编号 40 / 41 待确认,事项选项里带要写进识别词的那一行", async () => { + kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId; + expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed"); + lilith40 = ( + await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p]", "2025-03-14T15:00:00Z") + ).id; + lilith41 = ( + await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p]", "2025-03-21T15:00:00Z") + ).id; + expect((await er(lilith40)).method).toBe("unresolved"); + const ds = await openDecisions(); + expect(ds).toHaveLength(1); + expect(ds[0]!.evidence).toMatchObject({ suggestion: { expression: "EP-30", targetSeason: 2 } }); + const options = ds[0]!.options as { key: string; input: { line: string } }[]; + expect(options.map((o) => [o.key, o.input.line])).toEqual([ + ["confirm_suggestion", "S01 @Lilith-Raws >> S02 EP-30"], + ["ignore_rule", "S01 @Lilith-Raws >> 忽略"], + ]); + const w = await entityWords(); + expect(w).toMatchObject({ applicable: true, block: { text: "", version: 0 } }); + expect(w.knownGroups).toEqual(expect.arrayContaining(["ANi", "Lilith-Raws"])); + }); + + it("试算:逐行错误带行号,换算前后与仍对不上季表的条数;纯读不落库", async () => { + const r = await call(`/media-entities/${kId}/identify-words/dry-run`, { + body: { text: "# 连续编号\nS01 @Lilith-Raws >> S02 EP-30\nS0X >> EP\nS02 >> S01 2EP\n" }, + }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.data.errors).toEqual([ + { line: 3, message: expect.stringContaining("不是季号") }, + { line: 4, message: expect.stringContaining("EP") }, + ]); + expect(r.body.data).toMatchObject({ rules: 1, changed: 2, mismatched: 0 }); + // biome-ignore lint/suspicious/noExplicitAny: 测试断言 + const i40 = r.body.data.preview.items.find((x: any) => x.sourceReleaseId === lilith40); + expect(i40).toMatchObject({ + current: { method: "unresolved" }, + preview: { method: "rule", ruleLine: 2, canonical: { season: 2, episodes: { start: 10, end: 10 } } }, + validation: "pass", + changed: true, + words: [], + }); + // 算出越界:照样标对不上,算作仍对不上季表 + const bad = await call(`/media-entities/${kId}/identify-words/dry-run`, { + body: { text: "S01 @Lilith-Raws >> S02 EP-20\n" }, + }); + expect(bad.body.data).toMatchObject({ errors: [], mismatched: 2 }); + expect(await h.db.selectFrom("identify_words").select("id").execute()).toHaveLength(0); + expect((await er(lilith40)).method).toBe("unresolved"); + }); + + it("保存校验:有错的行拒绝、版本不符 409、只读 Key 403", async () => { + const e = await saveEntity("S0X >> EP\n", 0); + expect(e.status).toBe(400); + expect(e.body.error.message).toContain("第 1 行"); + expect((await saveEntity("S01 >> EP\n", 3)).status).toBe(409); + expect((await saveEntity("S01 >> EP\n", 0, READ_KEY)).status).toBe(403); + expect(await h.db.selectFrom("identify_words").select("id").execute()).toHaveLength(0); + }); + + it("集数待确认「确认建议规则」:这一行连同来历注释追加进作品识别词,重算后资源解决、事项关闭", async () => { + const d = (await openDecisions())[0]!; + const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_suggestion" } }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.data).toMatchObject({ + status: "resolved", + resolution: { + optionKey: "confirm_suggestion", + execution: { line: "S01 @Lilith-Raws >> S02 EP-30", lineNumber: 2, reresolve: { resolved: 2 } }, + }, + }); + const w = await entityWords(); + expect(w.block.version).toBe(1); + expect(w.block.text).toMatch( + /^# \d{4}-\d{2}-\d{2} 按发布时间对播出日期推出的建议\nS01 @Lilith-Raws >> S02 EP-30\n$/, + ); + expect(await er(lilith40)).toMatchObject({ + method: "rule", + ruleText: "S01 @Lilith-Raws >> S02 EP-30", + canonical: { season: 2, episodes: { start: 10, end: 10 } }, + }); + expect(await openDecisions()).toHaveLength(0); + const audit = await h.db + .selectFrom("audit_event") + .select(["action", "payload"]) + .where("action", "=", "identify_words.updated") + .execute(); + expect(audit[0]?.payload).toMatchObject({ mediaEntityId: kId, added: ["S01 @Lilith-Raws >> S02 EP-30"] }); + }); + + it("改这一行:每次保存重算并返回受影响数量;算出越界只待确认,这一行原样保留", async () => { + const w = await entityWords(); + const edited = w.block.text.replace("EP-30", "EP-31"); + const r = await saveEntity(edited, w.block.version); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.data.reresolve).toMatchObject({ changed: 2, resolved: 0 }); + expect(r.body.data.block.version).toBe(w.block.version + 1); + expect((await er(lilith40)).canonical).toEqual({ season: 2, episodes: { start: 9, end: 9 } }); + + // 写成 EP(集号不变):S02E40 / E41 超出第 2 季,资源待确认,这一行不会被停用或改写 + const over = edited.replace("EP-31", "EP"); + const o = await saveEntity(over, w.block.version + 1); + expect(o.status).toBe(200); + expect(await er(lilith40)).toMatchObject({ method: "unresolved", ruleText: "S01 @Lilith-Raws >> S02 EP" }); + expect((await entityWords()).block.text).toBe(over); + expect(await openDecisions()).toHaveLength(1); + }); + + it("注释掉这一行 = 停用:资源回到自动判定;选「忽略」把忽略行追加进去", async () => { + const w = await entityWords(); + const off = w.block.text.replace("S01 @Lilith-Raws >> S02 EP", "# S01 @Lilith-Raws >> S02 EP"); + expect((await saveEntity(off, w.block.version)).status).toBe(200); + expect((await er(lilith41)).method).toBe("unresolved"); + expect((await er(lilith41)).ruleText).toBeUndefined(); + + const d = (await openDecisions())[0]!; + const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "ignore_rule" } }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.data.status).toBe("dismissed"); + const text = (await entityWords()).block.text as string; + expect(text.startsWith(off.trimEnd())).toBe(true); + expect(text.trimEnd().split("\n").at(-1)).toBe("S01 @Lilith-Raws >> 忽略"); + expect(await er(lilith40)).toMatchObject({ method: "rule", special: "ignore", canonical: null }); + expect(await openDecisions()).toHaveLength(0); + }); + + it("有作品识别词的作品列表:规则行数、停用行数、可按名称搜", async () => { + const r = await call("/identify-words/entities"); + expect(r.status).toBe(200); + expect(r.body.data.items).toEqual([ + expect.objectContaining({ mediaEntityId: kId, title: "药屋少女的呢喃", rules: 1, disabled: 1, errors: 0 }), + ]); + expect((await call("/identify-words/entities?q=不存在")).body.data.items).toEqual([]); + }); + + it("单行示例:作品识别词找一条命中的近期资源,给出换算后的季集与校验;也可以自己给标题", async () => { + const r = await call("/identify-words/try", { + body: { scope: "entity", mediaEntityId: kId, line: "S01 @Lilith-Raws >> S02 EP-30" }, + }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.data).toMatchObject({ + matched: true, + sample: { title: expect.stringContaining("Lilith-Raws") }, + episode: { method: "rule", canonical: { season: 2 } }, + validation: "pass", + }); + const own = await call("/identify-words/try", { + body: { + scope: "entity", + mediaEntityId: kId, + line: "S01 @Lilith-Raws >> S02 EP-30", + title: "[Lilith-Raws] X - 99", + }, + }); + expect(own.body.data).toMatchObject({ matched: true, validation: "fail", episode: { method: "unresolved" } }); + const bad = await call("/identify-words/try", { body: { scope: "entity", mediaEntityId: kId, line: "S0X >> EP" } }); + expect(bad.body.data.error).toContain("不是季号"); + }); + + it("全局识别词:试算列出识别结果会变的资源;保存要带版本;生效行有增减才排后台重算", async () => { + const other = await insert("Fake.Show.S01E05.1080p.WEB-DL-GRP", null, ""); + const g0 = (await call("/identify-words/global")).body.data; + expect(g0).toMatchObject({ text: "", version: 0, reresolve: null }); + const text = "# 测试\nFake.Show => 假剧 {[tmdbid=1399;type=tv]}\n([ => broken\n"; + const dry = await call("/identify-words/global/dry-run", { body: { text } }); + expect(dry.status, JSON.stringify(dry.body)).toBe(200); + expect(dry.body.data.errors).toEqual([{ line: 3, message: expect.stringContaining("正则") }]); + expect(dry.body.data.added).toEqual(["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}", "([ => broken"]); + // biome-ignore lint/suspicious/noExplicitAny: 测试断言 + const item = dry.body.data.items.find((x: any) => x.sourceReleaseId === other.id); + expect(item).toMatchObject({ + words: ["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}"], + before: { title: "Fake Show", explicitIds: [] }, + after: { title: "假剧", explicitIds: [{ provider: "tmdb", id: "1399" }] }, + }); + expect(dry.body.data.matched).toBe(1); + + expect( + (await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 }, key: EXEC_KEY })) + .status, + ).toBe(403); + const saved = await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 } }); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + expect(saved.body.data.block).toMatchObject({ version: 1, reresolve: { status: "queued" } }); + expect((await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 } })).status).toBe( + 409, + ); + expect(h.engine.enqueued.filter((e) => e.task === "reresolveIdentifyWords")).toEqual([ + expect.objectContaining({ input: { version: 1 } }), + ]); + // 只改注释:生效行没变,不排重算 + const again = await call("/identify-words/global", { + method: "PUT", + body: { text: text.replace("# 测试", "# 改了注释"), expectedVersion: 1 }, + }); + expect(again.body.data.block.version).toBe(2); + expect(h.engine.enqueued.filter((e) => e.task === "reresolveIdentifyWords")).toHaveLength(1); + }); + + it("后台重算:只重新识别标题命中改动行的资源,进度写回;被新版本取代就停", async () => { + // 版本 2 的保存没有改动行,版本 1 的任务已被取代 + expect(await reresolveWordsBatch(h.ctx, 1, { last: null, scanned: 0, matched: 0, queued: 0 })).toBeNull(); + const g = (await call("/identify-words/global")).body.data; + const saved = await call("/identify-words/global", { + method: "PUT", + body: { text: `${g.text}Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28\n`, expectedVersion: g.version }, + }); + const version = saved.body.data.block.version as number; + const before = h.engine.enqueued.length; + expect(await reresolveWordsBatch(h.ctx, version, { last: null, scanned: 0, matched: 0, queued: 0 })).toBeNull(); + const queued = h.engine.enqueued.slice(before).filter((e) => e.task === "resolveObservation"); + expect(queued.map((e) => (e.input as { sourceReleaseId: string }).sourceReleaseId).sort()).toEqual( + [lilith40, lilith41].sort(), + ); + const p = (await call("/identify-words/global")).body.data.reresolve; + expect(p).toMatchObject({ status: "done", matched: 2, queued: 2 }); + expect(p.scanned).toBeGreaterThanOrEqual(4); + + // 重新识别后集数按全局识别词偏移(40 → 12,对上第 1 季),命中的行记在解析结果上 + await resolveRelease(h.rt, { sourceReleaseId: lilith40, force: true, trigger: "review" }); + const row = await h.db + .selectFrom("source_release") + .select(["parsed"]) + .where("id", "=", lilith40) + .executeTakeFirstOrThrow(); + expect((row.parsed as { words?: { applied: string[] } }).words?.applied).toEqual([ + "Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28", + ]); + }); + + it("全局示例:没给标题时从近期资源里找命中的", async () => { + const r = await call("/identify-words/try", { body: { scope: "global", line: "Fake.Show => 假剧" } }); + expect(r.body.data).toMatchObject({ + matched: true, + sample: { title: "Fake.Show.S01E05.1080p.WEB-DL-GRP" }, + titleAfter: "假剧.S01E05.1080p.WEB-DL-GRP", + parsed: { title: "假剧", season: 1, episodes: { start: 5, end: 5 } }, + }); + const none = await call("/identify-words/try", { body: { scope: "global", line: "完全不存在的词 => X" } }); + expect(none.body.data).toMatchObject({ matched: false, sample: null }); + }); + + it("站点列表抓取落库时就按全局识别词解析(parsed 记命中的行)", async () => { + const r = await h.db.transaction().execute((trx) => + upsertSiteRelease(h.rt, trx, { id: siteId }, { + siteId, + siteReleaseId: "words-snap-1", + title: "Fake.Show.S01E06.1080p.WEB-DL-GRP", + category: { id: "1", name: "TV" }, + sizeBytes: null, + seeders: null, + leechers: null, + snatched: null, + promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null }, + hr: false, + uploadedAt: null, + detailUrl: "https://acgrip.example/details.php?id=1", + tags: [], + raw: {}, + observedAt: new Date().toISOString(), + } as Parameters[3]), + ); + const row = await h.db + .selectFrom("source_release") + .select("parsed") + .where("id", "=", r.id) + .executeTakeFirstOrThrow(); + expect(row.parsed).toMatchObject({ + title: "假剧", + explicitIds: [{ provider: "tmdb", id: "1399" }], + words: { applied: ["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}"], original: "Fake.Show.S01E06.1080p.WEB-DL-GRP" }, + }); + }); +}); diff --git a/apps/server/test/sources-identity/migration-identify-words.test.ts b/apps/server/test/sources-identity/migration-identify-words.test.ts new file mode 100644 index 00000000..2719bd63 --- /dev/null +++ b/apps/server/test/sources-identity/migration-identify-words.test.ts @@ -0,0 +1,147 @@ +import { compileEntityWords } from "@umt/contracts"; +import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db"; +import { Kysely, PostgresDialect } from "kysely"; +import pg from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { up } from "../../../../packages/db/migrations/0094_identify_words.ts"; +import { PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts"; + +// 0094:episode_mapping_rule 结构化规则逐作品转成作品识别词文本行后删表。部署库只有一部作品 9 条规则 +// (药屋少女的呢喃:S2 任意组 EP+24 → S1;S3 任意组 EP+48 → S1;S3 七个发布组 EP → S1),按它造数据。 + +const hasPg = await pgAvailable(); + +describe.skipIf(!hasPg)("0094 映射规则迁成作品识别词", () => { + const schema = `iw_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`; + const admin = async (q: string) => { + const c = new pg.Client({ connectionString: PG_URL }); + await c.connect(); + await c.query(q).finally(() => c.end()); + }; + let db: Kysely; + + afterAll(async () => { + await db?.destroy(); + await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`)); + }); + + // biome-ignore lint/suspicious/noExplicitAny: 迁移前的旧表不在类型里 + const raw = () => db as unknown as Kysely; + const rule = (entity: string, v: Record) => + raw() + .insertInto("episode_mapping_rule") + .values({ media_entity_id: entity, kind: "expression", created_by_type: "user", ...v }) + .execute(); + const groups = ["ADWeb", "MWeb", "CHDWEB", "黒ネズミたち", "Judas", "绿茶字幕组", "綠茶字幕組"]; + + it("药屋 9 条规则合成 3 行;停用的变注释;单集对照一行一条;智能体的带注释;重跑只补缺的行", async () => { + await admin(`create schema ${schema}`); + db = new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }), + }), + }); + await withSchemaLock(async () => { + const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0093_pi_azure_provider"); + if (r.error) throw r.error; + }); + const entity = async (title: string) => + ( + await sql<{ + id: string; + }>`insert into media_entity (kind, title) values ('series', ${title}) returning id`.execute(db) + ).rows[0]!.id; + const kusuriya = await entity("药屋少女的呢喃"); + const other = await entity("别的剧"); + await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 }); + await rule(kusuriya, { season: 3, expression: "EP+48", target_season: 1 }); + for (const g of groups) await rule(kusuriya, { season: 3, release_group: g, expression: "EP", target_season: 1 }); + // 别的剧:停用(自动停用留下的)、单集对照、特别篇、智能体写的 + await rule(other, { + season: 1, + release_group: "ANi", + expression: "EP-12", + target_season: 2, + status: "disabled", + disabled_reason: JSON.stringify({ source: { season: 1 } }), + }); + await rule(other, { + season: 1, + release_group: "Baha", + kind: "table", + expression: null, + table_entries: JSON.stringify([ + { from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } }, + { from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } }, + ]), + }); + await rule(other, { season: null, release_group: "SweetSub", kind: "special", expression: null, special: "extra" }); + await rule(other, { + season: 2, + release_group: "Nekomoe", + expression: "EP-30", + created_by_type: "agent", + evidence: JSON.stringify({ reasons: ["发布时间对上播出日期"] }), + }); + + await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema })); + + const text = async (id: string) => + (await db.selectFrom("identify_words").select("text").where("media_entity_id", "=", id).executeTakeFirstOrThrow()) + .text; + expect(await text(kusuriya)).toBe( + ["S02 >> S01 EP+24", "S03 >> S01 EP+48", `S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`, ""].join("\n"), + ); + expect(await text(other)).toBe( + [ + "S01E13 @Baha >> S00E01", + "S01E27-E28 @Baha >> S02E02", + "# S01 @ANi >> S02 EP-12", + "# 智能体:发布时间对上播出日期", + "S02 @Nekomoe >> EP-30", + "* @SweetSub >> 花絮", + "", + ].join("\n"), + ); + // 迁出来的文本能原样编译,规则与原来等价(药屋 2 + 7 条) + const c = compileEntityWords(await text(kusuriya)); + expect(c.errors).toEqual([]); + expect(c.rules.map((r) => [r.season, r.releaseGroup, r.expression, r.targetSeason])).toEqual([ + [2, null, "EP+24", 1], + [3, null, "EP+48", 1], + ...groups.map((g) => [3, g, "EP", 1]), + ]); + expect(compileEntityWords(await text(other)).errors).toEqual([]); + const tables = await sql<{ + t: string | null; + }>`select to_regclass(${`${schema}.episode_mapping_rule`})::text as t`.execute(db); + expect(tables.rows[0]!.t).toBeNull(); + const version = await db + .selectFrom("identify_words") + .select(["version", "updated_by_type"]) + .where("media_entity_id", "=", kusuriya) + .executeTakeFirstOrThrow(); + expect(version).toEqual({ version: 1, updated_by_type: "system" }); + + // 重跑(表还在的情况):已有的行不重复,只追加新的 + await sql + .raw(`create table episode_mapping_rule ( + id uuid primary key default gen_random_uuid(), media_entity_id uuid not null, season integer, release_group text, + kind text not null, expression text, target_season integer, table_entries jsonb, special text, + status text not null default 'active', evidence jsonb not null default '{}'::jsonb, created_by_type text not null, + created_at timestamptz not null default now())`) + .execute(db); + await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 }); + await rule(kusuriya, { season: 4, expression: "EP+72", target_season: 1 }); + await up(raw()); + expect(await text(kusuriya)).toBe( + [ + "S02 >> S01 EP+24", + "S03 >> S01 EP+48", + `S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`, + "S04 >> S01 EP+72", + "", + ].join("\n"), + ); + }); +}); diff --git a/apps/server/test/sources-identity/mteam-tasks.test.ts b/apps/server/test/sources-identity/mteam-tasks.test.ts index ecb84487..65c55995 100644 --- a/apps/server/test/sources-identity/mteam-tasks.test.ts +++ b/apps/server/test/sources-identity/mteam-tasks.test.ts @@ -69,7 +69,7 @@ describe.skipIf(!up)("M-Team 快照与任务注册", () => { api.setFailMode(null); }); - it("模块注册 8 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => { + it("模块注册 9 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => { const reg = new TaskRegistry(); h.module.registerTasks(reg, h.ctx); expect(reg.names()).toEqual([ @@ -78,6 +78,7 @@ describe.skipIf(!up)("M-Team 快照与任务注册", () => { "prewarmMetadata", "refreshMetadata", "refreshSiteAccount", + "reresolveIdentifyWords", "resolveObservation", "searchSubscription", "snapshotSite", diff --git a/apps/server/test/support/identify-words.ts b/apps/server/test/support/identify-words.ts new file mode 100644 index 00000000..8acd8d7a --- /dev/null +++ b/apps/server/test/support/identify-words.ts @@ -0,0 +1,19 @@ +import type { DB, Kysely } from "@umt/db"; +import { invalidateGlobalWords } from "../../src/modules/sources-identity/service/identify-words-cache.ts"; + +// 测试里直接写识别词原文(绕过保存接口的版本检查与重算);全局识别词写完让进程内缓存失效。 + +export async function setWordsText(db: Kysely, entityId: string | null, text: string): Promise { + const cur = await (entityId + ? db.selectFrom("identify_words").select("id").where("media_entity_id", "=", entityId) + : db.selectFrom("identify_words").select("id").where("media_entity_id", "is", null) + ).executeTakeFirst(); + if (cur) + await db + .updateTable("identify_words") + .set((eb) => ({ text, version: eb("version", "+", 1) })) + .where("id", "=", cur.id) + .execute(); + else await db.insertInto("identify_words").values({ media_entity_id: entityId, text, version: 1 }).execute(); + if (!entityId) invalidateGlobalWords(); +} diff --git a/apps/web/app/api/episode-mapping.ts b/apps/web/app/api/episode-mapping.ts index 74557600..b131dcae 100644 --- a/apps/web/app/api/episode-mapping.ts +++ b/apps/web/app/api/episode-mapping.ts @@ -1,24 +1,33 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + EntityWordsDryRun, + EntityWordsList, + EntityWordsSaveResult, + EntityWordsView, EpisodeGroupList, EpisodeGroupSetResult, EpisodeMappingPreview, type EpisodeMappingPreviewInput, - type EpisodeMappingRuleCreate, - EpisodeMappingRuleList, - type EpisodeMappingRuleUpdate, - EpisodeMappingRuleWriteResult, EpisodeStructureSeasons, + GlobalWordsDryRun, + GlobalWordsSaveResult, + IdentifyWordsBlock, + type WordsTryInput, + WordsTryResult, } from "@umt/contracts"; import { api } from "~/lib/api.ts"; import { decisionKeys } from "./decisions.ts"; import { getParsed, libraryKeys, parseOrThrow } from "./library.ts"; -// 集数映射:作品的规则列表 / 增改停删(写入后服务端按作品重算)/ 模拟预览(纯读)。 +// 识别词(全局 / 作品,原文整块读写;保存前试算,作品保存后服务端按作品重算)、集数映射预览(纯读)、剧集组。 export const episodeMappingKeys = { all: ["episode-mapping"] as const, - rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const, + words: (entityId: string | null) => ["episode-mapping", "words", entityId ?? "global"] as const, + wordsList: (q: string) => ["episode-mapping", "words-list", q] as const, + dryRun: (entityId: string | null, text: string) => + ["episode-mapping", "dry-run", entityId ?? "global", text] as const, + tryLine: (input: WordsTryInput) => ["episode-mapping", "try", input] as const, preview: (entityId: string, input: EpisodeMappingPreviewInput) => ["episode-mapping", "preview", entityId, input] as const, groups: (entityId: string) => ["episode-mapping", "groups", entityId] as const, @@ -26,12 +35,94 @@ export const episodeMappingKeys = { ["episode-mapping", "structure", entityId, groupId ?? "default"] as const, }; -export function useEpisodeMappingRules(entityId: string | undefined) { +// ── 识别词 ── + +export function useGlobalWords() { return useQuery({ - queryKey: episodeMappingKeys.rules(entityId ?? ""), + queryKey: episodeMappingKeys.words(null), + queryFn: ({ signal }) => getParsed(IdentifyWordsBlock, "/identify-words/global", undefined, signal), + // 保存后后台重算有进度:进行中时每 3 秒刷一次 + refetchInterval: (q) => { + const s = q.state.data?.reresolve?.status; + return s === "queued" || s === "running" ? 3_000 : false; + }, + }); +} + +export function useEntityWords(entityId: string | null | undefined) { + return useQuery({ + queryKey: episodeMappingKeys.words(entityId ?? ""), enabled: !!entityId, queryFn: ({ signal }) => - getParsed(EpisodeMappingRuleList, `/media-entities/${entityId}/episode-mapping-rules`, undefined, signal), + getParsed(EntityWordsView, `/media-entities/${entityId}/identify-words`, undefined, signal), + }); +} + +export function useEntityWordsList(q: string) { + return useQuery({ + queryKey: episodeMappingKeys.wordsList(q), + placeholderData: keepPreviousData, + queryFn: ({ signal }) => getParsed(EntityWordsList, "/identify-words/entities", { q: q || undefined }, signal), + }); +} + +/** 试算(纯读):text 为 null 时不发 */ +export function useWordsDryRun(entityId: string | null, text: string | null) { + return useQuery({ + queryKey: episodeMappingKeys.dryRun(entityId, text ?? ""), + enabled: text !== null, + placeholderData: keepPreviousData, + queryFn: async () => + entityId + ? parseOrThrow( + EntityWordsDryRun, + await api.post(`/media-entities/${entityId}/identify-words/dry-run`, { text }), + "/media-entities/{id}/identify-words/dry-run", + ) + : parseOrThrow( + GlobalWordsDryRun, + await api.post("/identify-words/global/dry-run", { text }), + "/identify-words/global/dry-run", + ), + }); +} + +/** 一行识别词的实时示例(纯读) */ +export function useWordsTry(input: WordsTryInput | null) { + return useQuery({ + queryKey: episodeMappingKeys.tryLine(input ?? { scope: "global", line: "" }), + enabled: !!input?.line.trim(), + placeholderData: keepPreviousData, + staleTime: 60_000, + queryFn: async () => + parseOrThrow(WordsTryResult, await api.post("/identify-words/try", input), "/identify-words/try"), + }); +} + +/** 保存识别词:成功后刷新识别词、预览、事项与作品详情(季集可能变了) */ +export function useSaveWords(entityId: string | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { text: string; expectedVersion: number }) => + entityId + ? parseOrThrow( + EntityWordsSaveResult, + await api.put(`/media-entities/${entityId}/identify-words`, body), + "/media-entities/{id}/identify-words", + ) + : parseOrThrow( + GlobalWordsSaveResult, + await api.put("/identify-words/global", body), + "/identify-words/global", + ), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: episodeMappingKeys.all }); + void qc.invalidateQueries({ queryKey: decisionKeys.all }); + if (entityId) { + void qc.invalidateQueries({ queryKey: libraryKeys.detail(entityId) }); + void qc.invalidateQueries({ queryKey: libraryKeys.entity(entityId) }); + } + }, }); } @@ -53,33 +144,6 @@ export function useEpisodeMappingPreview( }); } -type WriteVars = - | { op: "create"; entityId: string; body: EpisodeMappingRuleCreate } - | { op: "update"; id: string; body: EpisodeMappingRuleUpdate } - | { op: "delete"; id: string }; - -/** 规则写入:成功后刷新规则、预览、待处理与作品详情(季集可能变了) */ -export function useEpisodeMappingWrite(entityId: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (v: WriteVars) => { - const data = - v.op === "create" - ? await api.post(`/media-entities/${v.entityId}/episode-mapping-rules`, v.body) - : v.op === "update" - ? await api.patch(`/episode-mapping-rules/${v.id}`, v.body) - : await api.del(`/episode-mapping-rules/${v.id}`); - return parseOrThrow(EpisodeMappingRuleWriteResult, data, "/episode-mapping-rules"); - }, - onSuccess: () => { - void qc.invalidateQueries({ queryKey: episodeMappingKeys.all }); - void qc.invalidateQueries({ queryKey: decisionKeys.all }); - void qc.invalidateQueries({ queryKey: libraryKeys.detail(entityId) }); - void qc.invalidateQueries({ queryKey: libraryKeys.entity(entityId) }); - }, - }); -} - // ── 剧集组(季集结构) ── /** 作品可选的 TMDB 剧集组与当前选择 */ diff --git a/apps/web/app/components/episode-mapping/decision.test.ts b/apps/web/app/components/episode-mapping/decision.test.ts index 38a202ad..6d4c0d65 100644 --- a/apps/web/app/components/episode-mapping/decision.test.ts +++ b/apps/web/app/components/episode-mapping/decision.test.ts @@ -47,8 +47,13 @@ const decision = (over: Partial = {}): Decision => }, }, options: [ - { key: "confirm_suggestion", label: "确认建议规则", description: "按 EP-30 换算到第 2 季" }, - { key: "ignore_rule", label: "忽略并建忽略规则" }, + { + key: "confirm_suggestion", + label: "确认建议规则", + description: "把「S01 @Lilith-Raws >> S02 EP-30」写进作品识别词", + input: { line: "S01 @Lilith-Raws >> S02 EP-30" }, + }, + { key: "ignore_rule", label: "忽略并写忽略规则", input: { line: "S01 @Lilith-Raws >> 忽略" } }, ], ...over, }) as Decision; @@ -59,6 +64,7 @@ describe("待处理「集数待确认」", () => { expect(v).toMatchObject({ entityId: ENTITY, season: 1, releaseGroup: "Lilith-Raws", count: 2 }); expect(v.releases.map((r) => r.source)).toEqual(["S01E40", "S01E41"]); expect(v.suggestion?.expression).toBe("EP-30"); + expect(v.suggestionLine).toBe("S01 @Lilith-Raws >> S02 EP-30"); expect(v.confirm?.key).toBe("confirm_suggestion"); expect(v.ignoreRule?.key).toBe("ignore_rule"); const none = episodeDecisionView(decision({ options: [], evidence: { mediaEntityId: ENTITY, suggestion: null } })); @@ -71,20 +77,19 @@ describe("待处理「集数待确认」", () => { ); expect(html).toContain("第 1 季 · Lilith-Raws"); expect(html).toContain("S01E40"); - expect(html).toContain("EP-30"); - expect(html).toContain("第 2 季"); + expect(html).toContain("S01 @Lilith-Raws >> S02 EP-30"); expect(html).toContain("S02E10"); expect(html).toContain("套用后 2 条都对得上"); }); - it("没有建议时提示到作品页手动建", () => { + it("没有建议时提示在作品识别词里手动写", () => { const html = renderToStaticMarkup( createElement(EpisodeDecisionBody, { view: episodeDecisionView(decision({ evidence: { mediaEntityId: ENTITY, suggestion: null } })), simulation: null, }), ); - expect(html).toContain("到作品页手动建"); + expect(html).toContain("在上面的作品识别词里手动写"); }); it("模拟结果一句话", () => { @@ -96,7 +101,7 @@ describe("待处理「集数待确认」", () => { }); const agentSuggestion = { - rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 }, + line: "S01 @Lilith-Raws >> S02 EP-30", reasons: ["40、41 的发布时间对上第 2 季第 10、11 集的播出日期"], simulation: { samples: 2, resolved: 2, passed: 2, missing: 0, regressions: 0, changed: 2, scanned: 5, ok: true }, samples: [ @@ -124,7 +129,7 @@ describe("待处理「集数待确认」", () => { it("智能体建议:取规则、理由、模拟结果与「确认智能体建议」", () => { const v = episodeDecisionView(withAgent()); - expect(v.agentSuggestion?.rule.expression).toBe("EP-30"); + expect(v.agentSuggestion?.line).toBe("S01 @Lilith-Raws >> S02 EP-30"); expect(v.agentSuggestion?.reasons).toHaveLength(1); expect(v.confirmAgent?.key).toBe("confirm_agent_suggestion"); expect(v.confirm?.key).toBe("confirm_suggestion"); @@ -132,13 +137,12 @@ describe("待处理「集数待确认」", () => { expect(episodeDecisionView(decision())).toMatchObject({ agentSuggestion: null, confirmAgent: null }); }); - it("渲染智能体建议:规则、作用域、理由、样本套用后的季集与模拟结果", () => { + it("渲染智能体建议:那一行作品识别词、理由、样本套用后的季集与模拟结果", () => { const html = renderToStaticMarkup( createElement(EpisodeDecisionBody, { view: episodeDecisionView(withAgent()), simulation: null }), ); expect(html).toContain("智能体建议"); - expect(html).toContain("偏移表达式"); - expect(html).toContain("EP-30 → 第 2 季"); + expect(html).toContain("S01 @Lilith-Raws >> S02 EP-30"); expect(html).toContain("对上第 2 季第 10、11 集的播出日期"); expect(html).toContain("S01E40 → S02E10"); expect(html).toContain("模拟:这 2 条都对得上,不影响已对上的资源"); diff --git a/apps/web/app/components/episode-mapping/decision.tsx b/apps/web/app/components/episode-mapping/decision.tsx index 658aa50f..2069d7d4 100644 --- a/apps/web/app/components/episode-mapping/decision.tsx +++ b/apps/web/app/components/episode-mapping/decision.tsx @@ -2,7 +2,6 @@ import type { Decision, DecisionOption, EpisodeMappingPreview, - EpisodeMappingRuleSpec, EpisodeResolutionView, EpisodeResultBrief, EpisodeSimulationSummary, @@ -12,25 +11,25 @@ import { useId, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { useResolveDecision } from "~/api/decisions.ts"; -import { useEpisodeMappingPreview } from "~/api/episode-mapping.ts"; +import { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { consolePath } from "~/components/console/nav.ts"; import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; -import { canonicalText, episodeCode, mappingHref, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts"; +import { canonicalText, episodeCode, mappingHref, scopeText, sourceText } from "./model.ts"; -// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议规则与它的模拟结果; -// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。 -// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则 -// (服务端建规则前按当前资源再模拟一次)。 +// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议的那一行作品识别词与它的模拟结果; +// 操作「采用」(把这一行写进作品识别词并重算)、「修改」(把这一行放进编辑器草稿)、「忽略」(可同时写一行忽略)。 +// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的那一行和理由留在事项上,「采用智能体建议」写进去 +// (服务端写之前按当前资源再模拟一次)。 export const EPISODE_CONFIRM_KEY = "confirm_suggestion"; export const EPISODE_CONFIRM_AGENT_KEY = "confirm_agent_suggestion"; export const EPISODE_IGNORE_RULE_KEY = "ignore_rule"; export interface AgentSuggestion { - rule: EpisodeMappingRuleSpec; + line: string; reasons: string[]; simulation: EpisodeSimulationSummary; samples: { title: string; source: EpisodeResolutionView["source"]; after: EpisodeResultBrief }[]; @@ -49,6 +48,8 @@ export interface EpisodeDecisionView { releases: { id: string; title: string; source: string }[]; count: number; suggestion: Suggestion | null; + /** 采用建议规则时写进作品识别词的那一行 */ + suggestionLine: string | null; agentSuggestion: AgentSuggestion | null; confirm: DecisionOption | null; confirmAgent: DecisionOption | null; @@ -80,19 +81,25 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView { count: typeof e.count === "number" ? e.count : releases.length, suggestion: s && typeof s.expression === "string" ? s : null, agentSuggestion: - a?.rule && a.simulation + typeof a?.line === "string" && a.simulation ? { ...a, reasons: Array.isArray(a.reasons) ? a.reasons : [], samples: Array.isArray(a.samples) ? a.samples : [], } : null, + suggestionLine: optionLine(d.options.find((o) => o.key === EPISODE_CONFIRM_KEY)), confirm: d.options.find((o) => o.key === EPISODE_CONFIRM_KEY) ?? null, confirmAgent: d.options.find((o) => o.key === EPISODE_CONFIRM_AGENT_KEY) ?? null, ignoreRule: d.options.find((o) => o.key === EPISODE_IGNORE_RULE_KEY) ?? null, }; } +const optionLine = (o: DecisionOption | undefined): string | null => { + const line = (o?.input as { line?: unknown } | undefined)?.line; + return typeof line === "string" ? line : null; +}; + /** 建议规则套用到同季同组近期资源的结果一句话 */ export function simulationText(p: EpisodeMappingPreview | undefined): string | null { if (!p) return null; @@ -113,11 +120,7 @@ export function agentSimulationText(s: EpisodeSimulationSummary): string { export function AgentSuggestionBody({ s }: { s: AgentSuggestion }) { return (
-
- {RULE_KIND_LABEL[s.rule.kind]}: - {ruleText(s.rule)} - {scopeText(s.rule.season, s.rule.releaseGroup)} -
+
{s.line}
{s.reasons.length ? (
    {s.reasons.map((r) => ( @@ -170,7 +173,9 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
    {view.suggestion ? ( <> - {view.suggestion.expression} → 第 {view.suggestion.targetSeason} 季 + + {view.suggestionLine ?? `${view.suggestion.expression} → 第 ${view.suggestion.targetSeason} 季`} +
      {view.suggestion.samples.slice(0, 3).map((s) => (
    • @@ -183,7 +188,7 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio {simulation ?
      {simulation}
      : null} ) : ( - 样本不够推出唯一的规则,到作品页手动建 + 样本不够推出唯一的规则,在上面的作品识别词里手动写 )}
    @@ -211,7 +216,7 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) { withRule && view.ignoreRule ? { id: d.id, optionKey: view.ignoreRule.key } : { id: d.id, dismiss: true }, ) .then( - () => toast.success(withRule ? "已忽略,并建了忽略规则" : "已忽略"), + () => toast.success(withRule ? "已忽略,并写了忽略规则" : "已忽略"), (err) => { toast.error(errorMessage(err, "忽略失败")); throw err; @@ -222,43 +227,37 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) { {view.ignoreRule ? ( ) : null} ); } -/** 集数待确认事项的证据与操作 */ -export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) { +/** + * 集数待确认事项的证据与操作。onEdit 给了(作品页集数映射里)时「修改」把这一行放进编辑器草稿,否则跳到作品页。 + */ +export function EpisodeDecision({ d, open, onEdit }: { d: Decision; open: boolean; onEdit?: (line: string) => void }) { const view = episodeDecisionView(d); const resolve = useResolveDecision(); const sug = view.suggestion; + const line = view.suggestionLine; + const words = useEntityWords(open && line ? view.entityId : null); + const current = words.data?.block.text.replace(/\n*$/, "") ?? ""; const preview = useEpisodeMappingPreview( view.entityId, - sug + line ? { season: view.season, releaseGroup: view.releaseGroup, - draft: { - season: view.season, - releaseGroup: view.releaseGroup, - kind: "expression", - expression: sug.expression, - targetSeason: sug.targetSeason, - }, + words: current ? `${current}\n${line}\n` : `${line}\n`, limit: 50, } : {}, - open && !!sug, + open && !!line && words.isSuccess, ); - const modifyHref = view.entityId - ? mappingHref(consolePath("library", view.entityId), { - season: view.season, - releaseGroup: view.releaseGroup, - ...(sug ? { expression: sug.expression, targetSeason: sug.targetSeason } : {}), - }) - : null; + const editLine = line ?? optionLine(view.ignoreRule ?? undefined); + const modifyHref = view.entityId ? mappingHref(consolePath("library", view.entityId), editLine ?? undefined) : null; return (
    @@ -272,12 +271,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) { 采用智能体建议 } - title="按智能体建议建规则?" - description={`${view.confirmAgent.description ?? ""}。规则在作品页的集数映射里可以再改。`} - confirmLabel="建规则并重算" + title="采用智能体建议?" + description={`${view.confirmAgent.description ?? ""}。写进去之后可以在作品识别词里再改。`} + confirmLabel="写入并重算" onConfirm={() => resolve.mutateAsync({ id: d.id, optionKey: view.confirmAgent!.key }).then( - () => toast.success("已建规则并重算"), + () => toast.success("已写进作品识别词并重算"), (err) => { toast.error(errorMessage(err, "处理失败")); throw err; @@ -294,12 +293,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) { 采用建议规则 } - title="按建议建规则?" - description={`${view.confirm.description ?? ""}。规则在作品页的集数映射里可以再改。`} - confirmLabel="建规则并重算" + title="采用建议规则?" + description={`${view.confirm.description ?? ""}。写进去之后可以在作品识别词里再改。`} + confirmLabel="写入并重算" onConfirm={() => resolve.mutateAsync({ id: d.id, optionKey: view.confirm!.key }).then( - () => toast.success("已建规则并重算"), + () => toast.success("已写进作品识别词并重算"), (err) => { toast.error(errorMessage(err, "处理失败")); throw err; @@ -308,7 +307,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) { } /> ) : null} - {modifyHref ? ( + {onEdit && editLine ? ( + + ) : modifyHref ? ( - {rule.status === "active" ? ( - - ) : ( - - )} - - - - } - title="删除这条规则?" - description={`删除后按作品重算:「${scopeText(rule.season, rule.releaseGroup)}」的资源回到自动判定。不能恢复。`} - confirmLabel="删除" - destructive - onConfirm={() => run({ op: "delete", id: rule.id }, "已删除")} - /> -
    - - ); -} +const pad = (n: number) => String(n).padStart(2, "0"); -function DraftEditor({ - draft, - onChange, - error, - groups, -}: { - draft: RuleDraft; - onChange: (d: RuleDraft) => void; - error: string | null; - groups: string[]; -}) { - const id = useId(); - const f = (k: string) => `${id}-${k}`; - const set = (patch: Partial) => onChange({ ...draft, ...patch }); - return ( -
    - - set({ season: e.target.value })} - /> - - - set({ releaseGroup: e.target.value })} - /> - - {groups.map((g) => ( - - - - - - {draft.kind === "expression" ? ( - - set({ targetSeason: e.target.value })} - /> - - ) : draft.kind === "special" ? ( - - - - ) : ( -
    - )} - {draft.kind === "expression" ? ( - - set({ expression: e.target.value })} - aria-invalid={!!error} - /> - - ) : draft.kind === "table" ? ( - -