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,在其它筛选条件之后、按其它维度的已选条件计数)