feat(search): 按作品搜资源时判定每条结果是否属于这部作品

对照 MP TorrentHelper.match_torrent:已识别到同一本地作品(或同一 TMDB 身份)的属于、识别到别的作品的不属于;
未识别的先排除音乐 / 游戏 / 软件 / 书分类,再核对类型、年份(剧集按各季首播年份 ±1)、季集范围(TMDB 季列表,
单季集号可按全部季总集数的绝对集号),最后按解析出的中 / 英文名、标题非英文词、副标题非英文词比对作品名、原名、别名。
不属于的不参与筛选与分面,响应里单独给出(unmatched / unmatchedTotal,只在第一页,最多 200 条),
页面在列表最后折叠为「另有 N 条可能不是这部作品」,展开后仍可下载。关键词搜索不做这一步。
用「药屋少女的呢喃」109 条真实结果做样本:只有《迷宫饭》片尾曲专辑和标题写成 S01-S24 的一条被判为不属于。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:48:46 +08:00
1 parent f6d976a963
commit 9d7e20067b
9 files changed
+1560 -10

No files matched your search

+55 -4
View File
@@ -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<string, { expires: number; value: Promise<MetadataCandidate | null> }>();
const cachedDetails = (type: "movie" | "tv", id: string): Promise<MetadataCandidate | null> => {
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<WorkTarget | null> => {
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<TmdbRichDetail | null> => {
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;
@@ -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<string>;
/** 别名、译名(归一化) */
names: Set<string>;
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<MetadataCandidate | null>;
/** 本地作品 → 目标(名字取作品名 / 原名 / 别名表;季信息取 TMDB 详情,拿不到就不核对季集范围) */
export async function localWorkTarget(
ctx: ServerContext,
entityId: string,
details: TmdbDetails,
): Promise<WorkTarget | null> {
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<Map<string, ReleaseLink>> {
const out = new Map<string, ReleaseLink>();
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<string, string>();
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 };
}
+874
View File
@@ -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": "药屋少女的呢喃"
}
]
@@ -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`);
+190
View File
@@ -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<string, ReleaseLink> = 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);
});
});
+1
View File
@@ -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",
@@ -57,6 +57,8 @@ import {
// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。
// SEARCH-5(参考 MP 资源卡片):促销放标题前、规格拆成中性标签、关键数字定宽对齐、按作品分组,
// 每行附规则判定(命中第几档 / 不满足什么 / 与库里版本比较,服务端 ruleVerdict)。
// 按作品搜时,服务端判定不属于这部作品的资源(对照 MP match_torrent)不参与筛选与分面,
// 在列表最后折叠为「另有 N 条可能不是这部作品」,展开后仍可下载。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = {
@@ -247,14 +249,35 @@ export function ReleaseResults({
<EmptyState
plain
icon={PackageSearch}
title={filtered ? "没有符合筛选条件的资源" : "所选站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换站点或换关键词再搜,或到站点页检查站点状态。"}
title={
filtered
? "没有符合筛选条件的资源"
: first?.unmatchedTotal
? "没有找到这部作品的资源"
: "所选站点都没有找到资源"
}
description={
filtered
? "放宽筛选条件再看。"
: first?.unmatchedTotal
? "下面是按名字搜到、但可能不是这部作品的资源。"
: "换站点或换关键词再搜,或到站点页检查站点状态。"
}
className="py-12"
/>
) : (
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} />
)}
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
{first?.unmatchedTotal ? (
<UnmatchedReleases
key={JSON.stringify(api)}
rows={first.unmatched ?? []}
total={first.unmatchedTotal}
onOpen={setSelected}
onDownload={setDl}
/>
) : null}
</div>
{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 (
<div className="border-t border-border/60">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-1.5 px-4 py-2.5 text-left text-caption text-muted-foreground transition-colors duration-micro hover:bg-foreground/[0.03] hover:text-foreground"
>
<ChevronDown className={cn("h-4 w-4 transition-transform duration-micro", open && "rotate-180")} />
另有 {total} 条可能不是这部作品
</button>
{open ? (
<div className="border-t border-border/60">
<ReleaseList rows={rows} onOpen={onOpen} onDownload={onDownload} />
</div>
) : null}
</div>
);
}
/** 部分站点失败 / 跳过:列出站点,其余结果照常 */
function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
const [open, setOpen] = useState(false);
@@ -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("");
});
});