From 6e71df276977ec13aaa550e66ffa5d3a2b2c1871 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 15:33:58 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(search):=20=E7=AB=99=E7=82=B9=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=A4=B1=E8=B4=A5=E3=80=81=E8=B6=85=E6=97=B6=E3=80=81?= =?UTF-8?q?=E8=A2=AB=E6=8B=A6=E6=88=AA=E7=9A=84=E7=BB=93=E6=9E=9C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=86=99=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前失败结果也缓存 30 秒,只在一轮全部站点失败时才撤销;某站一次失败后, 后续几次搜索都复用同一个失败。现在只缓存成功结果,失败 / 超时 / 跳过下次直接重试。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/server/src/modules/search/releases.ts | 10 ++------- apps/server/test/search/search.test.ts | 24 +++++++++++++++------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index 220019f7..5d4d2769 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -79,13 +79,12 @@ export class ReleaseFanout { /** * 在给定站点上搜一组查询词:每站每词一次请求(同站多个词的结果按站点资源 ID 去重)。 - * 缓存按(站点, 查询词):失败 / 超时也缓存,但一轮里全部站点都失败时不缓存(下次直接重试)。 + * 缓存按(站点, 查询词):只缓存成功的结果;失败 / 超时 / 验证拦截 / 跳过不写缓存,下次搜索直接重试。 */ async search(terms: string[], sites: SiteRow[], opts: { refresh?: boolean } = {}): Promise { const words = [...new Map(terms.map((t) => [cacheKey(t), t.trim()])).entries()].filter(([k]) => k); let cached = true; let coalesced = false; - const fresh: string[] = []; const perSite = await mapLimit(sites, Math.max(1, this.opts.concurrency), async (site) => { const parts: SiteResult[] = []; for (const [k, term] of words) { @@ -105,16 +104,11 @@ export class ReleaseFanout { const p = this.searchSite(this.rt(), site, term).finally(() => this.inflight.delete(key)); this.inflight.set(key, p); const r = await p; - this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, result: r }); - fresh.push(key); + if (r.coverage.status === "ok") this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, result: r }); parts.push(r); } return mergeSite(site, parts); }); - // 本轮新查的站点全部失败:撤掉这些缓存 - const freshResults = fresh.map((k) => this.cache.get(k)?.result).filter((r): r is SiteResult => !!r); - if (freshResults.length && freshResults.every((r) => r.coverage.status !== "ok")) - for (const k of fresh) this.cache.delete(k); this.prune(); const fetched = perSite.map((r) => r.fetchedAt); return { diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index 45c449ef..efadc6af 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -289,13 +289,20 @@ describe.skipIf(!up)("全局搜索", () => { expect(rows.length).toBe(3); }); - it("30s 内相同查询命中缓存,不再请求站点", async () => { + it("30s 内相同查询:成功的站点命中缓存不再请求;超时的站点不缓存,每次重试", async () => { const before = [nexusSearches(), mteamSearches(), slowCalls]; const r = await req("/search?q=%20dune%20&scope=releases"); - expect(r.body.data.releases).toMatchObject({ cached: true, total: 3, partial: true }); + // 超时的站点重新查了一次,所以整体不算缓存结果 + expect(r.body.data.releases).toMatchObject({ cached: false, total: 3, partial: true }); + expect([nexusSearches(), mteamSearches()]).toEqual(before.slice(0, 2)); + expect(slowCalls).toBe(before[2]! + 1); const page = await req("/search/releases?q=Dune"); - expect(page.body.data).toMatchObject({ cached: true, total: 3 }); - expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before); + expect(page.body.data).toMatchObject({ cached: false, total: 3, partial: true }); + expect([nexusSearches(), mteamSearches()]).toEqual(before.slice(0, 2)); + expect(slowCalls).toBe(before[2]! + 2); + // 只查成功过的站点:全部命中缓存 + const okOnly = await req(`/search/releases?q=Dune&sites=${sites.nexus},${sites.mteam}`); + expect(okOnly.body.data).toMatchObject({ cached: true, total: 3, partial: false }); // refresh=true 跳过缓存 const fresh = await req("/search/releases?q=Dune&refresh=true"); expect(fresh.body.data.cached).toBe(false); @@ -387,7 +394,9 @@ describe.skipIf(!up)("全局搜索", () => { }); it("筛选 / 排序 / 游标 / 分面", async () => { - const q = (s: string) => req(`/search/releases?q=Dune&${s}`).then((r) => r.body.data); + // 只查成功的两站(超时站点不缓存,每次重试会拖慢用例) + const ok = `sites=${sites.nexus},${sites.mteam}`; + const q = (s: string) => req(`/search/releases?q=Dune&${ok}&${s}`).then((r) => r.body.data); let d = await q("resolution=2160p"); expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["812345", "990001"]); expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 }); @@ -399,7 +408,7 @@ describe.skipIf(!up)("全局搜索", () => { d = await q("source=web-dl,REMUX&free=false"); expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["900001", "990001"]); - d = await q(`sites=${sites.mteam}`); + d = (await req(`/search/releases?q=Dune&sites=${sites.mteam}`)).body.data; expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]); d = await q(`minSize=${10 * 1024 ** 3}&maxSize=${50 * 1024 ** 3}`); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]); @@ -420,8 +429,9 @@ describe.skipIf(!up)("全局搜索", () => { }); it("快速筛选 qf(SEARCH-3):分面计数、同维度 OR / 跨维度 AND、分页一致", async () => { + const ok = `sites=${sites.nexus},${sites.mteam}`; const q = (qf: string, extra = "") => - req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data); + req(`/search/releases?q=Dune&${ok}&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data); const counts = (opts: Loose[]) => Object.fromEntries(opts.map((o) => [o.value, o.count])); const ids = (d: Loose) => d.items.map((i: Loose) => i.siteReleaseId).sort(); From 1845d47d3ffca004de419ceec70b13911799ed6d Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 15:34:33 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(search):=20=E6=8C=89=E4=BD=9C=E5=93=81?= =?UTF-8?q?=E6=90=9C=E8=B5=84=E6=BA=90=E6=97=B6=E4=B8=AD=E6=96=87=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E5=90=8D=E5=9C=A8=E5=89=8D=E3=80=81=E5=8E=9F=E5=90=8D?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 国内站点按中文名收录、副标题也多写中文名,查询词改为作品名(中文)优先、原名第二; 作品名不含中日韩文字时仍原名在前。仍最多 2 个、按归一化去重,拉丁名在电影上带年份。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/server/src/modules/search/works.ts | 21 +++++++++----- .../test/search/two-stage-search.test.ts | 29 +++++++++++++------ 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/server/src/modules/search/works.ts b/apps/server/src/modules/search/works.ts index df0974fb..6d9be215 100644 --- a/apps/server/src/modules/search/works.ts +++ b/apps/server/src/modules/search/works.ts @@ -204,8 +204,9 @@ const emptyInventory = (entityId: string): InventoryEntry => ({ /** * 搜资源的查询词(每个站点每个词查一次,最多 2 个,控制对站点的请求数): - * 原名(电影带年份,站点标题多为「原名 年份 …」)+ 译名(与原名归一化后不同才加)。 - * 剧集不带年份(各季年份不同)。 + * 国内站点按中文名收录、副标题也多写中文名,所以作品名(中文)在前,原名第二(与作品名归一化后不同才加)。 + * 拉丁文字的名字在电影上带年份(站点标题多为「原名 年份 …」),只带一个;剧集不带年份(各季年份不同)。 + * 作品名不含中日韩文字(没有中文译名)时原名在前。 */ export function searchTerms(w: { title: string; @@ -215,17 +216,23 @@ export function searchTerms(w: { }): string[] { const out: string[] = []; const seen = new Set(); - const add = (t: string | null, withYear: boolean) => { + const latin = (s: string | null) => !!s && /^[\p{Script=Latin}\d\s\p{P}\p{S}]+$/u.test(s); + const add = (t: string | null) => { const s = t?.trim(); if (!s) return; const n = normalizeTitle(s); if (!n || seen.has(n)) return; seen.add(n); - out.push(withYear && w.year ? `${s} ${w.year}` : s); + const withYear = w.movie && !!w.year && latin(s) && !out.some(latin); + out.push(withYear ? `${s} ${w.year}` : s); }; - const latin = (s: string | null) => !!s && /^[\p{Script=Latin}\d\s\p{P}\p{S}]+$/u.test(s); - add(w.originalTitle, w.movie && latin(w.originalTitle)); - add(w.title, w.movie && latin(w.title) && out.length === 0); + if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(w.title)) { + add(w.title); + add(w.originalTitle); + } else { + add(w.originalTitle); + add(w.title); + } return out.slice(0, 2); } diff --git a/apps/server/test/search/two-stage-search.test.ts b/apps/server/test/search/two-stage-search.test.ts index 3ff572d3..d65a92f3 100644 --- a/apps/server/test/search/two-stage-search.test.ts +++ b/apps/server/test/search/two-stage-search.test.ts @@ -32,21 +32,32 @@ const up = await pgAvailable(); type Loose = any; describe("searchTerms", () => { - it("电影:原名带年份 + 译名;同名只留一个;剧集不带年份", () => { + it("中文作品名在前、原名第二;拉丁名在电影上带年份;同名只留一个;剧集不带年份", () => { expect(searchTerms({ title: "星际穿越", originalTitle: "Interstellar", year: 2014, movie: true })).toEqual([ - "Interstellar 2014", "星际穿越", + "Interstellar 2014", ]); expect(searchTerms({ title: "流浪地球2", originalTitle: "流浪地球2", year: 2023, movie: true })).toEqual([ "流浪地球2", ]); expect(searchTerms({ title: "熊家餐馆", originalTitle: "The Bear", year: 2022, movie: false })).toEqual([ - "The Bear", "熊家餐馆", + "The Bear", ]); expect(searchTerms({ title: "Oppenheimer", originalTitle: null, year: 2023, movie: true })).toEqual([ "Oppenheimer 2023", ]); + // 日文原名:中文名在前,原名第二 + expect( + searchTerms({ title: "药屋少女的呢喃", originalTitle: "薬屋のひとりごと", year: 2023, movie: false }), + ).toEqual(["药屋少女的呢喃", "薬屋のひとりごと"]); + // 繁简只差字形的算同一个 + expect(searchTerms({ title: "无间道", originalTitle: "無間道", year: 2002, movie: true })).toEqual(["无间道"]); + // 作品名没有中文译名(仍是英文)时原名在前 + expect( + searchTerms({ title: "The Apothecary Diaries", originalTitle: "薬屋のひとりごと", year: 2023, movie: false }), + ).toEqual(["薬屋のひとりごと", "The Apothecary Diaries"]); + expect(searchTerms({ title: " ", originalTitle: null, year: null, movie: false })).toEqual([]); }); }); @@ -287,18 +298,18 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => { await db.deleteFrom("settings").where("key", "=", "search").execute(); }); - it("按作品搜资源:服务端用原名 + 年份 / 译名生成查询词,每站每词一次", async () => { + it("按作品搜资源:服务端用中文名 / 原名 + 年份生成查询词,每站每词一次", async () => { const before = counts(); const r = await req(`/search/releases?tmdb=movie:693134&sites=${sites.a}`); expect(r.status).toBe(200); - expect(r.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]); - expect(r.body.data.q).toBe("Dune: Part Two 2024"); + expect(r.body.data.queries).toEqual(["沙丘2", "Dune: Part Two 2024"]); + expect(r.body.data.q).toBe("沙丘2"); const sent = searches(a).slice(before[0]); expect(sent).toHaveLength(2); expect(r.body.data.sites[0]).toMatchObject({ siteId: sites.a, status: "ok" }); const byEntity = await req(`/search/releases?entityId=${dune}&sites=${sites.a}`); - expect(byEntity.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]); + expect(byEntity.body.data.queries).toEqual(["沙丘2", "Dune: Part Two 2024"]); expect(byEntity.body.data.cached).toBe(true); expect((await req(`/search/releases?sites=${sites.a}`)).status).toBe(422); @@ -320,7 +331,7 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => { inventory: { state: "not_collected" }, mediaServers: null, seasons: [], - queries: ["Oppenheimer 2023", "奥本海默"], + queries: ["奥本海默", "Oppenheimer 2023"], externalIds: { imdb: "tt15398776" }, }); @@ -338,7 +349,7 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => { [2, 10, null], [3, 10, null], ]); - expect(tv.body.data.queries).toEqual(["The Bear", "熊家餐馆"]); + expect(tv.body.data.queries).toEqual(["熊家餐馆", "The Bear"]); expect((await req(`/media/tmdb/movie/999999999`)).status).toBe(404); expect((await req(`/media/tmdb/person/1`)).status).toBe(422); From 18ee46666465c0c4f93f012120c843a923d399d3 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 15:36:15 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(parser):=20=E5=8E=9F=E7=9B=98=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E5=8F=82=E8=80=83=E5=89=AF=E6=A0=87=E9=A2=98=E7=BA=BF?= =?UTF-8?q?=E7=B4=A2=E4=B8=8E=20PCM=20=E9=9F=B3=E8=BD=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 标题写成 `Bluray H.264` 这类压制字样、但副标题写「自购自抓自扫」「BDMV」「原盘」「DIY」 「Blu-ray Disc」的,或带 LPCM / PCM 音轨又没有 x264 / x265 / BDRip / 压制说法的,判为原盘。 只在来源是 BluRay(或没写来源但有分辨率)时补判;「原盘压制」「自压」、WEB-DL、Remux、单个视频文件不受影响。 用「药屋少女的呢喃」真实搜索结果补正例与反例。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- packages/domain/src/release-parser/parse.ts | 36 ++++++- packages/domain/test/release-parser.test.ts | 107 ++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/release-parser/parse.ts b/packages/domain/src/release-parser/parse.ts index 5b581531..0b01f9e4 100644 --- a/packages/domain/src/release-parser/parse.ts +++ b/packages/domain/src/release-parser/parse.ts @@ -45,6 +45,8 @@ type Draft = ParsedRelease & { _fin?: boolean; _disc?: boolean; _remux?: boolean; + /** 标题里有 BDRip / BRRip(压制) */ + _rip?: boolean; /** `{[k=v;…]}` 显式身份块里的季/集/类型,finalize 时覆盖推断值 */ _explicit?: { season?: number; seasonEnd?: number; episodes?: EpisodeRange; tv?: boolean }; }; @@ -746,6 +748,7 @@ function walkTokens(tokens: string[], d: Draft): void { const cnTitle = cn.join(" ").trim(); if (enTitle) d.titleEn = enTitle; if (cnTitle) d.titleCn = cnTitle; + if (ripWord) d._rip = true; if (d._remux) d.source = "REMUX"; else if (d._disc) d.source = "BluRay-Disc"; else if (blurayWord && discCodec && !encodeCodec && !ripWord && !d.encoder && sources.size === 1) { @@ -1077,9 +1080,40 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele } } + // 14. 原盘线索:标题的编码字样不足以判定时,参考副标题 / 简介里的明确说法与无损 PCM 音轨 + refineDisc(d, s, subtitle); + return finalize(d, opts); } +/** 明确说是原盘:原盘(不含「原盘压制」)、BDMV、自购自抓 / 自抓自扫、Blu-ray Disc、DIY(原盘 DIY) */ +const DISC_CUE = + /原[盘盤](?![\s::]*(?:压|壓|rip|转|轉))|BDMV|自[购購]自抓|自抓自[扫掃]|Blu-?ray[\s.]+Disc|(? /^L?PCM\b/i.test(a)); + if (DISC_CUE.test(text)) { + d.source = "BluRay-Disc"; + d.evidence.push("source:disc_hint"); + } else if (pcm) { + d.source = "BluRay-Disc"; + d.evidence.push("source:disc_pcm"); + } +} + function applyCnMarkers(d: Draft, cm: CnMarkers, prefix: string): void { const ev = d.evidence; const note = (k: string) => { @@ -1171,7 +1205,7 @@ function finalize(d: Draft, opts: ParseOptions): ParsedRelease { d.title = d.titleCn ?? d.titleEn; d.confidence = confidenceOf(d); - const { _complete, _fin, _disc, _remux, _explicit, ...out } = d; + const { _complete, _fin, _disc, _remux, _rip, _explicit, ...out } = d; return out; } diff --git a/packages/domain/test/release-parser.test.ts b/packages/domain/test/release-parser.test.ts index 0e2d4691..670989a8 100644 --- a/packages/domain/test/release-parser.test.ts +++ b/packages/domain/test/release-parser.test.ts @@ -627,6 +627,113 @@ describe("MP 差分补迁写法", () => { }); }); +// 原盘补判(SEARCH 准确度):站点搜索「药屋少女的呢喃」的真实结果。标题写成压制字样、但副标题说「自购自抓自扫」的是原盘; +// LPCM / PCM 音轨且没有 x264 / x265 / BDRip / 压制说法的也是原盘。普通压制、WEB-DL、Remux 不受影响。 +const DISC_HINTS: Sample[] = [ + { + id: "DISC-sub-self-rip", + input: "Kusuriya no Hitorigoto 2025 S02 1080p Bluray H.264 LPCM-@U2", + subtitle: "自购自抓自扫 | Team HAYAKU | Animate特典附 | TBR35041D 药屋少女的呢喃 第二季", + expect: { source: "BluRay-Disc", isEncode: false }, + }, + { + id: "DISC-sub-bdmv", + input: "The Apothecary Diaries 2025 S02 1080p Blu-ray H.264-GRP", + subtitle: "药屋少女的呢喃 第二季 BDMV Vol.1", + expect: { source: "BluRay-Disc" }, + }, + { + id: "DISC-sub-yuanpan", + input: "Some Show S01 1080p BluRay H.264 DTS-HD MA 5.1-GRP", + subtitle: "某剧 第一季 原盘 中字", + expect: { source: "BluRay-Disc" }, + }, + { + id: "DISC-pcm", + input: "Some Movie 2020 1080p BluRay LPCM 2.0-GRP", + expect: { source: "BluRay-Disc", isEncode: false }, + }, + // 已按编码字样判成原盘的不变 + { + id: "DISC-avc-lpcm", + input: "Kusuriya no Hitorigoto S02 2025 Blu-ray 1080p AVC LPCM 2Audio-@U2", + subtitle: "Team HAYAKU | Animate全卷特典附 药屋少女的呢喃 第二季", + expect: { source: "BluRay-Disc" }, + }, + { + id: "DISC-bdmv-rip-welcome", + input: "The Apothecary Diaries Vol.3 2025 BDMV 1080p AVC LPCM 2.0-Arri-RAWS@U2", + subtitle: "U2 Exclusive, RIPs welcomed with credits | 制作RIP请注明出处 | 药屋少女的呢喃/药师少女的独语 S2", + expect: { source: "BluRay-Disc" }, + }, + // 反例:压制 + { + id: "ENC-self-encode", + input: "Kusuriya no Hitorigoto 2023 BDRip 1080p AVC FLAC@U2", + subtitle: "药师少女的呢喃/Kusuriya no Hitorigoto/薬屋のひとりごと 1080p 自压(附相关专辑) TV 01-24 Fin+SP", + expect: { source: "BluRay", isEncode: true }, + }, + { + id: "ENC-x265", + input: "The Apothecary Diaries S01 2023 1080p BluRay x265 10bit FLAC 2.0-ADE", + subtitle: "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries / 薬屋のひとりごと 【简日|繁日|简|繁|日字幕】", + expect: { source: "BluRay", isEncode: true }, + }, + { + id: "ENC-x264", + input: "Kusuriya no Hitorigoto S01 Complete 2023 1080p Blu-ray x264 FLAC-jsum@U2", + subtitle: "药屋少女的呢喃 / 药师少女的独语 / 薬屋のひとりごと", + expect: { source: "BluRay", isEncode: true }, + }, + { + id: "ENC-av1", + input: "Kusuriya no Hitorigoto S02 1080p BluRay AV1 OPUS 2.0-7³ACG@OurBits", + subtitle: "药师少女的独语第2季/薬屋のひとりごと 第2期 | 01-24(25-48) 日语+简繁字幕", + expect: { source: "BluRay", isEncode: true }, + }, + { id: "ENC-x264-lpcm", input: "Some Movie 2020 1080p BluRay x264 LPCM 2.0-GRP", expect: { source: "BluRay" } }, + { + id: "ENC-from-disc", + input: "Some Movie 2020 1080p BluRay H.264 FLAC-GRP", + subtitle: "基于原盘压制 简繁字幕", + expect: { source: "BluRay" }, + }, + { + id: "ENC-pcm-encode-note", + input: "Some Movie 2020 1080p BluRay H.264 LPCM-GRP", + subtitle: "自压 内封字幕", + expect: { source: "BluRay" }, + }, + // 反例:其他来源不受副标题影响 + { + id: "WEB-sub-yuanpan", + input: "Some Movie 2020 1080p WEB-DL H.264 AAC-GRP", + subtitle: "原盘 DIY 还没出,先发 WEB", + expect: { source: "WEB-DL" }, + }, + { + id: "REMUX-diy", + input: "The Apothecary Diaries S01 2023 1080p BluRay Remux AVC FLAC 2.0-ADE", + subtitle: "药屋少女的呢喃 【DIY / 双语简繁字幕】", + expect: { source: "REMUX" }, + }, + // 标题里写着 2160P 的非发布名(播放器解码信息):按字面解析,不猜 + { + id: "WEB-2160-literal", + input: + "The Apothecary Diaries 2023 S01E24 2160P TV WEB-DL MPEG-4 AVC1 - Native D3D9 DXVA Decoder VLD AAC LC -Kisssub", + subtitle: + "药屋少女的呢喃 薬屋のひとりごと (2023) 2160P TVS01 [01-24](AI增强1080P转2160P) AVC1 @5160kbps AAC @128kbps", + expect: { source: "WEB-DL", resolution: "2160p" }, + }, +]; + +describe("原盘补判(副标题线索 / PCM 音轨)", () => { + for (const s of DISC_HINTS) { + it(`${s.id} ${s.input}`, () => check(parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {}), s.expect)); + } +}); + describe("parseRelease 样本表", () => { it("样本数量 ≥ 60", () => { expect(TITLES.length).toBeGreaterThanOrEqual(60); From f6d976a963b410a1c1b682f0d47d68e519bca947 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 15:39:32 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(search):=20=E7=AD=9B=E9=80=89=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E5=8F=AA=E5=88=97=E7=BB=93=E6=9E=9C=E9=87=8C=E6=9C=89?= =?UTF-8?q?=E7=9A=84=E5=80=BC=EF=BC=8C=E5=AD=A3=E9=9B=86=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=88=90=E4=B8=80=E4=B8=AA=E7=BB=B4=E5=BA=A6=E6=94=BE=E6=9C=80?= =?UTF-8?q?=E5=89=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 MP useTorrentFilter:分面选项按实际结果生成,计数为 0 的不再列出(已选的值仍列出,可删除); 新增季集维度 seasonEpisode(MP season_episode 写法 `S01` / `S01-S02` / `S02 E14` / `S01 E25-E48`), 整季在前、单集在后、季号新的在前,在筛选条最前。旧的季 / 集维度保留(旧链接仍生效,以标签列出)。 搜索结果页与站点资源 Tab 共用这套口径。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/server/test/search/search.test.ts | 14 +-- .../components/search/quick-filter-bar.tsx | 5 +- .../search/quick-filter-state.test.ts | 24 ++-- .../components/search/quick-filter-state.ts | 29 +++-- packages/contracts/src/release-facets.ts | 6 +- packages/domain/src/release-facets/index.ts | 103 ++++++++++++------ packages/domain/test/release-facets.test.ts | 91 +++++++++++----- 7 files changed, 181 insertions(+), 91 deletions(-) diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index efadc6af..d8371e38 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -437,14 +437,16 @@ describe.skipIf(!up)("全局搜索", () => { let d = await q(""); expect(d.total).toBe(3); - expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1, "720p": 0 }); - expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0 }); - expect(counts(d.facets.releaseType)).toEqual({ disc: 0, remux: 1, encode: 0, webdl: 2, hdtv: 0 }); + // 只列结果里有的档位 + expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1 }); + expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1 }); + expect(counts(d.facets.releaseType)).toEqual({ remux: 1, webdl: 2 }); expect(counts(d.facets.site)).toEqual({ [sites.nexus]: 2, [sites.mteam]: 1 }); expect(d.facets.site.find((f: Loose) => f.value === sites.nexus).label).toBe("观众"); expect(counts(d.facets.releaseGroup)).toEqual({ FraMeSToR: 1, GRP: 1, FLUX: 1 }); expect(d.facets.hdr.find((f: Loose) => f.value === "SDR").count).toBe(2); expect(d.facets.season).toEqual([]); + expect(d.facets.seasonEpisode).toEqual([]); expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(3); // 旧分面仍在 expect(d.facets.resolutions.length).toBe(2); @@ -500,10 +502,8 @@ describe.skipIf(!up)("全局搜索", () => { d = await list("resolution:1080p"); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]); expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 }); - expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([ - ["FraMeSToR", 0], - ["GRP", 1], - ]); + // 在 1080p 下没有资源的制作组不列 + expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([["GRP", 1]]); // 分页 const p1 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1`).then((r) => r.body.data); expect(p1.items.length).toBe(1); diff --git a/apps/web/app/components/search/quick-filter-bar.tsx b/apps/web/app/components/search/quick-filter-bar.tsx index d1dc7de0..d5c6f197 100644 --- a/apps/web/app/components/search/quick-filter-bar.tsx +++ b/apps/web/app/components/search/quick-filter-bar.tsx @@ -20,7 +20,8 @@ import { } from "./quick-filter-state.ts"; // 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选; -// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。 +// 选项只列结果里有的值(服务端不给 0 计数的选项),带计数(按其它维度的已选条件);已选但当前为 0 的置灰; +// 已选条件以可移除标签列出,只有一个「清除」。季集在最前(整季在前、单集在后)。 // 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。 // POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。 // SEARCH-4:对齐 MP 后维度变多,常用的留在筛选条上,其余(音频、字幕、配音、特性、年份、分类、大小、做种、H&R) @@ -62,7 +63,7 @@ export function QuickFilterBar({ const dims = g.dims.filter((d) => !hide.includes(d.dim)); if (!dims.length) return false; const opts = dims.flatMap((d) => facets?.[d.dim] ?? []); - // 站点只有一个时不用筛;季/集只在有剧集资源时出现 + // 站点只有一个时不用筛;季集只在有剧集资源时出现 if (g.key === "site" && opts.length <= 1 && !selectedCount(value, ["site"])) return false; return ( opts.length > 0 || diff --git a/apps/web/app/components/search/quick-filter-state.test.ts b/apps/web/app/components/search/quick-filter-state.test.ts index 44b51dcc..0d297ec7 100644 --- a/apps/web/app/components/search/quick-filter-state.test.ts +++ b/apps/web/app/components/search/quick-filter-state.test.ts @@ -16,6 +16,7 @@ import { // SEARCH-3 快速筛选:qf 参数、选择切换、已选标签、本机记忆只存固定档位 const empty = { + seasonEpisode: [], resolution: [], videoCodec: [], releaseType: [], @@ -41,9 +42,11 @@ describe("快速筛选状态", () => { expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe( "hdr:DV;resolution:2160p;releaseGroup:A%2CB", ); - expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"] })).toBe( - "site:s;audioCodec:TrueHD;seeders:0", + expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"], seasonEpisode: ["S01"] })).toBe( + "seasonEpisode:S01;site:s;audioCodec:TrueHD;seeders:0", ); + // 旧的季 / 集维度排在最后,仍会带上 + expect(formatQuickFilter({ season: ["2"], resolution: ["2160p"] })).toBe("resolution:2160p;season:2"); expect(formatQuickFilter({ resolution: [] })).toBeUndefined(); }); @@ -57,11 +60,16 @@ describe("快速筛选状态", () => { expect(selectedCount(clearDims(s, ["season", "episode"]))).toBe(1); }); - it("已选标签取分面里的选项名,取不到用原值", () => { - const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] }; - expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([ - { dim: "season", value: "2", dimLabel: "季", label: "2" }, + it("已选标签取分面里的选项名,取不到用原值;季集在最前,旧的季 / 集在最后", () => { + const facets = { + ...empty, + releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }], + seasonEpisode: [{ value: "S02 E14", label: "S02 E14", count: 1 }], + }; + expect(selectedTags({ releaseType: ["encode"], season: ["2"], seasonEpisode: ["S02 E14"] }, facets)).toEqual([ + { dim: "seasonEpisode", value: "S02 E14", dimLabel: "季集", label: "S02 E14" }, { dim: "releaseType", value: "encode", dimLabel: "质量", label: "蓝光压制" }, + { dim: "season", value: "2", dimLabel: "季", label: "2" }, ]); }); @@ -86,7 +94,7 @@ describe("快速筛选状态", () => { }); }); - it("「更多筛选」收的是 MP 筛选条之外的维度", () => { + it("「更多筛选」收的是 MP 筛选条之外的维度;季集放最前", () => { expect(MORE_DIMS).toEqual([ "audioCodec", "subtitle", @@ -99,8 +107,8 @@ describe("快速筛选状态", () => { "hr", ]); expect(QUICK_GROUPS.filter((g) => !g.more).map((g) => g.label)).toEqual([ + "季集", "站点", - "季/集", "促销", "编码", "质量", diff --git a/apps/web/app/components/search/quick-filter-state.ts b/apps/web/app/components/search/quick-filter-state.ts index 38db6280..fc589261 100644 --- a/apps/web/app/components/search/quick-filter-state.ts +++ b/apps/web/app/components/search/quick-filter-state.ts @@ -8,9 +8,10 @@ export type QuickDim = keyof ReleaseQuickFacets; export type QuickSelection = Partial>; /** - * 筛选条上的一个入口;「季/集」一个入口里分两节。 + * 筛选条上的一个入口。 * more = 收进「更多筛选」(SEARCH-4):常用维度按 MP 搜索结果筛选条的顺序放在前面 - * (站点、季集、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。 + * (季集放最前,再是站点、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。 + * 季集是一个维度(MP 的 season_episode):整季在前、单集在后,服务端排好序。 */ export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[]; more?: boolean }; @@ -22,15 +23,8 @@ const one = (dim: QuickDim, label: string, more?: boolean): QuickGroup => ({ }); export const QUICK_GROUPS: QuickGroup[] = [ + one("seasonEpisode", "季集"), one("site", "站点"), - { - key: "seasonEpisode", - label: "季/集", - dims: [ - { dim: "season", label: "季" }, - { dim: "episode", label: "集" }, - ], - }, one("promotion", "促销"), one("videoCodec", "编码"), one("releaseType", "质量"), @@ -51,7 +45,16 @@ export const QUICK_GROUPS: QuickGroup[] = [ export const MORE_GROUPS = QUICK_GROUPS.filter((g) => g.more); export const MORE_DIMS: QuickDim[] = MORE_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)); -const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)); +/** 旧的季 / 集两个维度:筛选条上不再出现,旧链接带着时仍生效,并以可删除的标签列出 */ +const LEGACY_DIMS: { dim: QuickDim; label: string }[] = [ + { dim: "season", label: "季" }, + { dim: "episode", label: "集" }, +]; + +const DIM_ORDER: QuickDim[] = [ + ...QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)), + ...LEGACY_DIMS.map((d) => d.dim), +]; /** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集、年份、分类随搜索变化,不记 */ const PERSISTED: QuickDim[] = [ @@ -97,7 +100,9 @@ export function selectedTags( sel: QuickSelection, facets: ReleaseQuickFacets | undefined, ): { dim: QuickDim; value: string; dimLabel: string; label: string }[] { - const dimLabel = new Map(QUICK_GROUPS.flatMap((g) => g.dims.map((d) => [d.dim, d.label] as const))); + const dimLabel = new Map( + [...QUICK_GROUPS.flatMap((g) => g.dims), ...LEGACY_DIMS].map((d) => [d.dim, d.label] as const), + ); return DIM_ORDER.flatMap((dim) => (sel[dim] ?? []).map((value) => ({ dim, diff --git a/packages/contracts/src/release-facets.ts b/packages/contracts/src/release-facets.ts index a88d7810..4441c8a5 100644 --- a/packages/contracts/src/release-facets.ts +++ b/packages/contracts/src/release-facets.ts @@ -3,14 +3,14 @@ import { NonNegInt } from "./common.ts"; // 资源列表快速筛选(SEARCH-3):GET /search/releases 与 GET /sites/{id}/releases 共用。 // 口径实现在 @umt/domain release-facets:同一维度多选为 OR,不同维度为 AND; -// 计数按其它维度的已选条件统计(0 = 在其它条件下没有结果)。 +// 计数按其它维度的已选条件统计;选项只列计数不为 0 的值(已选的值总会列出)。 export const FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt }); export type FacetCount = z.infer; /** * 快速筛选参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent。 - * 维度:resolution(2160p/1080p/720p/other)、videoCodec(H.265/H.264/AV1/other)、 + * 维度:seasonEpisode(季集,MP 写法 `S01` / `S01-S02` / `S02 E14` / `S01 E25-E48`)、resolution(2160p/1080p/720p/other)、videoCodec(H.265/H.264/AV1/other)、 * releaseType(disc/remux/encode/webdl/hdtv/other)、site(站点 ID)、promotion(free/2x/50%/other/none)、 * hdr(DV/HDR10/HDR10+/other/SDR)、hr(yes/no/unknown)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季); * SEARCH-4 起:audioCodec(Atmos/TrueHD/DTS:X/DTS-HD/DTS/DDP/DD/AAC/FLAC/LPCM/other/unknown)、 @@ -20,6 +20,8 @@ export type FacetCount = z.infer; export const QuickFilterParam = z.string().max(4000).optional(); export const ReleaseQuickFacets = z.object({ + /** 季集:整季在前、单集在后,季号新的在前(对照 MP);没有剧集资源时为空 */ + seasonEpisode: z.array(FacetCount), resolution: z.array(FacetCount), videoCodec: z.array(FacetCount), releaseType: z.array(FacetCount), diff --git a/packages/domain/src/release-facets/index.ts b/packages/domain/src/release-facets/index.ts index 606cdf87..8b97b2bf 100644 --- a/packages/domain/src/release-facets/index.ts +++ b/packages/domain/src/release-facets/index.ts @@ -3,9 +3,12 @@ // // 组合逻辑:同一维度多选为 OR,不同维度之间为 AND。 // 计数按"其它维度的已选条件"算(disjunctive facet):某个选项的计数 = 加上它之后本维度仍是 OR 时能看到的条数, -// 所以已选维度里的其它选项仍有计数,0 计数表示在其它条件下没有结果。 +// 所以已选维度里的其它选项仍有计数。 +// 选项只列结果里实际出现、且在其它条件下计数不为 0 的值(对照 MP useTorrentFilter.initOptions);已选的值总会列出。 export const FACET_DIMS = [ + // 季集合并成一个维度(对照 MP season_episode):`S01`、`S01-S02`、`S02 E14`、`S01 E25-E48` + "seasonEpisode", "resolution", "videoCodec", "releaseType", @@ -75,19 +78,19 @@ export interface FacetInput { seeders?: number | null; } -/** 固定档位(顺序即展示顺序);"other" 类档位只在有资源时列出 */ -const FIXED: Partial> = { +/** 固定档位(顺序即展示顺序与选项名);只列出有资源的档位 */ +const FIXED: Partial> = { resolution: [ { value: "2160p", label: "2160p" }, { value: "1080p", label: "1080p" }, { value: "720p", label: "720p" }, - { value: "other", label: "其他", hideEmpty: true }, + { value: "other", label: "其他" }, ], videoCodec: [ { value: "H.265", label: "H.265" }, { value: "H.264", label: "H.264" }, { value: "AV1", label: "AV1" }, - { value: "other", label: "其他", hideEmpty: true }, + { value: "other", label: "其他" }, ], releaseType: [ { value: "disc", label: "原盘" }, @@ -95,60 +98,60 @@ const FIXED: Partial String(n).padStart(2, "0"); + +/** 季集(MP 的 season_episode 写法):`S01`、`S01-S03`、`S01 E05`、`S01 E01-E12`;没有季集信息为空 */ +export function seasonEpisodeValue(r: Pick): string | null { + if (r.season === null || r.season === undefined) return null; + const s = r.seasonEnd && r.seasonEnd > r.season ? `S${pad2(r.season)}-S${pad2(r.seasonEnd)}` : `S${pad2(r.season)}`; + if (!r.episodes) return s; + const { start, end } = r.episodes; + return end > start ? `${s} E${pad2(start)}-E${pad2(end)}` : `${s} E${pad2(start)}`; +} + +const SEASON_EPISODE = /^S(\d+)(?:-S(\d+))?(?:\s+E(\d+)(?:-E(\d+))?)?$/; + +/** + * 季集选项排序(照 MP sortSeasonOptions):整季在前、单集在后;整季按季号新→旧, + * 单集按季号新→旧、再按最大集号大→小、起始集号大→小;认不出的写法排在最后。 + */ +export function compareSeasonEpisode(a: string, b: string): number { + const key = (v: string) => { + const m = SEASON_EPISODE.exec(v); + if (!m) return { ok: false, whole: false, season: 0, ep: 0, maxEp: 0 }; + const ep = m[3] ? Number(m[3]) : 0; + return { ok: true, whole: !m[3], season: Number(m[1]), ep, maxEp: m[4] ? Number(m[4]) : ep }; + }; + const x = key(a); + const y = key(b); + if (x.ok !== y.ok) return x.ok ? -1 : 1; + if (x.whole !== y.whole) return x.whole ? -1 : 1; + return y.season - x.season || y.maxEp - x.maxEp || y.ep - x.ep; +} + function episodeValues(r: FacetInput): string[] { if (r.episodes) { const { start, end } = r.episodes; @@ -318,7 +352,9 @@ function episodeValues(r: FacetInput): string[] { /** 一条资源在各维度上的档位(制作组按原名,匹配时不分大小写) */ export function facetValues(r: FacetInput, now: number): Record { + const se = seasonEpisodeValue(r); return { + seasonEpisode: se ? [se] : [], resolution: [resolutionBucket(r.resolution)], videoCodec: [codecBucket(r.videoCodec)], releaseType: [releaseTypeBucket(r.source)], @@ -367,7 +403,7 @@ export interface FacetOutcome { /** * 按已选条件筛选并统计各维度计数。 - * 选项来自全部输入条目(固定档位总会列出,"其他"类只在有资源时列出);已选但当前没有资源的值也会列出(计数 0)。 + * 选项只列在其它维度已选条件下计数不为 0 的值(固定档位按固定顺序);已选的值总会列出(当前没有资源时计数 0)。 */ export function applyFacets( items: readonly T[], @@ -400,15 +436,17 @@ export function applyFacets( let options: FacetOption[]; if (fixed) { options = fixed - .filter((f) => !f.hideEmpty || universe.has(f.value) || chosen.has(f.value)) + .filter((f) => (counts.get(f.value) ?? 0) > 0 || chosen.has(f.value)) .map((f) => ({ value: f.value, label: f.label, count: counts.get(f.value) ?? 0 })); } else { - options = [...universe.entries()].map(([value, u]) => ({ - value: dim === "releaseGroup" ? u.label : value, - label: u.label, - count: counts.get(value) ?? 0, - total: u.total, - })); + options = [...universe.entries()] + .filter(([value]) => (counts.get(value) ?? 0) > 0 || chosen.has(value)) + .map(([value, u]) => ({ + value: dim === "releaseGroup" ? u.label : value, + label: u.label, + count: counts.get(value) ?? 0, + total: u.total, + })); options = sortDynamic( dim, options as (FacetOption & { total: number })[], @@ -435,6 +473,7 @@ function sortDynamic( label: o.label, count: o.count, }); + if (dim === "seasonEpisode") return options.sort((a, b) => compareSeasonEpisode(a.value, b.value)).map(strip); if (dim === "season" || dim === "episode") { const n = (v: string) => (v === "pack" ? -1 : Number(v)); return options.sort((a, b) => n(a.value) - n(b.value)).map(strip); diff --git a/packages/domain/test/release-facets.test.ts b/packages/domain/test/release-facets.test.ts index ff809cf6..a5e06191 100644 --- a/packages/domain/test/release-facets.test.ts +++ b/packages/domain/test/release-facets.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest"; import { applyFacets, audioBuckets, + compareSeasonEpisode, type FacetInput, formatFacetParam, parseFacetParam, parseRelease, promotionBuckets, releaseTypeBucket, + seasonEpisodeValue, seedersBucket, sizeBucket, subtitleBuckets, @@ -76,8 +78,9 @@ describe("release-facets", () => { expect(counts(r.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 1, other: 1 }); expect(counts(r.facets.releaseType)).toEqual({ disc: 1, remux: 1, encode: 1, webdl: 1, hdtv: 1 }); expect(r.facets.releaseType.map((o) => o.label)).toEqual(["原盘", "Remux", "蓝光压制", "WEB-DL", "HDTV"]); - expect(counts(r.facets.hdr)).toEqual({ DV: 1, HDR10: 2, "HDR10+": 0, SDR: 3 }); - expect(counts(r.facets.promotion)).toEqual({ free: 2, "2x": 1, "50%": 0, none: 3 }); + // 结果里没有的档位不列出(HDR10+、50%) + expect(counts(r.facets.hdr)).toEqual({ DV: 1, HDR10: 2, SDR: 3 }); + expect(counts(r.facets.promotion)).toEqual({ free: 2, "2x": 1, none: 3 }); expect(r.facets.site).toEqual([ { value: "a", label: "站 A", count: 3 }, { value: "b", label: "站 B", count: 2 }, @@ -88,24 +91,25 @@ describe("release-facets", () => { { value: "FraMeSToR", label: "FraMeSToR", count: 1 }, ]); expect(r.facets.season).toEqual([]); + expect(r.facets.seasonEpisode).toEqual([]); }); it("同维度 OR、不同维度 AND;计数按其它维度的已选条件", () => { const r = run({ resolution: ["2160p", "1080p"], videoCodec: ["H.265"] }); expect(ids(r)).toEqual(["1", "2"]); - // 分辨率的计数只受编码条件影响:1080p 在 H.265 下没有结果 → 0 - expect(counts(r.facets.resolution)).toEqual({ "2160p": 2, "1080p": 0, "720p": 0, other: 0 }); - // 编码计数只受分辨率条件影响 - expect(counts(r.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0, other: 0 }); - // 其它维度按全部条件 - expect(counts(r.facets.releaseType)).toMatchObject({ remux: 1, webdl: 1, encode: 0 }); + // 分辨率的计数只受编码条件影响:1080p 在 H.265 下没有结果,但已选,仍列出(计数 0);720p / 其他不列 + expect(counts(r.facets.resolution)).toEqual({ "2160p": 2, "1080p": 0 }); + // 编码计数只受分辨率条件影响:在 2160p / 1080p 下没有 AV1 / 其他,不列 + expect(counts(r.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1 }); + // 其它维度按全部条件:计数为 0 的档位不列 + expect(counts(r.facets.releaseType)).toEqual({ remux: 1, webdl: 1 }); expect(ids(run({ site: ["b"], releaseType: ["disc", "webdl"] }))).toEqual(["2", "5"]); expect(ids(run({ releaseGroup: ["flux"] }))).toEqual(["2", "3"]); expect(ids(run({ hdr: ["SDR"] }))).toEqual(["3", "4", "5"]); expect(ids(run({ promotion: ["2x"] }))).toEqual(["4"]); }); - it("H&R 三态分面:缺省按未知;未知档只在有资源时列出", () => { + it("H&R 三态分面:缺省按未知;只列有资源的档", () => { const hrItems = [mk("y", { hr: true }), mk("n", { hr: false }), mk("n2", { hr: false }), mk("u")]; const r = applyFacets(hrItems, (x) => x, {}, { now: NOW }); expect(r.facets.hr).toEqual([ @@ -115,7 +119,7 @@ describe("release-facets", () => { ]); expect(ids(applyFacets(hrItems, (x) => x, { hr: ["no", "unknown"] }, { now: NOW }))).toEqual(["n", "n2", "u"]); const known = applyFacets([mk("y", { hr: true })], (x) => x, {}, { now: NOW }); - expect(known.facets.hr.map((o) => o.value)).toEqual(["yes", "no"]); + expect(known.facets.hr.map((o) => o.value)).toEqual(["yes"]); }); it("已选但没有结果的值仍列出(计数 0)", () => { @@ -150,14 +154,56 @@ describe("release-facets", () => { { value: "2", label: "S02", count: 1 }, { value: "3", label: "S03", count: 1 }, ]); - expect(r.facets.episode).toEqual([ - { value: "pack", label: "整季", count: 1 }, - { value: "2", label: "E02", count: 0 }, - { value: "3", label: "E03", count: 0 }, - ]); + // 在 S02 下单集没有结果,不列 + expect(r.facets.episode).toEqual([{ value: "pack", label: "整季", count: 1 }]); expect(ids(applyFacets(tv, (x) => x, { season: ["1"], episode: ["3"] }, { now: NOW }))).toEqual(["s1e2"]); }); + it("季集(MP season_episode):一个维度,整季在前、单集在后,季号新的在前", () => { + const tv = [ + mk("s1", { season: 1, episodes: null }), + mk("s1b", { season: 1, episodes: null }), + mk("s2", { season: 2, episodes: null }), + mk("s1-3", { season: 1, seasonEnd: 3, episodes: null }), + mk("s1e25", { season: 1, episodes: { start: 25, end: 48 } }), + mk("s2e14", { season: 2, episodes: { start: 14, end: 14 } }), + mk("s2e1", { season: 2, episodes: { start: 1, end: 1 } }), + mk("s2e14-15", { season: 2, episodes: { start: 14, end: 15 } }), + mk("movie"), + ]; + const r = applyFacets(tv, (x) => x, {}, { now: NOW }); + expect(r.facets.seasonEpisode.map((o) => [o.value, o.count])).toEqual([ + ["S02", 1], + ["S01", 2], + ["S01-S03", 1], + ["S02 E14-E15", 1], + ["S02 E14", 1], + ["S02 E01", 1], + ["S01 E25-E48", 1], + ]); + expect(r.facets.seasonEpisode[0]?.label).toBe("S02"); + expect(ids(applyFacets(tv, (x) => x, { seasonEpisode: ["S01", "S02 E14"] }, { now: NOW }))).toEqual([ + "s1", + "s1b", + "s2e14", + ]); + // 计数按其它维度已选条件;在其它条件下为 0 的不列 + const free = applyFacets( + tv.map((t) => (t.id === "s2" ? { ...t, promotion: { ...none, type: "free", downloadFactor: 0 } } : t)), + (x) => x, + { promotion: ["free"] }, + { now: NOW }, + ); + expect(free.facets.seasonEpisode).toEqual([{ value: "S02", label: "S02", count: 1 }]); + expect(seasonEpisodeValue({ season: null, episodes: { start: 1, end: 2 } })).toBeNull(); + expect(["S01 E02", "bogus", "S10", "S02 E10-E12"].sort(compareSeasonEpisode)).toEqual([ + "S10", + "S02 E10-E12", + "S01 E02", + "bogus", + ]); + }); + it("促销与版本类型档位", () => { expect(promotionBuckets({ type: "2x50%", downloadFactor: 0.5, uploadFactor: 2, expiresAt: null }, NOW)).toEqual([ "2x", @@ -227,10 +273,7 @@ describe("release-facets", () => { Atmos: 1, TrueHD: 1, "DTS-HD": 1, - DTS: 0, DDP: 1, - DD: 0, - AAC: 0, unknown: 1, }); expect(r.facets.audioCodec.find((o) => o.value === "DDP")?.label).toBe("DD+"); @@ -246,16 +289,8 @@ describe("release-facets", () => { ["电影", 2], ["剧集", 1], ]); - expect(counts(r.facets.size)).toEqual({ - lt1: 0, - "1-5": 1, - "5-10": 1, - "10-20": 0, - "20-50": 0, - "50+": 1, - unknown: 1, - }); - expect(counts(r.facets.seeders)).toEqual({ "0": 1, "1-9": 0, "10-49": 1, "50+": 1, unknown: 1 }); + expect(counts(r.facets.size)).toEqual({ "1-5": 1, "5-10": 1, "50+": 1, unknown: 1 }); + expect(counts(r.facets.seeders)).toEqual({ "0": 1, "10-49": 1, "50+": 1, unknown: 1 }); }); it("新维度组合:同维度 OR、跨维度 AND,计数按其它维度已选条件", () => { From 9d7e20067b60ed6623259a3fedd8a44faa3a8728 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 15:48:46 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(search):=20=E6=8C=89=E4=BD=9C=E5=93=81?= =?UTF-8?q?=E6=90=9C=E8=B5=84=E6=BA=90=E6=97=B6=E5=88=A4=E5=AE=9A=E6=AF=8F?= =?UTF-8?q?=E6=9D=A1=E7=BB=93=E6=9E=9C=E6=98=AF=E5=90=A6=E5=B1=9E=E4=BA=8E?= =?UTF-8?q?=E8=BF=99=E9=83=A8=E4=BD=9C=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 MP TorrentHelper.match_torrent:已识别到同一本地作品(或同一 TMDB 身份)的属于、识别到别的作品的不属于; 未识别的先排除音乐 / 游戏 / 软件 / 书分类,再核对类型、年份(剧集按各季首播年份 ±1)、季集范围(TMDB 季列表, 单季集号可按全部季总集数的绝对集号),最后按解析出的中 / 英文名、标题非英文词、副标题非英文词比对作品名、原名、别名。 不属于的不参与筛选与分面,响应里单独给出(unmatched / unmatchedTotal,只在第一页,最多 200 条), 页面在列表最后折叠为「另有 N 条可能不是这部作品」,展开后仍可下载。关键词搜索不做这一步。 用「药屋少女的呢喃」109 条真实结果做样本:只有《迷宫饭》片尾曲专辑和标题写成 S01-S24 的一条被判为不属于。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/server/src/modules/search/routes.ts | 59 +- apps/server/src/modules/search/work-match.ts | 239 +++++ .../test/fixtures/search-apothecary.json | 874 ++++++++++++++++++ .../test/search/two-stage-search.test.ts | 62 +- apps/server/test/search/work-match.test.ts | 190 ++++ apps/server/vitest.config.ts | 1 + .../app/components/search/release-results.tsx | 63 +- .../search/search-results-ui.test.ts | 74 +- packages/contracts/src/search.ts | 8 +- 9 files changed, 1560 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/modules/search/work-match.ts create mode 100644 apps/server/test/fixtures/search-apothecary.json create mode 100644 apps/server/test/search/work-match.test.ts diff --git a/apps/server/src/modules/search/routes.ts b/apps/server/src/modules/search/routes.ts index cc34b83b..14df939d 100644 --- a/apps/server/src/modules/search/routes.ts +++ b/apps/server/src/modules/search/routes.ts @@ -1,5 +1,11 @@ import { createRoute, type RouteConfig } from "@hono/zod-openapi"; -import { fetchRichDetail, fetchRichSeason, TmdbError, type TmdbRichDetail } from "@umt/adapters"; +import { + fetchRichDetail, + fetchRichSeason, + type MetadataCandidate, + TmdbError, + type TmdbRichDetail, +} from "@umt/adapters"; import { Api, type ApiScope, @@ -38,6 +44,7 @@ import { bangumiCalendar, doubanHot, matchTmdb } from "./discover.ts"; import { InternalDispatcher } from "./dispatch.ts"; import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts"; import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts"; +import { localWorkTarget, splitByWork, tmdbWorkTarget, type WorkTarget } from "./work-match.ts"; import { buildWorks, detailSeasons, @@ -165,6 +172,33 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear } }; + /** 按作品搜资源时判定归属用的 TMDB 详情(季列表、别名):按 TMDB 缓存时长复用,失败返回 null */ + const detailCache = new Map }>(); + const cachedDetails = (type: "movie" | "tv", id: string): Promise => { + const key = `${type}:${id}`; + const hit = detailCache.get(key); + if (hit && hit.expires > d.now()) return hit.value; + // 失败不缓存:下次重试 + const value = tmdbDetails(type, id).catch(() => { + detailCache.delete(key); + return null; + }); + for (const [k, v] of detailCache) if (v.expires <= d.now()) detailCache.delete(k); + detailCache.set(key, { expires: d.now() + d.tmdbCacheTtlMs, value }); + return value; + }; + + /** 按作品搜(entityId / tmdb)时的目标作品;关键词搜索为 null(不判定归属) */ + const workTarget = async (q: ReleaseSearchQuery): Promise => { + if (q.entityId) return localWorkTarget(ctx, q.entityId, cachedDetails); + if (!q.tmdb) return null; + const [type, id] = q.tmdb.split(":") as ["movie" | "tv", string]; + const local = (await localEntities(ctx, [{ entityType: type, externalId: id }])).get(q.tmdb); + if (local) return localWorkTarget(ctx, local, cachedDetails); + const m = await cachedDetails(type, id); + return m ? tmdbWorkTarget(m) : null; + }; + /** MD-1 富详情(语言回退链、类型、评分、主创);失败返回 null,详情页退回基础字段 */ const tmdbRich = async (type: "movie" | "tv", id: string): Promise => { const client = await d.runtime().tmdb(); @@ -196,7 +230,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear } if (q.tmdb) { const [type, id] = q.tmdb.split(":") as ["movie" | "tv", string]; - const m = await tmdbDetails(type, id); + const m = (await cachedDetails(type, id)) ?? (await tmdbDetails(type, id)); return searchTerms({ title: m.title, originalTitle: m.originalTitle, year: m.year, movie: type === "movie" }); } throw new ApiException("validation_failed", "q、entityId、tmdb 至少给一个"); @@ -279,18 +313,32 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear const t0 = performance.now(); const q = query as ReleaseSearchQuery; const terms = await releaseTerms(q); - const hit = await fanout.search(terms, await resolveSites(q.sites), { refresh: q.refresh === "true" }); - const all = hit.fanout.items; + const [hit, target] = await Promise.all([ + fanout.search(terms, await resolveSites(q.sites), { refresh: q.refresh === "true" }), + workTarget(q), + ]); + // 按作品搜:不属于这部作品的不参与筛选与分面,第一页另外给出(列表最后折叠展示) + const split = target ? await splitByWork(ctx, target, hit.fanout.items) : null; + const all = split ? split.matched : hit.fanout.items; const quick = quickFilter(all, q, d.now()); const filtered = sortReleases(quick.items, q.sort, q.order); const offset = decodeCursor(q.cursor); const pageItems = filtered.slice(offset, offset + q.limit); const next = offset + q.limit < filtered.length ? encodeCursor(offset + q.limit) : null; const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout"); + const others = + split && offset === 0 && split.unmatched.length + ? await decorate( + d.runtime(), + sortReleases(split.unmatched, q.sort, q.order).slice(0, UNMATCHED_MAX), + inventory(c), + ) + : []; const out: ReleaseSearchPage = { q: q.q ?? terms[0]!, queries: terms, items: await decorate(d.runtime(), pageItems, inventory(c)), + ...(split ? { unmatched: others, unmatchedTotal: split.unmatched.length } : {}), nextCursor: next, total: filtered.length, unfilteredTotal: all.length, @@ -445,6 +493,9 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear }); } +/** 「可能不是这部作品」最多给多少条 */ +const UNMATCHED_MAX = 200; + const encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url"); function decodeCursor(cursor: string | undefined): number { if (!cursor) return 0; diff --git a/apps/server/src/modules/search/work-match.ts b/apps/server/src/modules/search/work-match.ts new file mode 100644 index 00000000..a0aaf4cc --- /dev/null +++ b/apps/server/src/modules/search/work-match.ts @@ -0,0 +1,239 @@ +import type { MetadataCandidate } from "@umt/adapters"; +import { normalizeTitle } from "@umt/domain"; +import type { ServerContext } from "../types.ts"; +import type { BaseRelease } from "./releases.ts"; + +// 按作品搜资源时,判定每条结果是否属于这部作品(对照 MP TorrentHelper.match_torrent, +// app/application/torrent/download.py):站点按关键词搜,简介里提到作品名的专辑、别的作品也会被搜到。 +// 不属于的不参与筛选与分面,列表最后折叠展示(仍可下载)。关键词搜索不做这一步。 +// +// 判定顺序: +// 1. 已识别到本地作品:同一作品(或同一 TMDB 身份)→ 属于;别的作品 → 不属于。 +// 2. 未识别:分类是音乐 / 游戏 / 软件 / 书 → 不属于;类型(剧集资源 ≠ 电影作品)、年份、季集范围对不上 → 不属于; +// 再按名字:解析出的中 / 英文名命中作品名、原名或别名;标题里的非英文词命中作品名 / 原名; +// 副标题 / 简介里的非英文词命中作品名、原名或别名 → 属于。都没命中 → 不属于。 +// 与 MP 的差别(为了不误伤,记在这里): +// - 剧集年份允许与任一季首播年份相差 1 年(MP 要求等于某季年份;蓝光常在播出后一年发行); +// - 单季集号超出该季集数、但不超过全部季总集数时算属于(动画常按绝对集号写 `S01E25-E48`); +// - 只有拿到 TMDB 详情(季列表)时才核对季集范围与各季年份;本地季单元可能过期(新季还没刷新),不用来否决; +// - 标题 / 副标题拆词时同时按 `[` `]` 拆;解析出的英文名去掉末尾的 `Vol.N` 再比。 + +export interface WorkTarget { + /** 本地作品 ID;只在 TMDB 上的作品为 null */ + entityId: string | null; + /** 已接受的 TMDB 身份:`tv:123` / `movie:456` */ + tmdb: string | null; + movie: boolean; + /** 作品名、原名(归一化) */ + titles: Set; + /** 别名、译名(归一化) */ + names: Set; + year: number | null; + /** 季号 > 0 的各季:首播年份与集数;null = 没拿到季信息(不核对季集范围) */ + seasons: { season: number; year: number | null; episodes: number | null }[] | null; +} + +/** 资源已识别到的本地作品(跟随合并)及其 TMDB 身份 */ +export interface ReleaseLink { + entityId: string; + tmdb: string | null; +} + +const norm = (s: string | null | undefined) => (s ? normalizeTitle(s) : ""); + +/** 非音视频分类:音乐 / 游戏 / 软件 / 书 / 学习 */ +const NON_VIDEO_CATEGORY = /音乐|音樂|music|游戏|遊戲|game|软件|軟體|software|电子书|電子書|e-?book|有声书|学习|學習/i; +/** 站点分类明确是剧集 */ +const TV_CATEGORY = /剧集|劇集|电视剧|電視劇|连续剧|連續劇|tv\s*series|综艺|綜藝/i; +/** MP is_english_word:不含空格的纯 ASCII 字母词(容易误匹配,拆词比对时跳过) */ +const englishWord = (t: string) => /^[A-Za-z]+$/.test(t); + +function splitNames(text: string, sep: RegExp): string[] { + return text + .split(sep) + .filter((t) => t && !englishWord(t)) + .map(norm) + .filter(Boolean); +} + +/** 资源的季范围(没有季号为空) */ +function releaseSeasons(r: BaseRelease): number[] { + const s = r.parsed.season; + if (s === null || s === undefined) return []; + const end = r.parsed.seasonEnd && r.parsed.seasonEnd > s ? r.parsed.seasonEnd : s; + const out: number[] = []; + for (let i = s; i <= end && out.length < 100; i++) out.push(i); + return out; +} + +function yearOk(t: WorkTarget, y: number | null): boolean { + if (t.movie) { + // 电影:上下浮动 1 年,资源没写年份不通过(MP 同) + return !!y && !!t.year && Math.abs(y - t.year) <= 1; + } + if (!y || !t.year) return true; + const years = [t.year, ...(t.seasons ?? []).map((s) => s.year).filter((x): x is number => x !== null)]; + if (t.seasons?.length) return years.some((x) => Math.abs(x - y) <= 1); + // 没有各季年份:只排除早于首播一年以上的(后面的季年份未知) + return y >= t.year - 1; +} + +function topologyOk(t: WorkTarget, r: BaseRelease): boolean { + if (t.movie || !t.seasons?.length) return true; + const seasons = releaseSeasons(r); + const known = new Map(t.seasons.map((s) => [s.season, s.episodes])); + if (seasons.length && !seasons.every((s) => known.has(s))) return false; + if (seasons.length !== 1 || !r.parsed.episodes) return true; + const count = known.get(seasons[0]!); + const maxEp = Math.max(r.parsed.episodes.start, r.parsed.episodes.end); + if (!count || maxEp <= count) return true; + // 绝对集号:不超过全部季的总集数 + const total = t.seasons.reduce((n, s) => n + (s.episodes ?? 0), 0); + return maxEp <= total; +} + +/** 一条资源是否属于目标作品 */ +export function belongsToWork(t: WorkTarget, r: BaseRelease, link: ReleaseLink | null): boolean { + if (link) { + if (t.entityId && link.entityId === t.entityId) return true; + return !!t.tmdb && link.tmdb === t.tmdb; + } + const category = r.category.name ?? ""; + if (NON_VIDEO_CATEGORY.test(category)) return false; + const tvRelease = r.parsed.season !== null || r.parsed.episodes !== null; + if (t.movie && (tvRelease || TV_CATEGORY.test(category))) return false; + if (!yearOk(t, r.parsed.year)) return false; + if (!topologyOk(t, r)) return false; + + const all = new Set([...t.titles, ...t.names]); + const en = r.parsed.titleEn; + const metaNames = [r.parsed.titleCn, en, en?.replace(/\s+Vol\.?\s*\d+$/i, "")].map(norm).filter(Boolean); + if (metaNames.some((n) => all.has(n))) return true; + // 标题拆词:只比非英文词,只比作品名 / 原名 + if (splitNames(r.title, /[\s/【】.[\]-]+/).some((n) => t.titles.has(n))) return true; + // 副标题 / 简介拆词:非英文词比作品名、原名、别名 + if (r.subtitle && splitNames(r.subtitle, /[\s/【】|[\]]+/).some((n) => all.has(n))) return true; + return false; +} + +const yearOf = (d: string | null | undefined) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null); + +/** TMDB 详情 → 目标作品的名字与季信息 */ +function fromTmdb(m: MetadataCandidate) { + const seasons = (m.seasons ?? []) + .filter((s) => s.seasonNumber > 0) + .map((s) => ({ + season: s.seasonNumber, + year: yearOf(s.airDate), + episodes: s.episodeCount > 0 ? s.episodeCount : null, + })); + return { + titles: [m.title, m.originalTitle], + names: [...m.aliases, ...m.titles.map((x) => x.text)], + seasons: m.entityType === "tv" && seasons.length ? seasons : null, + }; +} + +export type TmdbDetails = (type: "movie" | "tv", id: string) => Promise; + +/** 本地作品 → 目标(名字取作品名 / 原名 / 别名表;季信息取 TMDB 详情,拿不到就不核对季集范围) */ +export async function localWorkTarget( + ctx: ServerContext, + entityId: string, + details: TmdbDetails, +): Promise { + const e = await ctx.db + .selectFrom("media_entity") + .select(["id", "kind", "title", "original_title", "year", "status", "merged_into_id"]) + .where("id", "=", entityId) + .executeTakeFirst(); + if (!e) return null; + const id = e.status === "merged" && e.merged_into_id ? e.merged_into_id : e.id; + const [aliases, ident] = await Promise.all([ + ctx.db.selectFrom("title_alias").select(["alias"]).where("media_entity_id", "=", id).execute(), + ctx.db + .selectFrom("external_identity") + .select(["entity_type", "external_id"]) + .where("media_entity_id", "=", id) + .where("provider", "=", "tmdb") + .where("status", "=", "accepted") + .executeTakeFirst(), + ]); + const tmdb = + ident && (ident.entity_type === "movie" || ident.entity_type === "tv") + ? { type: ident.entity_type as "movie" | "tv", id: ident.external_id } + : null; + const remote = tmdb ? await details(tmdb.type, tmdb.id).catch(() => null) : null; + const r = remote ? fromTmdb(remote) : null; + const titles = [e.title, e.original_title, ...(r?.titles ?? [])].map(norm).filter(Boolean); + const names = [...aliases.map((a) => a.alias), ...(r?.names ?? [])].map(norm).filter(Boolean); + return { + entityId: id, + tmdb: tmdb ? `${tmdb.type}:${tmdb.id}` : null, + movie: e.kind === "movie", + titles: new Set(titles), + names: new Set(names), + year: e.year ?? remote?.year ?? null, + seasons: e.kind === "movie" ? null : (r?.seasons ?? null), + }; +} + +/** 只在 TMDB 上的作品 → 目标 */ +export function tmdbWorkTarget(m: MetadataCandidate): WorkTarget { + const r = fromTmdb(m); + return { + entityId: null, + tmdb: `${m.entityType}:${m.externalId}`, + movie: m.entityType === "movie", + titles: new Set(r.titles.map(norm).filter(Boolean)), + names: new Set(r.names.map(norm).filter(Boolean)), + year: m.year, + seasons: r.seasons, + }; +} + +/** 资源已识别到的本地作品(跟随合并)与 TMDB 身份 */ +export async function releaseLinks(ctx: ServerContext, items: BaseRelease[]): Promise> { + const out = new Map(); + const ids = [...new Set(items.map((i) => i.sourceReleaseId).filter((x): x is string => !!x))]; + if (ids.length === 0) return out; + const rows = await ctx.db + .selectFrom("source_release as r") + .innerJoin("media_entity as e", "e.id", "r.media_entity_id") + .select(["r.id", "e.id as entity_id", "e.status", "e.merged_into_id"]) + .where("r.id", "in", ids) + .execute(); + const entity = new Map( + rows.map((r) => [r.id, r.status === "merged" && r.merged_into_id ? r.merged_into_id : r.entity_id]), + ); + const entityIds = [...new Set(entity.values())]; + const tmdb = new Map(); + if (entityIds.length) { + for (const i of await ctx.db + .selectFrom("external_identity") + .select(["media_entity_id", "entity_type", "external_id"]) + .where("media_entity_id", "in", entityIds) + .where("provider", "=", "tmdb") + .where("status", "=", "accepted") + .execute()) + if (i.media_entity_id) tmdb.set(i.media_entity_id, `${i.entity_type}:${i.external_id}`); + } + for (const it of items) { + const eid = it.sourceReleaseId ? entity.get(it.sourceReleaseId) : undefined; + if (eid) out.set(it.key, { entityId: eid, tmdb: tmdb.get(eid) ?? null }); + } + return out; +} + +/** 把结果分成属于 / 可能不属于这部作品两组(保持输入顺序) */ +export async function splitByWork( + ctx: ServerContext, + target: WorkTarget, + items: BaseRelease[], +): Promise<{ matched: BaseRelease[]; unmatched: BaseRelease[] }> { + const links = await releaseLinks(ctx, items); + const matched: BaseRelease[] = []; + const unmatched: BaseRelease[] = []; + for (const it of items) (belongsToWork(target, it, links.get(it.key) ?? null) ? matched : unmatched).push(it); + return { matched, unmatched }; +} diff --git a/apps/server/test/fixtures/search-apothecary.json b/apps/server/test/fixtures/search-apothecary.json new file mode 100644 index 00000000..be780dca --- /dev/null +++ b/apps/server/test/fixtures/search-apothecary.json @@ -0,0 +1,874 @@ +[ + { + "id": "1139946", + "site": "馒头", + "title": "The Apothecary Diaries S01E25-E48 2023 1080p BluRay x265 10bit FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと / The Apothecary Diaries [25-48Fin] 【简|繁字幕】", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "880803", + "site": "馒头", + "title": "The Apothecary Diaries S01 2023 1080p CR WEB-DL H.264 AAC-RLWeb", + "subtitle": "药屋少女的呢喃 / 藥師少女的獨語 / 薬屋のひとりごと 第1季 全24集 【简繁英多国字幕】", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "333808", + "site": "观众", + "title": "The Apothecary Diaries S01 2023 1080p BluRay x265 10bit FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries / 薬屋のひとりごと 【简日|繁日|简|繁|日字幕】", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "991924", + "site": "馒头", + "title": "The Apothecary Diaries 2025 S02 1080p NF WEB-DL H.264 AAC 2.0-FROGWeb", + "subtitle": "药屋少女的呢喃 / 薬屋のひとりごと 第2季 | 类型:动画 剧情 悬疑 | 语言:日本語 | *内封简繁英多国软字幕* 第2季", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "816044", + "site": "馒头", + "title": "The Apothecary Diaries S01 2023 1080p BluRay x265 10bit FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries / 薬屋のひとりごと 【简日|繁日|简|繁|日字幕】", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "273708", + "site": "观众", + "title": "The Apothecary Diaries S01 2023 1080p CR WEB-DL H.264 AAC-RLWeb", + "subtitle": "药屋少女的呢喃 / 藥師少女的獨語 / 薬屋のひとりごと / Kusuriya no Hitorigoto 第1季 全24集 【简繁英多国字幕】", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "624759", + "site": "观众", + "title": "The Apothecary Diaries S01E25-E48 2023 1080p BluRay x265 10bit FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと / The Apothecary Diaries [25-48Fin] 【简|繁字幕】", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "822279", + "site": "馒头", + "title": "The Apothecary Diaries S01 1080p BluRay x265 FLAC 2.0-7³ACG", + "subtitle": "药屋少女的呢喃/薬屋のひとりごと | 01-24 日语+简繁字幕", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "501794", + "site": "观众", + "title": "The Apothecary Diaries S02 2025 1080p NF WEB-DL x264 AAC-ADWeb", + "subtitle": "药屋少女的呢喃 第二季 薬屋のひとりごと 第2期 全24集 | 类型: 动画", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "991690", + "site": "馒头", + "title": "The Apothecary Diaries 2025 S02 Complete 1080p NF WEB-DL H.264 AAC 2.0-UBWEB", + "subtitle": "2025年1月新番 | 药屋少女的呢喃 第二季/药师少女的独语 第2期/Kusuriya no Hitorigoto Season 2 | 全24集 [日语/多语字幕]", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "768448", + "site": "馒头", + "title": "Kusuriya no Hitorigoto S01 2023 1080p CR WEB-DL x264 AAC-ADWeb", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries *第一季 全24集* | 类别:动画 古装", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1043334", + "site": "馒头", + "title": "The Apothecary Diaries 2025 S02 1080p NF WEB-DL x264 AAC 2.0-ADWeb", + "subtitle": "药屋少女的呢喃 第二季 薬屋のひとりごと 第2期 全24集 | 类型: 动画", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "887758", + "site": "馒头", + "title": "The Apothecary Diaries S01 2023 1080p NF WEB-DL H.264 AAC-FROGWeb", + "subtitle": "药屋少女的呢喃 / 薬屋のひとりごと / 药师少女的独语 / 类型: 动画 / 古装 / 第1季 完结 全24集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "950711", + "site": "馒头", + "title": "The Apothecary Diaries S02E16 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第16集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "562127", + "site": "观众", + "title": "The Apothecary Diaries S01E25-E48 2023 1080p BluRay Remux AVC FLAC 2.0-AnimeF@ADE", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと / The Apothecary Diaries [25-48Fin] [中日双语字幕]", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "941215", + "site": "馒头", + "title": "The Apothecary Diaries S02E14 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第14集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "934440", + "site": "馒头", + "title": "The Apothecary Diaries S02E11 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第11集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "353484", + "site": "观众", + "title": "The Apothecary Diaries S01 1080p BluRay x265 FLAC 2.0-7³ACG@OurBits", + "subtitle": "药屋少女的呢喃/薬屋のひとりごと | 01-24 日语+简繁字幕", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "924132", + "site": "馒头", + "title": "The Apothecary Diaries S02E10 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第10集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "920137", + "site": "馒头", + "title": "The Apothecary Diaries S02E09 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第9集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "916395", + "site": "馒头", + "title": "The Apothecary Diaries S02E08 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第8集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "900711", + "site": "馒头", + "title": "The Apothecary Diaries S02E04 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第4集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "897035", + "site": "馒头", + "title": "The Apothecary Diaries S02E01 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第1集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1157922", + "site": "馒头", + "title": "The Apothecary Diaries 2023 S01 WEB-DL NF 1080p H.264 DDP2.0-ZmWeb", + "subtitle": "药屋少女的呢喃 全24集 | 药师少女的独语 | The Apothecary Diaries | 内封多语言字幕 |", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1157450", + "site": "馒头", + "title": "Kusuriya no Hitorigoto S02 2026 1080p BluRay x265 FLAC2.0-Nekomoe kissaten&VCB-Studio", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと 第2期 | TV 25-48", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1059159", + "site": "馒头", + "title": "The Apothecary Diaries S01E25-E48 2023 1080p BluRay Remux AVC FLAC 2.0-AnimeF@ADE", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと / The Apothecary Diaries [25-48Fin] [中日双语字幕]", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "934441", + "site": "馒头", + "title": "The Apothecary Diaries S02E12 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第12集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "897563", + "site": "馒头", + "title": "The Apothecary Diaries S02E03 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第3集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "500809", + "site": "观众", + "title": "Kusuriya no Hitorigoto S02 2025 1080p CR WEB-DL x264 AAC-AnimeS@ADWeb", + "subtitle": "2025年1月新番 药师少女的独语 第二季 / 薬屋のひとりごと 第2期 全24集 *内封简繁英多国软字幕*", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "954445", + "site": "馒头", + "title": "The Apothecary Diaries S02E17 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第17集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "897036", + "site": "馒头", + "title": "The Apothecary Diaries S02E02 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第2集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1247063", + "site": "馒头", + "title": "The Apothecary Diaries 2023 S01 Complete 1080p CR WEB-DL H264 AAC AAC2.0-UBWEB", + "subtitle": "2023年10月新番 | 药屋少女的呢喃/药师少女的独语 | 全24集 [日语/多语字幕]", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "986950", + "site": "馒头", + "title": "The Apothecary Diaries S02E23 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第23集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "904012", + "site": "馒头", + "title": "The Apothecary Diaries S02E05 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第5集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1247057", + "site": "馒头", + "title": "The Apothecary Diaries 2025 S02 Complete 1080p CR WEB-DL H264 AAC AAC2.0-UBWEB", + "subtitle": "2025年1月新番 | 药屋少女的呢喃 第二季/药师少女的独语 第2期/Kusuriya no Hitorigoto Season 2 | 全24集 [日语/多语字幕]", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "515981", + "site": "观众", + "title": "The Apothecary Diaries 2025 S02 Complete 1080p NF WEB-DL H.264 AAC-UBWEB", + "subtitle": "2025年1月新番 | 药屋少女的呢喃 第二季/药师少女的独语 第2期/Kusuriya no Hitorigoto Season 2 | 全24集 [日语/多语字幕]", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "974052", + "site": "馒头", + "title": "The Apothecary Diaries S02E21 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第21集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "958298", + "site": "馒头", + "title": "The Apothecary Diaries S02E18 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第18集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "946869", + "site": "馒头", + "title": "The Apothecary Diaries S02E15 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第15集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "936817", + "site": "馒头", + "title": "The Apothecary Diaries S02E13 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第13集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "274379", + "site": "观众", + "title": "Kusuriya no Hitorigoto S01 2023 1080p AMZN WEB-DL H.264 DDP2.0-ADWeb", + "subtitle": "2023年10月新番 药屋少女的呢喃 / 薬屋のひとりごと | 全24集 | 类型: 动画 / 古装", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "912161", + "site": "馒头", + "title": "The Apothecary Diaries S02E07 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第7集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "908251", + "site": "馒头", + "title": "The Apothecary Diaries S02E06 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第6集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "890406", + "site": "馒头", + "title": "The Apothecary Diaries 2023 S01 Complete 1080p CR WEB-DL H.264 AAC 2.0-QHstudIo", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 全24集【内封简繁中字】【导演:长沼范裕 | 配音: 悠木碧 | 大塚刚央 | 小西克幸 | 种崎敦美】QHstudIo小组作品", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1088513", + "site": "馒头", + "title": "Kusuriya no Hitorigoto S02 1080p BluRay x265 OPUS 2.0-7³ACG@OurBits", + "subtitle": "药师少女的独语第2季/薬屋のひとりごと 第2期 | 01-24(25-48) 日语+简繁字幕", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "978618", + "site": "馒头", + "title": "The Apothecary Diaries S02E22 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第22集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "322717", + "site": "观众", + "title": "The Apothecary Diaries S01 2023 1080p BluRay AVC LPCM 2.0-DIY@Audies", + "subtitle": "药屋少女的呢喃 / 薬屋のひとりごと / The Apothecary Diaries 【DIY / 双语简繁字幕】【正片全24集+特典全24集】", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1059522", + "site": "馒头", + "title": "Kusuriya no Hitorigoto Season 2 2025 BDRip 1080p TV 01-24(25-48) Fin+SP H264 FLAC 2.0-jsum@U2", + "subtitle": "[药师少女的独语第2季][Kusuriya no Hitorigoto Season 2][薬屋のひとりごと 第2期][BDRip][1920x1080][TV 01-24(25-48) Fin+SP][H264 FLAC MKV]", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "562150", + "site": "观众", + "title": "The Apothecary Diaries S01E01-E24 2023 1080p BluRay Remux AVC FLAC 2.0-AnimeF@ADE", + "subtitle": "药屋少女的呢喃 第一季 / 薬屋のひとりごと / The Apothecary Diaries [01-24Fin] [中日双语字幕]", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "971111", + "site": "馒头", + "title": "The Apothecary Diaries S02E20 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第20集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "815968", + "site": "馒头", + "title": "The Apothecary Diaries S01 2023 1080p BluRay Remux AVC FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries / 薬屋のひとりごと 【简日|繁日|简|繁|日字幕】", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "791154", + "site": "馒头", + "title": "The Apothecary Diaries 2023 S01E24 2160P TV WEB-DL MPEG-4 AVC1 - Native D3D9 DXVA Decoder VLD AAC LC -Kisssub", + "subtitle": "药屋少女的呢喃 薬屋のひとりごと (2023) 2160P TVS01 [01-24](AI增强1080P转2160P) AVC1 @5160kbps AAC @128kbps", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "585310", + "site": "观众", + "title": "Kusuriya no Hitorigoto S02 1080p BluRay AV1 OPUS 2.0-7³ACG@OurBits", + "subtitle": "药师少女的独语第2季/薬屋のひとりごと 第2期 | 01-24(25-48) 日语+简繁字幕", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1062507", + "site": "馒头", + "title": "Kusuriya no Hitorigoto S02 2025 1080p CR WEB-DL x264 AAC 2.0-AnimeS@ADWeb", + "subtitle": "2025年1月新番 药师少女的独语 第二季 / 薬屋のひとりごと 第2期 全24集 *内封简繁英多国软字幕*", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1012256", + "site": "馒头", + "title": "药屋少女的呢喃 The Apothecary Diaries 2023 S01-S24 WEB-DL 1080p H.264 DDP2.0-ZmWeb", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The.Apothecary.Diaries|季度全24集 导演:长沼范裕 | 主演:悠木碧 / 大刚央 / 小西克", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "971109", + "site": "馒头", + "title": "The Apothecary Diaries S02E19 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第19集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "939884", + "site": "馒头", + "title": "The Apothecary Diaries S01 2023 1080p NF WEB-DL AAC2.0 x.264-HHWEB", + "subtitle": "药屋少女的呢喃 | 全24集 | 1080p | 类型: 动画/古装 | 导演: 长沼范裕 | 主演: 悠木碧/大塚刚央/小西克幸/种崎敦美/石川由依 | ARDTU | By ATU_Tool", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "804792", + "site": "馒头", + "title": "The Apothecary Diaries S01 2023 1080p BluRay AVC LPCM 2.0-DIY@Audies", + "subtitle": "药屋少女的呢喃 / 薬屋のひとりごと / The Apothecary Diaries 【DIY / 双语简繁字幕】【正片全24集+特典全24集】", + "category": null, + "resolved": "药屋少女的呢喃" + }, + { + "id": "1109851", + "site": "馒头", + "title": "Kusuriya no Hitorigoto S02 1080p BluRay AV1 OPUS 2.0-7³ACG@OurBits", + "subtitle": "药师少女的独语第2季/薬屋のひとりごと 第2期 | 01-24(25-48) 日语+简繁字幕", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "991501", + "site": "馒头", + "title": "The Apothecary Diaries S02E24 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃 第二季/薬屋のひとりごと 第2期 | 2025 | 日本 | 动画 | 长沼范裕 笔坂明规 | 悠木碧 大塚刚央 | 第2季第24集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "941489", + "site": "馒头", + "title": "The Apothecary Diaries 2025 S02 part1 1080p NF WEB-DL H.264 AAC-FROGWeb", + "subtitle": "薬屋のひとりごと 第2期 / 药屋少女的呢喃 第二季 / 药师少女的独语 第2期 / 类型: 动画 / 第2季 第01-12集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "935566", + "site": "馒头", + "title": "The Apothecary Diaries 2023 S01 Complete 1080p NF WEB-DL H.264 AAC-UBWEB", + "subtitle": "药屋少女的呢喃/药师少女的独语 | 全24集 | 导演: 长沼范裕 | 主演: 悠木碧 大塚刚央 小西克幸 种崎敦美 [日语/中英日字幕]", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "804876", + "site": "馒头", + "title": "Kusuriya no Hitorigoto 2023 BDRip 1080p AVC FLAC@U2", + "subtitle": "药师少女的呢喃/Kusuriya no Hitorigoto/薬屋のひとりごと 1080p 自压(附相关专辑) TV 01-24 Fin+SP", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "301225", + "site": "观众", + "title": "Kusuriya no Hitorigoto S01 2023 1080p NF WEB-DL x264 DDP2.0-ADWeb", + "subtitle": "2023年10月新番 药屋少女的呢喃 薬屋のひとりごと 全24集 | 类型: 动画 / 古装", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "641057", + "site": "观众", + "title": "Kusuriya no Hitorigoto S02 2025 1080p BluRay x265 FLAC2.0-Nekomoekissaten&VCB-Studio", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと 第2期 | TV 25-48", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "994487", + "site": "馒头", + "title": "Kusuriya no Hitorigoto 1080p BDRip x265 FLAC@Nekomoe kissaten&VCB-Studio", + "subtitle": "[喵萌奶茶屋&VCB-Studio] 药师少女的独语 / Kusuriya no Hitorigoto / 薬屋のひとりごと 10-bit 1080p HEVC BDRip [S1 Fin]", + "category": "动画", + "resolved": null + }, + { + "id": "274416", + "site": "观众", + "title": "Kusuriya no Hitorigoto S01 2023 1080p CR WEB-DL x264 AAC-AnimeS@ADWeb", + "subtitle": "2023年10月新番 药师少女的独语 / 薬屋のひとりごと 全24集 *内封简繁英多国软字幕*", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "728125", + "site": "观众", + "title": "The Apothecary Diaries S03E01 1080p CR WEB-DL DDP 2.0 H.264-Kitsune", + "subtitle": "药屋少女的呢喃 [第三季 第01集] / 药师少女的独语 / The Apothecary Diaries | 类型:动画 古装", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "965726", + "site": "馒头", + "title": "Kusuriya no Hitorigoto S01 1080p Crunchyroll WEB-DL H.264 AAC-CHDWEB", + "subtitle": "药屋少女的呢喃 / 薬屋のひとりごと 全24集 内封中字", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "333798", + "site": "观众", + "title": "The Apothecary Diaries S01 2023 1080p BluRay Remux AVC FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries / 薬屋のひとりごと 【简日|繁日|简|繁|日字幕】", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1135207", + "site": "馒头", + "title": "The Apothecary Diaries aka Kusuriya no Hitorigoto Season 2 2025 BDRip 1080p x265 FLAC 2.0-AI-Raws@U2", + "subtitle": "药屋少女的呢喃 第二季 薬屋のひとりごと 第2期 TV S2 01-24(25-48)+SP Fin HEVC", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1058988", + "site": "馒头", + "title": "The Apothecary Diaries S01E01-E24 2023 1080p BluRay Remux AVC FLAC 2.0-AnimeF@ADE", + "subtitle": "药屋少女的呢喃 第一季 / 薬屋のひとりごと / The Apothecary Diaries [01-24Fin] [中日双语字幕]", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1047858", + "site": "馒头", + "title": "Kusuriya no hitorigoto 2023 S01 Complete 1080p NF WEB-DL x264 AAC-PTerWEB", + "subtitle": "药屋少女的呢喃/The Apothecary Diaries/药师少女的独语 全24集 | 导演: 长沼范裕 主演: 悠木碧 大塚刚央 小西克幸 [日语中字] | ARDTU", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "968958", + "site": "馒头", + "title": "The Apothecary Diaries S02E19 2025 1080p NF WEB-DL x264 AAC-ADWeb", + "subtitle": "药屋少女的呢喃 第二季 薬屋のひとりごと 第2期 第19集 | 类型: 动画", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "322404", + "site": "观众", + "title": "Kusuriya no Hitorigoto 2023 S01 1080p JPN Blu-ray AVC LPCM 2.0-TheM14@U2", + "subtitle": "药屋少女的呢喃 / 薬屋のひとりごと | Vol.1-4 Fin | TheM14@U2 自购自抓自扫 | Team HAYAKU | Animate全卷特典附", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1214134", + "site": "馒头", + "title": "The Apothecary Diaries 2025 S02 1080p BluRay REMUX AVC FLAC 2.0 2Audios-NAN0", + "subtitle": "药屋少女的呢喃 第二季 / 药师少女的独语 第2期 / The Apothecary Diaries season 2 | 动画 | 全24集 | 简繁英字幕", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "954567", + "site": "馒头", + "title": "Kusuriya no Hitorigoto 2025 S02 1080p Bluray H.264 LPCM-@U2", + "subtitle": "自购自抓自扫 | Team HAYAKU | Animate特典附 | TBR35041D 药屋少女的呢喃 第二季", + "category": null, + "resolved": "药屋少女的呢喃" + }, + { + "id": "890418", + "site": "馒头", + "title": "The Apothecary Diaries S02E01 1080p CR WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃/薬屋のひとりごと | 2023 | 日本 | 动画 古装 | 长沼范裕 | 悠木碧 大塚刚央 | 第2季第1集", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "823729", + "site": "馒头", + "title": "Kusuriya no Hitorigoto S01 1080p BluRay x265 FLAC 2.0-Snow-Raws", + "subtitle": "药屋少女的呢喃 / 薬屋のひとりごと | TV 01-24 Fin", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "624758", + "site": "观众", + "title": "The Apothecary Diaries S01E25-E48 2023 1080p BluRay x264 FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと / The Apothecary Diaries [25-48Fin] 【简|繁字幕】", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1059286", + "site": "馒头", + "title": "Kusuriya no Hitorigoto S02 2025 Blu-ray 1080p AVC LPCM 2Audio-@U2", + "subtitle": "Team HAYAKU | Animate全卷特典附 药屋少女的呢喃 第二季", + "category": null, + "resolved": "药屋少女的呢喃" + }, + { + "id": "1021945", + "site": "馒头", + "title": "The Apothecary Diaries Vol.3 2025 BDMV 1080p AVC LPCM 2.0-Arri-RAWS@U2", + "subtitle": "U2 Exclusive, RIPs welcomed with credits | 制作RIP请注明出处 | 药屋少女的呢喃/药师少女的独语 S2", + "category": null, + "resolved": null + }, + { + "id": "516439", + "site": "观众", + "title": "The Apothecary Diaries 2023 S01 WEB-DL 1080p AVC AAC2.0-ZmWeb", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The.Apothecary.Diaries|季度全24集 导演:长沼范裕 | 主演:悠木碧 / 大刚央 / 小西克", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "433512", + "site": "观众", + "title": "Kusuriya no Hitorigoto S01 Complete 2023 1080p Blu-ray x264 FLAC-jsum@U2", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / 薬屋のひとりごと", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "918584", + "site": "馒头", + "title": "The Apothecary Diaries S01 1080p NF WEB-DL AAC2.0 H.264-MWeb", + "subtitle": "药屋少女的呢喃/薬屋のひとりごと | 2023 | 日本 | 动画 古装 | 长沼范裕 | 悠木碧 大塚刚央", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "804941", + "site": "馒头", + "title": "Kusuriya no Hitorigoto 2023 Bluray 1080p AVC LPCM@U2", + "subtitle": "药屋少女的呢喃/Kusuriya no Hitorigoto/薬屋のひとりごと 1080p 自购自抓自扫 Team HAYAKU Animate全卷特典附 Vol.1-4 Fin", + "category": null, + "resolved": "药屋少女的呢喃" + }, + { + "id": "1219881", + "site": "馒头", + "title": "The Apothecary Diaries 2025 S02 1080p CR WEB-DL AAC2.0 H.264 DUAL-BiOMA", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries 内封多语言字幕/ 葡语/日语", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1142641", + "site": "馒头", + "title": "The Apothecary Diaries 2023 S01 1080p Blu-ray AVC LPCM 2.0-U2", + "subtitle": "药屋少女的呢喃 第二季 | 类别:动画 转自U2", + "category": null, + "resolved": "药屋少女的呢喃" + }, + { + "id": "1140018", + "site": "馒头", + "title": "The Apothecary Diaries S01E25-E48 2023 1080p BluRay x264 FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと / The Apothecary Diaries [25-48Fin] 【简|繁字幕】", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "562440", + "site": "观众", + "title": "Kusuriya no Hitorigoto S02 2025 Blu-ray 1080p AVC LPCM-@U2", + "subtitle": "Team HAYAKU | Animate全卷特典附 药屋少女的呢喃 第二季", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "946749", + "site": "馒头", + "title": "The Apothecary Diaries 2025 S02 E14-E15 1080p CR WEB-DL x264 AAC-PTerWEB", + "subtitle": "药屋少女的呢喃 第二季/The Apothecary Diaries season 2/药师少女的独语 第2期 第14-15集 | 导演: 长沼范裕 主演: 悠木碧 大塚刚央 小西克幸 | [日语中字]", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "816042", + "site": "馒头", + "title": "The Apothecary Diaries S01 2023 1080p BluRay x264 FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries / 薬屋のひとりごと 【简日|繁日|简|繁|日字幕】", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "812090", + "site": "馒头", + "title": "The Apothecary Diaries S01 2023 1080p Blu-ray Remux AVC FLAC-7³ACG@OurBits", + "subtitle": "药屋少女的呢喃/薬屋のひとりごと | 01-24 日语+简繁字幕", + "category": "动画", + "resolved": "药屋少女的呢喃" + }, + { + "id": "728045", + "site": "观众", + "title": "The Apothecary Diaries AKA Kusuriya no Hitorigoto S03E01 1080p CR WEB-DL AAC 2.0 H.264-VARYG", + "subtitle": "药屋少女的呢喃 [第三季 第01集] / 药师少女的独语 / The Apothecary Diaries | 类型:动画 古装", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "926704", + "site": "馒头", + "title": "Channel U ESCL-6057~9 2023 BDMV 1080p FLAC-@U2", + "subtitle": "緑黄色社会 5th Album | TVアニメ『ダンジョン飯』ED1 | TVアニメ『薬屋のひとりごと』OP1 | 初回生産限定盤A | Amazon.co.jp 限定特典付 XLD", + "category": "音乐", + "resolved": "迷宫饭" + }, + { + "id": "768303", + "site": "馒头", + "title": "The Apothecary Diaries Vol 2 2023 1080p BluRay AVC LPCM 2.0-DIY@Audies", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries 【Vol.2】【正片第07-12集+特典12集】 【DIY / 双语简繁字幕】", + "category": null, + "resolved": "药屋少女的呢喃" + }, + { + "id": "717999", + "site": "观众", + "title": "Kusuriya no Hitorigoto S02 1080p BluRay AVC LPCM 2.0-HAYAKU", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと 第2期 | Vol.3 2Audio", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "1049675", + "site": "馒头", + "title": "Kusuriya no Hitorigoto S02 Vol 3 1080p Blu-ray AVC LPCM 2.0 2Audio-HAYAKU", + "subtitle": "药屋少女的呢喃 第二季 / 薬屋のひとりごと 第2期 | Vol.3", + "category": null, + "resolved": "药屋少女的呢喃" + }, + { + "id": "946967", + "site": "馒头", + "title": "The Apothecary Diaries 2025 S02 BDMV 1080p Vol.1 AVC LPCM-Arri-RAWS@U2", + "subtitle": "U2 Exclusive, RIPs welcomed with credits | 自购自抓 药屋少女的呢喃/药师少女的独语 S2", + "category": null, + "resolved": "药屋少女的呢喃" + }, + { + "id": "788913", + "site": "馒头", + "title": "The Apothecary Diaries Vol 3 2023 1080p BluRay AVC LPCM 2.0-DIY@Audies", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries 【Vol.3】【正片第13-18集】 【DIY / 双语简繁字幕】", + "category": null, + "resolved": "药屋少女的呢喃" + }, + { + "id": "585335", + "site": "观众", + "title": "Kusuriya no Hitorigoto S02 1080p BluRay x265 OPUS 2.0-7³ACG@OurBits", + "subtitle": "药师少女的独语第2季/薬屋のひとりごと 第2期 | 01-24(25-48) 日语+简繁字幕", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "578760", + "site": "观众", + "title": "The Apothecary Diaries S02 1080p AMZN WEB-DL DUAL DDP 2.0 H.264-ToonsHub", + "subtitle": "药屋少女的呢喃 [第二季] / 药师少女的独语 / The Apothecary Diaries | 类型:动画 古装 | 2025", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "541534", + "site": "观众", + "title": "The Apothecary Diaries S01 REPACK2 1080p CR WEB-DL DUAL DDP 2.0 H.264-Kitsune", + "subtitle": "药屋少女的呢喃 [第一季] / 药师少女的独语 / The Apothecary Diaries | 类型:动画 古装", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "506365", + "site": "观众", + "title": "The Apothecary Diaries S02 1080p CR WEB-DL Dual-Audio DDP 2.0 H.264-Kitsune", + "subtitle": "药屋少女的呢喃 [第二季] / 药师少女的独语 / The Apothecary Diaries | 类型:动画 古装", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "403592", + "site": "观众", + "title": "The Apothecary Diaries AKA Kusuriya no Hitorigoto S01 1080p CR WEB-DL Dual-Audio DDP 2.0 H.264-Kitsune", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries *第一季 全24集* | 类别:动画 古装", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "333806", + "site": "观众", + "title": "The Apothecary Diaries S01 2023 1080p BluRay x264 FLAC 2.0-ADE", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries / 薬屋のひとりごと 【简日|繁日|简|繁|日字幕】", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "330499", + "site": "观众", + "title": "The Apothecary Diaries S01 2023 1080p Blu-ray Remux AVC FLAC-7³ACG@OurBits", + "subtitle": "药屋少女的呢喃/薬屋のひとりごと | 01-24 日语+简繁字幕", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "278786", + "site": "观众", + "title": "The Apothecary Diaries (Kusuriya no Hitorigoto) S01 2023 1080p WEB-DL AVC AAC 2.0 Dual Audio -ZR-", + "subtitle": "药屋少女的呢喃 [第一季] / 药师少女的独语 / The Apothecary Diaries | 类型:动画 古装", + "category": "剧集", + "resolved": "药屋少女的呢喃" + }, + { + "id": "769727", + "site": "馒头", + "title": "The Apothecary Diaries Vol 1 2023 1080p BluRay AVC LPCM 2.0-DIY@Audies", + "subtitle": "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries 【Vol.1】【第1-6集】 【DIY / 双语简繁字幕】", + "category": null, + "resolved": "药屋少女的呢喃" + } +] diff --git a/apps/server/test/search/two-stage-search.test.ts b/apps/server/test/search/two-stage-search.test.ts index d65a92f3..6e24c749 100644 --- a/apps/server/test/search/two-stage-search.test.ts +++ b/apps/server/test/search/two-stage-search.test.ts @@ -100,7 +100,14 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => { snatched: "10", uploadedAt: "2026-09-20 10:00:00", }; - a = await createMockNexusPhpSite({ releases: [...AUDIENCES_FIXTURE_RELEASES, release] }); + // 按作品搜时的归属判定:同名蓝光(属于)、剧集(作品是电影)、1984 年的前作、名字对不上的(识别后才算属于) + const nearby = [ + { ...release, id: "700002", title: "Dune.Part.Two.2024.1080p.BluRay.x264-GRP2", subtitle: "沙丘2 | 简繁字幕" }, + { ...release, id: "700003", title: "Dune Prophecy S01 2024 1080p WEB-DL H264-TV", subtitle: "沙丘2 衍生剧" }, + { ...release, id: "700004", title: "Dune 1984 1080p BluRay x264-OLD", subtitle: "沙丘2 前作" }, + { ...release, id: "700005", title: "Arrakis Chronicles 2024 1080p WEB-DL H264-ZZ", subtitle: "沙丘2特别版" }, + ]; + a = await createMockNexusPhpSite({ releases: [...AUDIENCES_FIXTURE_RELEASES, release, ...nearby] }); b = await createMockNexusPhpSite({ releases: [{ ...release, id: "800001" }] }); tmdb = await createMockTmdb(); @@ -316,6 +323,59 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => { expect((await req(`/search/releases?tmdb=movie:999999999`)).status).toBe(404); }); + it("按作品搜:不属于这部作品的不参与筛选与分面,第一页单独给出;已识别的按识别结果;关键词搜索不判定", async () => { + const ids = (xs: Loose[]) => xs.map((i) => i.siteReleaseId).sort(); + const page = async (extra = "") => + (await req(`/search/releases?entityId=${dune}&sites=${sites.a}${extra}`)).body.data; + let d = await page(); + expect(ids(d.items)).toEqual(["700001", "700002", "812345"]); + expect(ids(d.unmatched)).toEqual(["700003", "700004", "700005"]); + expect(d).toMatchObject({ total: 3, unfilteredTotal: 3, unmatchedTotal: 3 }); + // 分面只算属于的:没有 1984 年、没有季集 + expect(d.facets.year.map((f: Loose) => f.value)).toEqual(["2024"]); + expect(d.facets.seasonEpisode).toEqual([]); + expect(d.facets.site).toEqual([{ value: sites.a, label: "观众", count: 3 }]); + // 筛选只作用于属于的;「可能不是」那组不受影响 + d = await page(`&qf=${encodeURIComponent("resolution:2160p")}`); + expect(ids(d.items)).toEqual(["700001", "812345"]); + expect(d.unmatchedTotal).toBe(3); + // 只在第一页给出 + const p1 = await page("&limit=1"); + expect(p1.unmatched).toHaveLength(3); + const p2 = await page(`&limit=1&cursor=${p1.nextCursor}`); + expect(p2.unmatched).toEqual([]); + expect(p2.unmatchedTotal).toBe(3); + + // 识别结果优先:名字对不上的识别到这部作品后属于;名字对得上的识别到别的作品后不属于 + const other = ( + await db + .insertInto("media_entity") + .values({ kind: "movie", title: "沙丘", original_title: "Dune", year: 1984, status: "active" }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const resolve = (id: string, entity: string) => + db + .updateTable("source_release") + .set({ media_entity_id: entity }) + .where("site_id", "=", sites.a) + .where("site_release_id", "=", id) + .execute(); + await resolve("700005", dune); + await resolve("700002", other); + d = await page(); + expect(ids(d.items)).toEqual(["700001", "700005", "812345"]); + expect(ids(d.unmatched)).toEqual(["700002", "700003", "700004"]); + // 按 TMDB 条目搜同一作品:同样判定 + const byTmdb = (await req(`/search/releases?tmdb=movie:693134&sites=${sites.a}`)).body.data; + expect(ids(byTmdb.items)).toEqual(["700001", "700005", "812345"]); + + // 关键词搜索不判定归属 + const kw = (await req(`/search/releases?q=${encodeURIComponent("沙丘2")}&sites=${sites.a}`)).body.data; + expect(kw.unmatched).toBeUndefined(); + expect(kw.total).toBe(6); + }); + it("GET /media/tmdb/{type}/{id}:TMDB 信息 + 季列表 + 库存对比 + 是否已建档", async () => { const before = counts(); const movie = await req(`/media/tmdb/movie/872585`); diff --git a/apps/server/test/search/work-match.test.ts b/apps/server/test/search/work-match.test.ts new file mode 100644 index 00000000..3dd3384e --- /dev/null +++ b/apps/server/test/search/work-match.test.ts @@ -0,0 +1,190 @@ +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); + }); +}); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 5a1ead4d..73054598 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -11,6 +11,7 @@ const UNIT = [ "test/api-registry.test.ts", "test/organize-in-place-verify.test.ts", "test/file-manager/preview-parse.test.ts", + "test/search/work-match.test.ts", "test/auth.test.ts", "test/health.test.ts", "test/sources-identity/identity-differential.test.ts", diff --git a/apps/web/app/components/search/release-results.tsx b/apps/web/app/components/search/release-results.tsx index a8c4dcf7..d18f9ce2 100644 --- a/apps/web/app/components/search/release-results.tsx +++ b/apps/web/app/components/search/release-results.tsx @@ -57,6 +57,8 @@ import { // 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。 // SEARCH-5(参考 MP 资源卡片):促销放标题前、规格拆成中性标签、关键数字定宽对齐、按作品分组, // 每行附规则判定(命中第几档 / 不满足什么 / 与库里版本比较,服务端 ruleVerdict)。 +// 按作品搜时,服务端判定不属于这部作品的资源(对照 MP match_torrent)不参与筛选与分面, +// 在列表最后折叠为「另有 N 条可能不是这部作品」,展开后仍可下载。 /** URL 里的资源筛选(字符串形态,空串 = 不筛) */ export type ReleaseFilterState = { @@ -247,14 +249,35 @@ export function ReleaseResults({ ) : ( )} {rows.length > 0 || res.hasNextPage ? : null} + {first?.unmatchedTotal ? ( + + ) : null} {first ? ( @@ -285,6 +308,42 @@ export function ReleaseResults({ ); } +/** 「另有 N 条可能不是这部作品」:默认收起,展开后与上面的资源行一样可查看、下载 */ +export function UnmatchedReleases({ + rows, + total, + onOpen, + onDownload, + defaultOpen = false, +}: { + rows: SearchRelease[]; + total: number; + onOpen: (r: SearchRelease) => void; + onDownload: (r: SearchRelease) => void; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + if (!total) return null; + return ( +
+ + {open ? ( +
+ +
+ ) : null} +
+ ); +} + /** 部分站点失败 / 跳过:列出站点,其余结果照常 */ function CoverageNotice({ page }: { page: ReleaseSearchPage }) { const [open, setOpen] = useState(false); diff --git a/apps/web/app/components/search/search-results-ui.test.ts b/apps/web/app/components/search/search-results-ui.test.ts index cdac0aca..ae04c23b 100644 --- a/apps/web/app/components/search/search-results-ui.test.ts +++ b/apps/web/app/components/search/search-results-ui.test.ts @@ -1,8 +1,11 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import type { MediaServerInventory, WorkInventory } from "@umt/contracts"; +import type { MediaServerInventory, SearchRelease, WorkInventory } from "@umt/contracts"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; -import { parseWorkRef, type ReleaseFilterState, toApiFilters } from "./release-results.tsx"; +import { parseWorkRef, type ReleaseFilterState, toApiFilters, UnmatchedReleases } from "./release-results.tsx"; import { resolvePick } from "./site-picker-dialog.tsx"; import { inventoryBadge, mediaServerSummary, releaseSearchPath, slotLine, workHref, workRef } from "./work-card.tsx"; @@ -127,3 +130,70 @@ describe("订阅入口位置", () => { } }); }); + +describe("可能不是这部作品的资源", () => { + const rel = (key: string, title: string): SearchRelease => ({ + key, + sourceReleaseId: null, + siteId: ID, + siteName: "馒头", + siteReleaseId: key, + title, + subtitle: null, + category: { id: null, name: "音乐" }, + sizeBytes: null, + seeders: 3, + leechers: 0, + snatched: null, + promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null }, + free: false, + hr: false, + uploadedAt: null, + detailUrl: "http://x/", + observedAt: "2026-10-06T00:00:00.000Z", + parsed: { + titleCn: null, + titleEn: null, + year: 2023, + season: null, + episodes: null, + resolution: "1080p", + source: "BluRay-Disc", + videoCodec: null, + hdr: [], + releaseGroup: null, + }, + mediaEntityId: null, + entityMatch: null, + entity: null, + inventory: null, + ruleVerdict: null, + }); + const rows = [rel("a", "Channel U ESCL-6057~9 2023 BDMV 1080p FLAC-@U2")]; + const render = (defaultOpen: boolean, total = 1) => + renderToStaticMarkup( + createElement( + MemoryRouter, + null, + createElement(UnmatchedReleases, { rows, total, onOpen: () => {}, onDownload: () => {}, defaultOpen }), + ), + ); + + it("默认折叠成一行「另有 N 条可能不是这部作品」,不列资源", () => { + const html = render(false); + expect(html).toContain("另有 1 条可能不是这部作品"); + expect(html).toContain('aria-expanded="false"'); + expect(html).not.toContain("Channel U"); + }); + + it("展开后列出资源,仍有下载按钮", () => { + const html = render(true); + expect(html).toContain('aria-expanded="true"'); + expect(html).toContain("Channel U ESCL-6057~9 2023 BDMV 1080p FLAC-@U2"); + expect(html).toContain("下载"); + }); + + it("没有时不渲染", () => { + expect(render(false, 0)).toBe(""); + }); +}); diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts index 72ef85b5..146ab03e 100644 --- a/packages/contracts/src/search.ts +++ b/packages/contracts/src/search.ts @@ -342,8 +342,14 @@ export const ReleaseSearchPage = z nextCursor: z.string().nullable(), /** 筛选后的条数 */ total: NonNegInt, - /** 未筛选的全部条数 */ + /** 未筛选的全部条数(按作品搜时只算属于这部作品的) */ unfilteredTotal: NonNegInt, + /** + * 按作品搜(entityId / tmdb)时判定不属于这部作品的资源(对照 MP match_torrent:识别到别的作品, + * 或名字、类型、年份、季集对不上);不参与筛选与分面,只在第一页给出(最多 200 条)。关键词搜索没有这两项 + */ + unmatched: z.array(SearchRelease).optional(), + unmatchedTotal: NonNegInt.optional(), /** * sites / resolutions / sources:旧的筛选项计数(基于未筛选的全部结果); * 其余为快速筛选分面(SEARCH-3,在其它筛选条件之后、按其它维度的已选条件计数) From 3c5ab75d756878f8ab6c69cedab55944180bcdb8 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 15:49:37 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs(search):=20=E8=BF=90=E8=A1=8C=E6=89=8B?= =?UTF-8?q?=E5=86=8C=E8=A1=A5=E6=8C=89=E4=BD=9C=E5=93=81=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E3=80=81=E5=AD=A3=E9=9B=86=E7=BB=B4=E5=BA=A6?= =?UTF-8?q?=E3=80=81=E5=8E=9F=E7=9B=98=E8=A1=A5=E5=88=A4=E4=B8=8E=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8D=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- docs/runbook/search.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/runbook/search.md b/docs/runbook/search.md index aa3b6013..51d26ee8 100644 --- a/docs/runbook/search.md +++ b/docs/runbook/search.md @@ -1,6 +1,6 @@ # 全局搜索运行手册(W9-G、SEARCH-2) -实现:`apps/server/src/modules/search/`(`routes.ts` 端点、`releases.ts` 跨站搜索/缓存/筛选/叠加、`media.ts` 本地作品与 TMDB、`works.ts` 作品合并/库存对比/查询词、`actions.ts` 页面与操作清单、`dispatch.ts` 进程内转调);合同 `packages/contracts/src/search.ts`;测试 `apps/server/test/search/`(`search-results-ui.test.ts` 为两阶段)。 +实现:`apps/server/src/modules/search/`(`routes.ts` 端点、`releases.ts` 跨站搜索/缓存/筛选/叠加、`work-match.ts` 按作品搜时的归属判定、`media.ts` 本地作品与 TMDB、`works.ts` 作品合并/库存对比/查询词、`actions.ts` 页面与操作清单、`dispatch.ts` 进程内转调);合同 `packages/contracts/src/search.ts`;测试 `apps/server/test/search/`(`search-results-ui.test.ts` 为两阶段)。 ## 两阶段流程(SEARCH-2) @@ -30,15 +30,27 @@ ## 跨站资源搜索 - **站点范围**:只查选中的站点(`enabled=true` 且未删除)。`sites=` 给了就只查这些;没给按 `settings.search.defaultSiteMode`:`default` = 站点「默认参与搜索」(`site.search_default`,迁移 0048,缺省 true)的站点,`all` = 全部启用站点,`custom` = `settings.search.siteIds` 里仍启用的站点。`siteFilter=` 只筛选结果、不改查询的站点(前端站点分面用)。不支持搜索或没有适配器定义的站点记为 `skipped`。 -- **查询词**:给了 `q` 只用 `q`;按作品(`entityId` / `tmdb`)时由服务端生成,最多 2 个:原名(电影且原名是拉丁字母时带年份,如 `Interstellar 2014`)+ 译名(归一化后与原名不同才加);剧集不带年份。每个站点每个词查一次,同站结果按站点资源 ID 去重,响应 `queries` 给出实际查询词。 +- **查询词**:给了 `q` 只用 `q`;按作品(`entityId` / `tmdb`)时由服务端生成,最多 2 个:国内站点按中文名收录,作品名(中文)在前、原名第二(归一化后与作品名不同才加);作品名不含中日韩文字时原名在前。拉丁名在电影上带年份(如 `星际穿越` + `Interstellar 2014`),剧集不带年份。每个站点每个词查一次,同站结果按站点资源 ID 去重,响应 `queries` 给出实际查询词。 - **限流**:沿用 sources-identity 运行时里每个站点的共享限流器,和快照、工作台、详情、下载用的是同一个令牌桶。 - **超时**:单站 `AbortSignal.timeout(15s)`,限流等待也算在内;超时记为 `timeout`。 - **站点级并发**:同时最多搜索 4 个站点(`createSearchModule({ siteConcurrency })`)。 - **并发合并**:按(站点, 查询词)合并,查询词去首尾空白、小写、合并空格;同一对同时只会发一次请求,后到的等同一个结果,`coalesced=true`。 -- **短缓存**:按(站点, 查询词)缓存 30s,换站点选择时已查过的站点直接复用;全部命中时 `cached=true`。失败 / 超时也缓存,但一轮里新查的站点全部失败时不缓存。`refresh=true` 跳过缓存,但仍然参与合并。进程内缓存,多个 api 实例之间不共享。 +- **短缓存**:按(站点, 查询词)缓存 30s,换站点选择时已查过的站点直接复用;全部命中时 `cached=true`。只缓存成功的结果:失败 / 超时 / 验证拦截 / 跳过不写缓存,下次搜索直接重试。`refresh=true` 跳过缓存,但仍然参与合并。进程内缓存,多个 api 实例之间不共享。 - **部分失败**:任一站点失败或超时,`partial=true`,`failedSites` 列出这些站点(status 为 `failed` 或 `timeout`,附已脱敏的错误)。其它站点的结果照常返回。`sites` 是每个站点的覆盖情况:status、count、tookMs、error。 - **落库**:搜索结果按 site + 站点资源 ID upsert 到资源表(`source_release`),新资源照常发 `release.observed`,进入识别流水线,和工作台实时补查是同一条路径。`sourceReleaseId` 给站点工作台、下载、委托 Agent 用。落库失败只记警告,仍然返回结果,此时 `sourceReleaseId=null`。搜索不写 `site_snapshot`,也不改站点健康状态。 +### 按作品搜时的归属判定(对照 MP `TorrentHelper.match_torrent`) + +站点按关键词搜,简介里提到作品名的专辑、别的作品也会被搜到(例:搜「药屋少女的呢喃」带出《迷宫饭》片尾曲专辑「Channel U … BDMV」)。按作品(`entityId` / `tmdb`)搜时,每条结果先判定是否属于这部作品(`work-match.ts`),关键词搜索不做这一步: + +1. 已识别到本地作品(跟随合并):同一作品,或该作品与目标有相同的已接受 TMDB 身份 → 属于;识别到别的作品 → 不属于。 +2. 未识别:站点分类是音乐 / 游戏 / 软件 / 书 → 不属于;剧集资源(有季集)或剧集分类而作品是电影 → 不属于;年份:电影上下 1 年且必须有年份,剧集与作品或任一季首播年份相差 1 年以内(没有季信息时只排除早于首播一年以上的);季集:资源的季必须在作品已知的季里,单季集号超过该季集数时按全部季总集数算(绝对集号)。 +3. 名字:解析出的中 / 英文名(英文名去掉末尾 `Vol.N`)归一化后命中作品名、原名或别名(`title_alias` + TMDB 别名 / 译名);标题按 `空白 / 【】 . [ ] -` 拆出的非英文词命中作品名 / 原名;副标题 / 简介按 `空白 / 【】 | [ ]` 拆出的非英文词命中作品名、原名或别名。都没命中 → 不属于。 + +季信息(各季首播年份与集数)只取 TMDB 详情(按 TMDB 缓存时长复用,失败不缓存);本地季单元可能过期,不用来否决。与 MP 的差别:剧集年份允许相差 1 年(MP 要求等于某季年份,蓝光常晚一年发行);允许绝对集号;拆词时多按 `[` `]` 拆。没有做 MP 的「无年份别名命中再识别消歧」。 + +不属于的不参与筛选、分面与 `total` / `unfilteredTotal`,响应另给 `unmatched`(只在第一页,按当前排序,最多 200 条)与 `unmatchedTotal`;页面在列表最后折叠为「另有 N 条可能不是这部作品」,展开后与上面的行一样可下载。 + ### 作品关联与库存叠加 叠加每次请求时现算,不进缓存,所以识别完成后刷新一次就能看到。 @@ -61,11 +73,11 @@ `GET /search/releases` 与 `GET /sites/{id}/releases` 共用,口径在 `packages/domain/src/release-facets/`。 - 参数 `qf=维度:值,值;维度:值`,值经 `encodeURIComponent`。同一维度多值为 OR,不同维度为 AND;与其它筛选参数叠加(AND),排序、游标照旧。 -- 维度与档位:`resolution` 2160p / 1080p(含 1080i)/ 720p / other;`videoCodec` H.265 / H.264 / AV1 / other;`releaseType` 按解析的来源:disc 原盘(BluRay-Disc)/ remux / encode 蓝光压制(BluRay)/ webdl(WEB-DL)/ hdtv / other(WEBRip、DVD 等);`site` 站点 ID;`promotion` free / 2x / 50% / other(30% 等)/ none,一条可同时属于 free 和 2x,促销已过期算 none;`hdr` DV / HDR10 / HDR10+ / other(HLG、HDR Vivid)/ SDR(没有 HDR 标记),一条可属于多个;`hr` yes / no / unknown;`releaseGroup` 制作组名(不分大小写);`season` 季号(多季合集展开到每一季);`episode` 集号,整季资源为 `pack`。 +- 维度与档位:`seasonEpisode` 季集(MP `season_episode` 写法:`S01`、`S01-S02`、`S02 E14`、`S01 E25-E48`);`resolution` 2160p / 1080p(含 1080i)/ 720p / other;`videoCodec` H.265 / H.264 / AV1 / other;`releaseType` 按解析的来源:disc 原盘(BluRay-Disc;标题有 BDMV / ISO 等,或 Blu-ray + AVC / HEVC 且没有 x26x / H.26x,或副标题写「原盘」「BDMV」「自购自抓」「DIY」「Blu-ray Disc」,或带 LPCM / PCM 音轨又没有 x264 / x265 / BDRip / 压制说法)/ remux / encode 蓝光压制(BluRay)/ webdl(WEB-DL)/ hdtv / other(WEBRip、DVD 等);`site` 站点 ID;`promotion` free / 2x / 50% / other(30% 等)/ none,一条可同时属于 free 和 2x,促销已过期算 none;`hdr` DV / HDR10 / HDR10+ / other(HLG、HDR Vivid)/ SDR(没有 HDR 标记),一条可属于多个;`hr` yes / no / unknown;`releaseGroup` 制作组名(不分大小写);`season` 季号(多季合集展开到每一季);`episode` 集号,整季资源为 `pack`(这两个是旧维度,筛选条上已换成 `seasonEpisode`,旧链接仍生效)。 - SEARCH-4 新增:`audioCodec` Atmos / TrueHD / DTS:X / DTS-HD(MA、HR)/ DTS / DDP(DD+、E-AC3)/ DD(AC3)/ AAC / FLAC / LPCM(含 PCM)/ other / unknown(标题没写),一条可属于多个,声道和 `2Audios` 不算编码;`subtitle` zh(任何中文字幕)/ zh-Hans / zh-Hant / en / ja / ko / unknown;`dub` cmn 国语 / yue 粤语 / en 英语 / unknown(来自「国语」「国配」「国粤双语」「国英双语」等标记);`feature` 10bit / hfr(50 帧以上)/ cut(加长、导演剪辑等剪辑版),没有的不落任何档;`year` 年份,新的在前;`category` 站点分类名,计数最多的 20 个;`size` lt1 / 1-5 / 5-10 / 10-20 / 20-50 / 50+(GiB)/ unknown;`seeders` 0 / 1-9 / 10-49 / 50+ / unknown。 -- 响应 `facets.<维度>` 为 `{value, label, count}`。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,0 表示在其它条件下没有结果。固定档位总会列出,标了"只在有资源时列出"的档位(other、unknown、日文 / 韩文、特性、DTS:X、FLAC、LPCM 等)只在有资源时列出;制作组、分类只列计数最多的 20 个(已选的补在后面);没有剧集资源时 `season` / `episode` 为空;已选但当前没有资源的值计数 0。 +- 响应 `facets.<维度>` 为 `{value, label, count}`。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,选项只列结果里实际出现、且在其它条件下计数不为 0 的值(对照 MP `initOptions`),固定档位按固定顺序;已选的值总会列出(当前没有资源时计数 0,可删除)。制作组、分类只列计数最多的 20 个(已选的补在后面);季集整季在前、单集在后,季号新的在前、同季集号大的在前(照 MP `sortSeasonOptions`);没有剧集资源时 `seasonEpisode` / `season` / `episode` 为空。 - 站点资源列表:先按 `q` / 分类 / 促销 / 识别状态从资源表取出全部候选的解析字段、促销、分类名(列表快照 → RSS → 分类 ID)、大小、做种,在内存里算分面和筛选,再只为当前页取完整行。只读本地资源表,`qf` 不会触发实时补查;响应另有 `total`(快速筛选后)与 `unfilteredTotal`。配音语言是解析器 SEARCH-4 才补的字段,解析结果里没有时按标题 + 副标题现扫(不改库)。 -- 前端:`components/search/quick-filter-bar.tsx`(桌面弹层、手机底部抽屉),状态与 `qf` 拼接在 `quick-filter-state.ts`。筛选条顺序照 MP:站点、季/集、促销、编码、质量、HDR、分辨率、制作组;其余收进「更多筛选」(桌面一个宽弹层分节、手机一个抽屉):音频、字幕、配音、特性、年份、分类、大小、做种、H&R。搜索结果页已有精确的大小区间(「大小与排序」),不显示大小分档;站点资源 Tab 已有站点分类下拉(与 MP 站点资源一致),不显示分类分面,原来只筛已加载结果的「做种 / 大小」下拉换成分面(按全部本地资源)。 +- 前端:`components/search/quick-filter-bar.tsx`(桌面弹层、手机底部抽屉),状态与 `qf` 拼接在 `quick-filter-state.ts`。筛选条顺序照 MP,季集放最前:季集、站点、促销、编码、质量、HDR、分辨率、制作组;其余收进「更多筛选」(桌面一个宽弹层分节、手机一个抽屉):音频、字幕、配音、特性、年份、分类、大小、做种、H&R。搜索结果页已有精确的大小区间(「大小与排序」),不显示大小分档;站点资源 Tab 已有站点分类下拉(与 MP 站点资源一致),不显示分类分面,原来只筛已加载结果的「做种 / 大小」下拉换成分面(按全部本地资源)。 - 本机记住上次选择(localStorage `umt.quickFilters.search` / `umt.quickFilters.site`),只记与结果无关的固定档位:分辨率、编码、质量、促销、HDR、H&R、音频、字幕、配音、特性、大小、做种;站点、制作组、季集、年份、分类随结果变化,不记。「清除」清全部,弹层 / 抽屉里的「清除」只清本节。 - 弹层材质:全站悬浮层(`pg-float`:Popover、Select、下拉菜单、对话框、抽屉)从 SEARCH-4 起是不透明实底(`--mat-float-bg` = `--popover`),不做背景模糊,只改 token,组件侧不要再加半透明底。 @@ -76,7 +88,7 @@ MP 搜索结果页筛选条(`src/composables/useTorrentFilter.ts` + `components/ | MP 维度(取值来源) | 本工具 | 解析字段来源 | 说明 | |---|---|---|---| | 站点 `site`(site_name) | 已有 `site` | 站点 | 单站资源列表不显示 | -| 季 `season`(season_episode,如 `S01 E03`) | 已有 `season` + `episode` | `season` / `seasonEnd` / `episodes` | MP 季集是一个字符串;这里季、集分两节,多季合集展开到每季,整季单列 | +| 季 `season`(season_episode,如 `S01 E03`) | `seasonEpisode`(旧的 `season` + `episode` 保留) | `season` / `seasonEnd` / `episodes` | 与 MP 同一写法、同一排序(整季在前);放在筛选条最前 | | 促销 `freeState`(普通 / 免费 / 2X / 2X免费 / 50% / 2X 50% / 30%…) | 已有 `promotion` | 站点促销 + 到期 | 归到 免费 / 2x / 50% / 其他 / 无,2X免费同时算免费和 2x;过期算无 | | 视频编码 `videoCode`(video_encode) | 已有 `videoCodec` | `videoCodec` | H.265 / H.264 / AV1 / 其他 | | 质量 `edition`(resource_type + resource_effect,如 `BluRay DoVi`) | 已有 `releaseType`(质量)+ `hdr` | `source`、`hdr` | MP 把来源和特效拼成一个字符串;这里拆成「质量」和「HDR」两个维度,组合更灵活 |