import type { ReleaseSearchQuery } from "@umt/contracts"; import { normalizeTitle, parseRelease } from "@umt/domain"; import { describe, expect, it } from "vitest"; import { type BaseRelease, quickFilter } from "../../src/modules/search/releases.ts"; import { belongsToWork, type ReleaseLink, type WorkTarget } from "../../src/modules/search/work-match.ts"; import samples from "../fixtures/search-apothecary.json" with { type: "json" }; // 按作品搜资源的归属判定(对照 MP TorrentHelper.match_torrent)。样本是站点搜索「药屋少女的呢喃」的真实结果(109 条), // 按「刚搜到、还没识别」处理:只靠名字 / 类型 / 年份 / 季集判定。 const base = (o: { id: string; title: string; subtitle: string | null; category: string | null }): BaseRelease => { const p = parseRelease(o.title, o.subtitle ? { subtitle: o.subtitle } : {}); return { key: `s:${o.id}`, sourceReleaseId: null, siteId: "00000000-0000-4000-8000-000000000001", siteName: "站", siteReleaseId: o.id, title: o.title, subtitle: o.subtitle, category: { id: null, name: o.category }, sizeBytes: null, seeders: 1, leechers: 0, snatched: 0, promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null }, free: false, hr: "unknown", uploadedAt: null, detailUrl: "http://x/", observedAt: "2026-10-06T00:00:00.000Z", parsed: { titleCn: p.titleCn ?? null, titleEn: p.titleEn ?? null, year: p.year ?? null, season: p.season ?? null, seasonEnd: p.seasonEnd ?? null, episodes: p.episodes ? { start: p.episodes.start, end: p.episodes.end } : null, resolution: p.resolution ?? null, source: p.source ?? null, videoCodec: p.videoCodec ?? null, hdr: [...p.hdr], releaseGroup: p.releaseGroup?.name ?? null, audio: [...p.audio], }, }; }; const set = (xs: string[]) => new Set(xs.map(normalizeTitle)); // 本地作品与别名表(取自真实库里这部作品的 TMDB 别名) const apothecary: WorkTarget = { entityId: "e-apothecary", tmdb: "tv:220542", movie: false, titles: set(["药屋少女的呢喃", "薬屋のひとりごと"]), names: set([ "The Apothecary Diaries", "药师少女的独语", "Kusuriya no Hitorigoto", "Kusuriya no Hitorigoto 2nd Season", "薬屋のひとりごと(第3期)", "Apothecary's Soliloquy", "Les Carnets de l'apothicaire", ]), year: 2023, seasons: [ { season: 1, year: 2023, episodes: 24 }, { season: 2, year: 2025, episodes: 24 }, { season: 3, year: 2026, episodes: null }, ], }; const rows = samples.map(base); const byId = (id: string) => rows.find((r) => r.siteReleaseId === id)!; const ALBUM = "926704"; // Channel U(《迷宫饭》片尾曲专辑,简介里提到这部作品的片头曲) const S01_S24 = "1012256"; // 标题写成 S01-S24(实为 24 集),超出作品的季范围 const out = (t: WorkTarget, links: Map = new Map()) => rows.filter((r) => !belongsToWork(t, r, links.get(r.key) ?? null)).map((r) => r.siteReleaseId); describe("按作品判定归属(未识别,真实样本)", () => { it("109 条里只有专辑与季范围不可能的那条不属于;其余(S01 / S02 / S03、Vol 原盘、无年份标题)都属于", () => { expect(samples).toHaveLength(109); expect(out(apothecary).sort()).toEqual([S01_S24, ALBUM].sort()); // 逐类抽查不被误伤 for (const [id, why] of [ ["1139946", "S01E25-E48 绝对集号"], ["728125", "S03E01"], ["954567", "S02 自购自抓原盘,中文名解析成「自购自抓自扫」,靠英文名 / 副标题"], ["1021945", "Vol.3 BDMV,英文名带 Vol 3、没识别"], ["768303", "Vol 2 原盘"], ["994487", "没有年份没有季号(VCB BDRip)"], ["1157450", "S02 2026(蓝光发行年份)"], ] as const) { const r = byId(id); expect(r, why).toBeTruthy(); expect(belongsToWork(apothecary, r, null), `${why}:${r.title}`).toBe(true); } }); it("没拿到 TMDB 季信息时不核对季集范围,只排除专辑", () => { expect(out({ ...apothecary, seasons: null })).toEqual([ALBUM]); }); it("专辑没有分类也靠名字排除:简介里『』括起的作品名不算命中", () => { const album = { ...byId(ALBUM), category: { id: null, name: null } }; expect(belongsToWork(apothecary, album, null)).toBe(false); }); it("已识别:同一作品或同一 TMDB 身份属于;识别到别的作品不属于(哪怕名字对得上)", () => { const album = byId(ALBUM); const s1 = rows[0]!; expect(belongsToWork(apothecary, album, { entityId: "e-dungeon", tmdb: "tv:209867" })).toBe(false); expect(belongsToWork(apothecary, s1, { entityId: "e-dungeon", tmdb: null })).toBe(false); expect(belongsToWork(apothecary, s1, { entityId: "e-apothecary", tmdb: null })).toBe(true); expect(belongsToWork(apothecary, byId(S01_S24), { entityId: "e-apothecary", tmdb: null })).toBe(true); // 本地有重复建档,但 TMDB 身份相同 expect(belongsToWork(apothecary, s1, { entityId: "e-dup", tmdb: "tv:220542" })).toBe(true); // 只在 TMDB 上的作品:按 TMDB 身份比 const tmdbOnly = { ...apothecary, entityId: null }; expect(belongsToWork(tmdbOnly, s1, { entityId: "e-any", tmdb: "tv:220542" })).toBe(true); expect(belongsToWork(tmdbOnly, s1, { entityId: "e-any", tmdb: null })).toBe(false); }); it("不属于的不进筛选与分面:原盘只剩这部作品的", () => { const mine = rows.filter((r) => belongsToWork(apothecary, r, null)); const q = { sort: "default", order: "desc", limit: 50, qf: "releaseType:disc" } as ReleaseSearchQuery; const all = quickFilter(rows, q, Date.now()); const kept = quickFilter(mine, q, Date.now()); expect(all.items.map((r) => r.siteReleaseId)).toContain(ALBUM); expect(kept.items.map((r) => r.siteReleaseId)).not.toContain(ALBUM); // 「自购自抓自扫」那条按原盘计 expect(kept.items.map((r) => r.siteReleaseId)).toContain("954567"); expect(kept.items).toHaveLength(all.items.length - 1); }); }); describe("按作品判定归属(规则)", () => { const mk = (title: string, subtitle: string | null = null, category: string | null = null) => base({ id: title, title, subtitle, category }); it("别的作品:名字对不上的不属于(迷宫饭)", () => { expect( belongsToWork(apothecary, mk("Delicious in Dungeon S01 2024 1080p NF WEB-DL", "迷宫饭 / ダンジョン飯"), null), ).toBe(false); }); it("剧集:年份与任一季首播相差 1 年以内;季号超出范围不属于;集号可按绝对集号", () => { expect(belongsToWork(apothecary, mk("Kusuriya no Hitorigoto S01 2019 1080p WEB-DL"), null)).toBe(false); expect(belongsToWork(apothecary, mk("Kusuriya no Hitorigoto S01 2024 1080p BluRay x265"), null)).toBe(true); expect(belongsToWork(apothecary, mk("Kusuriya no Hitorigoto S05 1080p WEB-DL"), null)).toBe(false); expect(belongsToWork(apothecary, mk("Kusuriya no Hitorigoto S01E30 1080p WEB-DL"), null)).toBe(true); expect(belongsToWork(apothecary, mk("Kusuriya no Hitorigoto S01E60 1080p WEB-DL"), null)).toBe(false); // 没有季信息时,只排除早于首播一年以上的 const noSeasons = { ...apothecary, seasons: null }; expect(belongsToWork(noSeasons, mk("Kusuriya no Hitorigoto 2030 1080p WEB-DL"), null)).toBe(true); expect(belongsToWork(noSeasons, mk("Kusuriya no Hitorigoto 2019 1080p WEB-DL"), null)).toBe(false); }); it("名字:标题拆词只比非英文词与作品名 / 原名;副标题拆词也比别名;英文单词不拆着比", () => { // 标题里的中文名 expect(belongsToWork(apothecary, mk("[药屋少女的呢喃][S02E05][1080p]"), null)).toBe(true); // 副标题里的别名 expect(belongsToWork(apothecary, mk("KnH 2023 S01 1080p WEB-DL", "药师少女的独语 | 全24集"), null)).toBe(true); // 英文单词片段不算 expect(belongsToWork(apothecary, mk("Diaries of a Pharmacist S01 2023 1080p WEB-DL", "The Diaries"), null)).toBe( false, ); // 非音视频分类 expect(belongsToWork(apothecary, mk("Kusuriya no Hitorigoto OST 2023 FLAC", null, "音乐"), null)).toBe(false); }); it("电影:年份上下 1 年,没写年份不属于(MP 同);剧集资源或剧集分类不属于电影", () => { const dune: WorkTarget = { entityId: "e-dune", tmdb: "movie:693134", movie: true, titles: set(["沙丘2", "Dune: Part Two"]), names: set(["Dune 2", "沙丘:第二部"]), year: 2024, seasons: null, }; expect(belongsToWork(dune, mk("Dune.Part.Two.2024.2160p.WEB-DL"), null)).toBe(true); expect(belongsToWork(dune, mk("Dune.Part.Two.2025.2160p.BluRay"), null)).toBe(true); expect(belongsToWork(dune, mk("Dune.Part.Two.2160p.WEB-DL"), null)).toBe(false); expect(belongsToWork(dune, mk("Dune 1984 1080p BluRay", "沙丘2 前作"), null)).toBe(false); expect(belongsToWork(dune, mk("Dune Part Two S01 2024 1080p WEB-DL"), null)).toBe(false); expect(belongsToWork(dune, mk("Dune.Part.Two.2024.1080p.WEB-DL", null, "剧集"), null)).toBe(false); expect(belongsToWork(dune, mk("Some Movie 2024 1080p WEB-DL", "沙丘2"), null)).toBe(true); }); });