merge: fix/m-search-filter
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
88f98386e8
21 files changed
+1950
-135
No files matched your search
@@ -79,13 +79,12 @@ export class ReleaseFanout {
|
||||
|
||||
/**
|
||||
* 在给定站点上搜一组查询词:每站每词一次请求(同站多个词的结果按站点资源 ID 去重)。
|
||||
* 缓存按(站点, 查询词):失败 / 超时也缓存,但一轮里全部站点都失败时不缓存(下次直接重试)。
|
||||
* 缓存按(站点, 查询词):只缓存成功的结果;失败 / 超时 / 验证拦截 / 跳过不写缓存,下次搜索直接重试。
|
||||
*/
|
||||
async search(terms: string[], sites: SiteRow[], opts: { refresh?: boolean } = {}): Promise<FanoutHit> {
|
||||
const words = [...new Map(terms.map((t) => [cacheKey(t), t.trim()])).entries()].filter(([k]) => k);
|
||||
let cached = true;
|
||||
let coalesced = false;
|
||||
const fresh: string[] = [];
|
||||
const perSite = await mapLimit(sites, Math.max(1, this.opts.concurrency), async (site) => {
|
||||
const parts: SiteResult[] = [];
|
||||
for (const [k, term] of words) {
|
||||
@@ -105,16 +104,11 @@ export class ReleaseFanout {
|
||||
const p = this.searchSite(this.rt(), site, term).finally(() => this.inflight.delete(key));
|
||||
this.inflight.set(key, p);
|
||||
const r = await p;
|
||||
this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, result: r });
|
||||
fresh.push(key);
|
||||
if (r.coverage.status === "ok") this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, result: r });
|
||||
parts.push(r);
|
||||
}
|
||||
return mergeSite(site, parts);
|
||||
});
|
||||
// 本轮新查的站点全部失败:撤掉这些缓存
|
||||
const freshResults = fresh.map((k) => this.cache.get(k)?.result).filter((r): r is SiteResult => !!r);
|
||||
if (freshResults.length && freshResults.every((r) => r.coverage.status !== "ok"))
|
||||
for (const k of fresh) this.cache.delete(k);
|
||||
this.prune();
|
||||
const fetched = perSite.map((r) => r.fetchedAt);
|
||||
return {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -204,8 +204,9 @@ const emptyInventory = (entityId: string): InventoryEntry => ({
|
||||
|
||||
/**
|
||||
* 搜资源的查询词(每个站点每个词查一次,最多 2 个,控制对站点的请求数):
|
||||
* 原名(电影带年份,站点标题多为「原名 年份 …」)+ 译名(与原名归一化后不同才加)。
|
||||
* 剧集不带年份(各季年份不同)。
|
||||
* 国内站点按中文名收录、副标题也多写中文名,所以作品名(中文)在前,原名第二(与作品名归一化后不同才加)。
|
||||
* 拉丁文字的名字在电影上带年份(站点标题多为「原名 年份 …」),只带一个;剧集不带年份(各季年份不同)。
|
||||
* 作品名不含中日韩文字(没有中文译名)时原名在前。
|
||||
*/
|
||||
export function searchTerms(w: {
|
||||
title: string;
|
||||
@@ -215,17 +216,23 @@ export function searchTerms(w: {
|
||||
}): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (t: string | null, withYear: boolean) => {
|
||||
const latin = (s: string | null) => !!s && /^[\p{Script=Latin}\d\s\p{P}\p{S}]+$/u.test(s);
|
||||
const add = (t: string | null) => {
|
||||
const s = t?.trim();
|
||||
if (!s) return;
|
||||
const n = normalizeTitle(s);
|
||||
if (!n || seen.has(n)) return;
|
||||
seen.add(n);
|
||||
out.push(withYear && w.year ? `${s} ${w.year}` : s);
|
||||
const withYear = w.movie && !!w.year && latin(s) && !out.some(latin);
|
||||
out.push(withYear ? `${s} ${w.year}` : s);
|
||||
};
|
||||
const latin = (s: string | null) => !!s && /^[\p{Script=Latin}\d\s\p{P}\p{S}]+$/u.test(s);
|
||||
add(w.originalTitle, w.movie && latin(w.originalTitle));
|
||||
add(w.title, w.movie && latin(w.title) && out.length === 0);
|
||||
if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(w.title)) {
|
||||
add(w.title);
|
||||
add(w.originalTitle);
|
||||
} else {
|
||||
add(w.originalTitle);
|
||||
add(w.title);
|
||||
}
|
||||
return out.slice(0, 2);
|
||||
}
|
||||
|
||||
|
||||
+874
@@ -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": "药屋少女的呢喃"
|
||||
}
|
||||
]
|
||||
@@ -289,13 +289,20 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
expect(rows.length).toBe(3);
|
||||
});
|
||||
|
||||
it("30s 内相同查询命中缓存,不再请求站点", async () => {
|
||||
it("30s 内相同查询:成功的站点命中缓存不再请求;超时的站点不缓存,每次重试", async () => {
|
||||
const before = [nexusSearches(), mteamSearches(), slowCalls];
|
||||
const r = await req("/search?q=%20dune%20&scope=releases");
|
||||
expect(r.body.data.releases).toMatchObject({ cached: true, total: 3, partial: true });
|
||||
// 超时的站点重新查了一次,所以整体不算缓存结果
|
||||
expect(r.body.data.releases).toMatchObject({ cached: false, total: 3, partial: true });
|
||||
expect([nexusSearches(), mteamSearches()]).toEqual(before.slice(0, 2));
|
||||
expect(slowCalls).toBe(before[2]! + 1);
|
||||
const page = await req("/search/releases?q=Dune");
|
||||
expect(page.body.data).toMatchObject({ cached: true, total: 3 });
|
||||
expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before);
|
||||
expect(page.body.data).toMatchObject({ cached: false, total: 3, partial: true });
|
||||
expect([nexusSearches(), mteamSearches()]).toEqual(before.slice(0, 2));
|
||||
expect(slowCalls).toBe(before[2]! + 2);
|
||||
// 只查成功过的站点:全部命中缓存
|
||||
const okOnly = await req(`/search/releases?q=Dune&sites=${sites.nexus},${sites.mteam}`);
|
||||
expect(okOnly.body.data).toMatchObject({ cached: true, total: 3, partial: false });
|
||||
// refresh=true 跳过缓存
|
||||
const fresh = await req("/search/releases?q=Dune&refresh=true");
|
||||
expect(fresh.body.data.cached).toBe(false);
|
||||
@@ -387,7 +394,9 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
});
|
||||
|
||||
it("筛选 / 排序 / 游标 / 分面", async () => {
|
||||
const q = (s: string) => req(`/search/releases?q=Dune&${s}`).then((r) => r.body.data);
|
||||
// 只查成功的两站(超时站点不缓存,每次重试会拖慢用例)
|
||||
const ok = `sites=${sites.nexus},${sites.mteam}`;
|
||||
const q = (s: string) => req(`/search/releases?q=Dune&${ok}&${s}`).then((r) => r.body.data);
|
||||
let d = await q("resolution=2160p");
|
||||
expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["812345", "990001"]);
|
||||
expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 });
|
||||
@@ -399,7 +408,7 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
|
||||
d = await q("source=web-dl,REMUX&free=false");
|
||||
expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["900001", "990001"]);
|
||||
d = await q(`sites=${sites.mteam}`);
|
||||
d = (await req(`/search/releases?q=Dune&sites=${sites.mteam}`)).body.data;
|
||||
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]);
|
||||
d = await q(`minSize=${10 * 1024 ** 3}&maxSize=${50 * 1024 ** 3}`);
|
||||
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]);
|
||||
@@ -420,21 +429,24 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
});
|
||||
|
||||
it("快速筛选 qf(SEARCH-3):分面计数、同维度 OR / 跨维度 AND、分页一致", async () => {
|
||||
const ok = `sites=${sites.nexus},${sites.mteam}`;
|
||||
const q = (qf: string, extra = "") =>
|
||||
req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data);
|
||||
req(`/search/releases?q=Dune&${ok}&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data);
|
||||
const counts = (opts: Loose[]) => Object.fromEntries(opts.map((o) => [o.value, o.count]));
|
||||
const ids = (d: Loose) => d.items.map((i: Loose) => i.siteReleaseId).sort();
|
||||
|
||||
let d = await q("");
|
||||
expect(d.total).toBe(3);
|
||||
expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1, "720p": 0 });
|
||||
expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0 });
|
||||
expect(counts(d.facets.releaseType)).toEqual({ disc: 0, remux: 1, encode: 0, webdl: 2, hdtv: 0 });
|
||||
// 只列结果里有的档位
|
||||
expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1 });
|
||||
expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1 });
|
||||
expect(counts(d.facets.releaseType)).toEqual({ remux: 1, webdl: 2 });
|
||||
expect(counts(d.facets.site)).toEqual({ [sites.nexus]: 2, [sites.mteam]: 1 });
|
||||
expect(d.facets.site.find((f: Loose) => f.value === sites.nexus).label).toBe("观众");
|
||||
expect(counts(d.facets.releaseGroup)).toEqual({ FraMeSToR: 1, GRP: 1, FLUX: 1 });
|
||||
expect(d.facets.hdr.find((f: Loose) => f.value === "SDR").count).toBe(2);
|
||||
expect(d.facets.season).toEqual([]);
|
||||
expect(d.facets.seasonEpisode).toEqual([]);
|
||||
expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(3);
|
||||
// 旧分面仍在
|
||||
expect(d.facets.resolutions.length).toBe(2);
|
||||
@@ -490,10 +502,8 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
d = await list("resolution:1080p");
|
||||
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
|
||||
expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 });
|
||||
expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([
|
||||
["FraMeSToR", 0],
|
||||
["GRP", 1],
|
||||
]);
|
||||
// 在 1080p 下没有资源的制作组不列
|
||||
expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([["GRP", 1]]);
|
||||
// 分页
|
||||
const p1 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1`).then((r) => r.body.data);
|
||||
expect(p1.items.length).toBe(1);
|
||||
|
||||
@@ -32,21 +32,32 @@ const up = await pgAvailable();
|
||||
type Loose = any;
|
||||
|
||||
describe("searchTerms", () => {
|
||||
it("电影:原名带年份 + 译名;同名只留一个;剧集不带年份", () => {
|
||||
it("中文作品名在前、原名第二;拉丁名在电影上带年份;同名只留一个;剧集不带年份", () => {
|
||||
expect(searchTerms({ title: "星际穿越", originalTitle: "Interstellar", year: 2014, movie: true })).toEqual([
|
||||
"Interstellar 2014",
|
||||
"星际穿越",
|
||||
"Interstellar 2014",
|
||||
]);
|
||||
expect(searchTerms({ title: "流浪地球2", originalTitle: "流浪地球2", year: 2023, movie: true })).toEqual([
|
||||
"流浪地球2",
|
||||
]);
|
||||
expect(searchTerms({ title: "熊家餐馆", originalTitle: "The Bear", year: 2022, movie: false })).toEqual([
|
||||
"The Bear",
|
||||
"熊家餐馆",
|
||||
"The Bear",
|
||||
]);
|
||||
expect(searchTerms({ title: "Oppenheimer", originalTitle: null, year: 2023, movie: true })).toEqual([
|
||||
"Oppenheimer 2023",
|
||||
]);
|
||||
// 日文原名:中文名在前,原名第二
|
||||
expect(
|
||||
searchTerms({ title: "药屋少女的呢喃", originalTitle: "薬屋のひとりごと", year: 2023, movie: false }),
|
||||
).toEqual(["药屋少女的呢喃", "薬屋のひとりごと"]);
|
||||
// 繁简只差字形的算同一个
|
||||
expect(searchTerms({ title: "无间道", originalTitle: "無間道", year: 2002, movie: true })).toEqual(["无间道"]);
|
||||
// 作品名没有中文译名(仍是英文)时原名在前
|
||||
expect(
|
||||
searchTerms({ title: "The Apothecary Diaries", originalTitle: "薬屋のひとりごと", year: 2023, movie: false }),
|
||||
).toEqual(["薬屋のひとりごと", "The Apothecary Diaries"]);
|
||||
expect(searchTerms({ title: " ", originalTitle: null, year: null, movie: false })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,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();
|
||||
|
||||
@@ -287,24 +305,77 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => {
|
||||
await db.deleteFrom("settings").where("key", "=", "search").execute();
|
||||
});
|
||||
|
||||
it("按作品搜资源:服务端用原名 + 年份 / 译名生成查询词,每站每词一次", async () => {
|
||||
it("按作品搜资源:服务端用中文名 / 原名 + 年份生成查询词,每站每词一次", async () => {
|
||||
const before = counts();
|
||||
const r = await req(`/search/releases?tmdb=movie:693134&sites=${sites.a}`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]);
|
||||
expect(r.body.data.q).toBe("Dune: Part Two 2024");
|
||||
expect(r.body.data.queries).toEqual(["沙丘2", "Dune: Part Two 2024"]);
|
||||
expect(r.body.data.q).toBe("沙丘2");
|
||||
const sent = searches(a).slice(before[0]);
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(r.body.data.sites[0]).toMatchObject({ siteId: sites.a, status: "ok" });
|
||||
|
||||
const byEntity = await req(`/search/releases?entityId=${dune}&sites=${sites.a}`);
|
||||
expect(byEntity.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]);
|
||||
expect(byEntity.body.data.queries).toEqual(["沙丘2", "Dune: Part Two 2024"]);
|
||||
expect(byEntity.body.data.cached).toBe(true);
|
||||
|
||||
expect((await req(`/search/releases?sites=${sites.a}`)).status).toBe(422);
|
||||
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`);
|
||||
@@ -320,7 +391,7 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => {
|
||||
inventory: { state: "not_collected" },
|
||||
mediaServers: null,
|
||||
seasons: [],
|
||||
queries: ["Oppenheimer 2023", "奥本海默"],
|
||||
queries: ["奥本海默", "Oppenheimer 2023"],
|
||||
externalIds: { imdb: "tt15398776" },
|
||||
});
|
||||
|
||||
@@ -338,7 +409,7 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => {
|
||||
[2, 10, null],
|
||||
[3, 10, null],
|
||||
]);
|
||||
expect(tv.body.data.queries).toEqual(["The Bear", "熊家餐馆"]);
|
||||
expect(tv.body.data.queries).toEqual(["熊家餐馆", "The Bear"]);
|
||||
|
||||
expect((await req(`/media/tmdb/movie/999999999`)).status).toBe(404);
|
||||
expect((await req(`/media/tmdb/person/1`)).status).toBe(422);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
} from "./quick-filter-state.ts";
|
||||
|
||||
// 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选;
|
||||
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。
|
||||
// 选项只列结果里有的值(服务端不给 0 计数的选项),带计数(按其它维度的已选条件);已选但当前为 0 的置灰;
|
||||
// 已选条件以可移除标签列出,只有一个「清除」。季集在最前(整季在前、单集在后)。
|
||||
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
|
||||
// POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。
|
||||
// SEARCH-4:对齐 MP 后维度变多,常用的留在筛选条上,其余(音频、字幕、配音、特性、年份、分类、大小、做种、H&R)
|
||||
@@ -62,7 +63,7 @@ export function QuickFilterBar({
|
||||
const dims = g.dims.filter((d) => !hide.includes(d.dim));
|
||||
if (!dims.length) return false;
|
||||
const opts = dims.flatMap((d) => facets?.[d.dim] ?? []);
|
||||
// 站点只有一个时不用筛;季/集只在有剧集资源时出现
|
||||
// 站点只有一个时不用筛;季集只在有剧集资源时出现
|
||||
if (g.key === "site" && opts.length <= 1 && !selectedCount(value, ["site"])) return false;
|
||||
return (
|
||||
opts.length > 0 ||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
// SEARCH-3 快速筛选:qf 参数、选择切换、已选标签、本机记忆只存固定档位
|
||||
|
||||
const empty = {
|
||||
seasonEpisode: [],
|
||||
resolution: [],
|
||||
videoCodec: [],
|
||||
releaseType: [],
|
||||
@@ -41,9 +42,11 @@ describe("快速筛选状态", () => {
|
||||
expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe(
|
||||
"hdr:DV;resolution:2160p;releaseGroup:A%2CB",
|
||||
);
|
||||
expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"] })).toBe(
|
||||
"site:s;audioCodec:TrueHD;seeders:0",
|
||||
expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"], seasonEpisode: ["S01"] })).toBe(
|
||||
"seasonEpisode:S01;site:s;audioCodec:TrueHD;seeders:0",
|
||||
);
|
||||
// 旧的季 / 集维度排在最后,仍会带上
|
||||
expect(formatQuickFilter({ season: ["2"], resolution: ["2160p"] })).toBe("resolution:2160p;season:2");
|
||||
expect(formatQuickFilter({ resolution: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -57,11 +60,16 @@ describe("快速筛选状态", () => {
|
||||
expect(selectedCount(clearDims(s, ["season", "episode"]))).toBe(1);
|
||||
});
|
||||
|
||||
it("已选标签取分面里的选项名,取不到用原值", () => {
|
||||
const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] };
|
||||
expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([
|
||||
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
|
||||
it("已选标签取分面里的选项名,取不到用原值;季集在最前,旧的季 / 集在最后", () => {
|
||||
const facets = {
|
||||
...empty,
|
||||
releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }],
|
||||
seasonEpisode: [{ value: "S02 E14", label: "S02 E14", count: 1 }],
|
||||
};
|
||||
expect(selectedTags({ releaseType: ["encode"], season: ["2"], seasonEpisode: ["S02 E14"] }, facets)).toEqual([
|
||||
{ dim: "seasonEpisode", value: "S02 E14", dimLabel: "季集", label: "S02 E14" },
|
||||
{ dim: "releaseType", value: "encode", dimLabel: "质量", label: "蓝光压制" },
|
||||
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -86,7 +94,7 @@ describe("快速筛选状态", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("「更多筛选」收的是 MP 筛选条之外的维度", () => {
|
||||
it("「更多筛选」收的是 MP 筛选条之外的维度;季集放最前", () => {
|
||||
expect(MORE_DIMS).toEqual([
|
||||
"audioCodec",
|
||||
"subtitle",
|
||||
@@ -99,8 +107,8 @@ describe("快速筛选状态", () => {
|
||||
"hr",
|
||||
]);
|
||||
expect(QUICK_GROUPS.filter((g) => !g.more).map((g) => g.label)).toEqual([
|
||||
"季集",
|
||||
"站点",
|
||||
"季/集",
|
||||
"促销",
|
||||
"编码",
|
||||
"质量",
|
||||
|
||||
@@ -8,9 +8,10 @@ export type QuickDim = keyof ReleaseQuickFacets;
|
||||
export type QuickSelection = Partial<Record<QuickDim, string[]>>;
|
||||
|
||||
/**
|
||||
* 筛选条上的一个入口;「季/集」一个入口里分两节。
|
||||
* 筛选条上的一个入口。
|
||||
* more = 收进「更多筛选」(SEARCH-4):常用维度按 MP 搜索结果筛选条的顺序放在前面
|
||||
* (站点、季集、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。
|
||||
* (季集放最前,再是站点、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。
|
||||
* 季集是一个维度(MP 的 season_episode):整季在前、单集在后,服务端排好序。
|
||||
*/
|
||||
export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[]; more?: boolean };
|
||||
|
||||
@@ -22,15 +23,8 @@ const one = (dim: QuickDim, label: string, more?: boolean): QuickGroup => ({
|
||||
});
|
||||
|
||||
export const QUICK_GROUPS: QuickGroup[] = [
|
||||
one("seasonEpisode", "季集"),
|
||||
one("site", "站点"),
|
||||
{
|
||||
key: "seasonEpisode",
|
||||
label: "季/集",
|
||||
dims: [
|
||||
{ dim: "season", label: "季" },
|
||||
{ dim: "episode", label: "集" },
|
||||
],
|
||||
},
|
||||
one("promotion", "促销"),
|
||||
one("videoCodec", "编码"),
|
||||
one("releaseType", "质量"),
|
||||
@@ -51,7 +45,16 @@ export const QUICK_GROUPS: QuickGroup[] = [
|
||||
export const MORE_GROUPS = QUICK_GROUPS.filter((g) => g.more);
|
||||
export const MORE_DIMS: QuickDim[] = MORE_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
|
||||
|
||||
const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
|
||||
/** 旧的季 / 集两个维度:筛选条上不再出现,旧链接带着时仍生效,并以可删除的标签列出 */
|
||||
const LEGACY_DIMS: { dim: QuickDim; label: string }[] = [
|
||||
{ dim: "season", label: "季" },
|
||||
{ dim: "episode", label: "集" },
|
||||
];
|
||||
|
||||
const DIM_ORDER: QuickDim[] = [
|
||||
...QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)),
|
||||
...LEGACY_DIMS.map((d) => d.dim),
|
||||
];
|
||||
|
||||
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集、年份、分类随搜索变化,不记 */
|
||||
const PERSISTED: QuickDim[] = [
|
||||
@@ -97,7 +100,9 @@ export function selectedTags(
|
||||
sel: QuickSelection,
|
||||
facets: ReleaseQuickFacets | undefined,
|
||||
): { dim: QuickDim; value: string; dimLabel: string; label: string }[] {
|
||||
const dimLabel = new Map(QUICK_GROUPS.flatMap((g) => g.dims.map((d) => [d.dim, d.label] as const)));
|
||||
const dimLabel = new Map(
|
||||
[...QUICK_GROUPS.flatMap((g) => g.dims), ...LEGACY_DIMS].map((d) => [d.dim, d.label] as const),
|
||||
);
|
||||
return DIM_ORDER.flatMap((dim) =>
|
||||
(sel[dim] ?? []).map((value) => ({
|
||||
dim,
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
+19
-7
@@ -1,6 +1,6 @@
|
||||
# 全局搜索运行手册(W9-G、SEARCH-2)
|
||||
|
||||
实现:`apps/server/src/modules/search/`(`routes.ts` 端点、`releases.ts` 跨站搜索/缓存/筛选/叠加、`media.ts` 本地作品与 TMDB、`works.ts` 作品合并/库存对比/查询词、`actions.ts` 页面与操作清单、`dispatch.ts` 进程内转调);合同 `packages/contracts/src/search.ts`;测试 `apps/server/test/search/`(`search-results-ui.test.ts` 为两阶段)。
|
||||
实现:`apps/server/src/modules/search/`(`routes.ts` 端点、`releases.ts` 跨站搜索/缓存/筛选/叠加、`work-match.ts` 按作品搜时的归属判定、`media.ts` 本地作品与 TMDB、`works.ts` 作品合并/库存对比/查询词、`actions.ts` 页面与操作清单、`dispatch.ts` 进程内转调);合同 `packages/contracts/src/search.ts`;测试 `apps/server/test/search/`(`search-results-ui.test.ts` 为两阶段)。
|
||||
|
||||
## 两阶段流程(SEARCH-2)
|
||||
|
||||
@@ -30,15 +30,27 @@
|
||||
## 跨站资源搜索
|
||||
|
||||
- **站点范围**:只查选中的站点(`enabled=true` 且未删除)。`sites=` 给了就只查这些;没给按 `settings.search.defaultSiteMode`:`default` = 站点「默认参与搜索」(`site.search_default`,迁移 0048,缺省 true)的站点,`all` = 全部启用站点,`custom` = `settings.search.siteIds` 里仍启用的站点。`siteFilter=` 只筛选结果、不改查询的站点(前端站点分面用)。不支持搜索或没有适配器定义的站点记为 `skipped`。
|
||||
- **查询词**:给了 `q` 只用 `q`;按作品(`entityId` / `tmdb`)时由服务端生成,最多 2 个:原名(电影且原名是拉丁字母时带年份,如 `Interstellar 2014`)+ 译名(归一化后与原名不同才加);剧集不带年份。每个站点每个词查一次,同站结果按站点资源 ID 去重,响应 `queries` 给出实际查询词。
|
||||
- **查询词**:给了 `q` 只用 `q`;按作品(`entityId` / `tmdb`)时由服务端生成,最多 2 个:国内站点按中文名收录,作品名(中文)在前、原名第二(归一化后与作品名不同才加);作品名不含中日韩文字时原名在前。拉丁名在电影上带年份(如 `星际穿越` + `Interstellar 2014`),剧集不带年份。每个站点每个词查一次,同站结果按站点资源 ID 去重,响应 `queries` 给出实际查询词。
|
||||
- **限流**:沿用 sources-identity 运行时里每个站点的共享限流器,和快照、工作台、详情、下载用的是同一个令牌桶。
|
||||
- **超时**:单站 `AbortSignal.timeout(15s)`,限流等待也算在内;超时记为 `timeout`。
|
||||
- **站点级并发**:同时最多搜索 4 个站点(`createSearchModule({ siteConcurrency })`)。
|
||||
- **并发合并**:按(站点, 查询词)合并,查询词去首尾空白、小写、合并空格;同一对同时只会发一次请求,后到的等同一个结果,`coalesced=true`。
|
||||
- **短缓存**:按(站点, 查询词)缓存 30s,换站点选择时已查过的站点直接复用;全部命中时 `cached=true`。失败 / 超时也缓存,但一轮里新查的站点全部失败时不缓存。`refresh=true` 跳过缓存,但仍然参与合并。进程内缓存,多个 api 实例之间不共享。
|
||||
- **短缓存**:按(站点, 查询词)缓存 30s,换站点选择时已查过的站点直接复用;全部命中时 `cached=true`。只缓存成功的结果:失败 / 超时 / 验证拦截 / 跳过不写缓存,下次搜索直接重试。`refresh=true` 跳过缓存,但仍然参与合并。进程内缓存,多个 api 实例之间不共享。
|
||||
- **部分失败**:任一站点失败或超时,`partial=true`,`failedSites` 列出这些站点(status 为 `failed` 或 `timeout`,附已脱敏的错误)。其它站点的结果照常返回。`sites` 是每个站点的覆盖情况:status、count、tookMs、error。
|
||||
- **落库**:搜索结果按 site + 站点资源 ID upsert 到资源表(`source_release`),新资源照常发 `release.observed`,进入识别流水线,和工作台实时补查是同一条路径。`sourceReleaseId` 给站点工作台、下载、委托 Agent 用。落库失败只记警告,仍然返回结果,此时 `sourceReleaseId=null`。搜索不写 `site_snapshot`,也不改站点健康状态。
|
||||
|
||||
### 按作品搜时的归属判定(对照 MP `TorrentHelper.match_torrent`)
|
||||
|
||||
站点按关键词搜,简介里提到作品名的专辑、别的作品也会被搜到(例:搜「药屋少女的呢喃」带出《迷宫饭》片尾曲专辑「Channel U … BDMV」)。按作品(`entityId` / `tmdb`)搜时,每条结果先判定是否属于这部作品(`work-match.ts`),关键词搜索不做这一步:
|
||||
|
||||
1. 已识别到本地作品(跟随合并):同一作品,或该作品与目标有相同的已接受 TMDB 身份 → 属于;识别到别的作品 → 不属于。
|
||||
2. 未识别:站点分类是音乐 / 游戏 / 软件 / 书 → 不属于;剧集资源(有季集)或剧集分类而作品是电影 → 不属于;年份:电影上下 1 年且必须有年份,剧集与作品或任一季首播年份相差 1 年以内(没有季信息时只排除早于首播一年以上的);季集:资源的季必须在作品已知的季里,单季集号超过该季集数时按全部季总集数算(绝对集号)。
|
||||
3. 名字:解析出的中 / 英文名(英文名去掉末尾 `Vol.N`)归一化后命中作品名、原名或别名(`title_alias` + TMDB 别名 / 译名);标题按 `空白 / 【】 . [ ] -` 拆出的非英文词命中作品名 / 原名;副标题 / 简介按 `空白 / 【】 | [ ]` 拆出的非英文词命中作品名、原名或别名。都没命中 → 不属于。
|
||||
|
||||
季信息(各季首播年份与集数)只取 TMDB 详情(按 TMDB 缓存时长复用,失败不缓存);本地季单元可能过期,不用来否决。与 MP 的差别:剧集年份允许相差 1 年(MP 要求等于某季年份,蓝光常晚一年发行);允许绝对集号;拆词时多按 `[` `]` 拆。没有做 MP 的「无年份别名命中再识别消歧」。
|
||||
|
||||
不属于的不参与筛选、分面与 `total` / `unfilteredTotal`,响应另给 `unmatched`(只在第一页,按当前排序,最多 200 条)与 `unmatchedTotal`;页面在列表最后折叠为「另有 N 条可能不是这部作品」,展开后与上面的行一样可下载。
|
||||
|
||||
### 作品关联与库存叠加
|
||||
|
||||
叠加每次请求时现算,不进缓存,所以识别完成后刷新一次就能看到。
|
||||
@@ -61,11 +73,11 @@
|
||||
`GET /search/releases` 与 `GET /sites/{id}/releases` 共用,口径在 `packages/domain/src/release-facets/`。
|
||||
|
||||
- 参数 `qf=维度:值,值;维度:值`,值经 `encodeURIComponent`。同一维度多值为 OR,不同维度为 AND;与其它筛选参数叠加(AND),排序、游标照旧。
|
||||
- 维度与档位:`resolution` 2160p / 1080p(含 1080i)/ 720p / other;`videoCodec` H.265 / H.264 / AV1 / other;`releaseType` 按解析的来源:disc 原盘(BluRay-Disc)/ remux / encode 蓝光压制(BluRay)/ webdl(WEB-DL)/ hdtv / other(WEBRip、DVD 等);`site` 站点 ID;`promotion` free / 2x / 50% / other(30% 等)/ none,一条可同时属于 free 和 2x,促销已过期算 none;`hdr` DV / HDR10 / HDR10+ / other(HLG、HDR Vivid)/ SDR(没有 HDR 标记),一条可属于多个;`hr` yes / no / unknown;`releaseGroup` 制作组名(不分大小写);`season` 季号(多季合集展开到每一季);`episode` 集号,整季资源为 `pack`。
|
||||
- 维度与档位:`seasonEpisode` 季集(MP `season_episode` 写法:`S01`、`S01-S02`、`S02 E14`、`S01 E25-E48`);`resolution` 2160p / 1080p(含 1080i)/ 720p / other;`videoCodec` H.265 / H.264 / AV1 / other;`releaseType` 按解析的来源:disc 原盘(BluRay-Disc;标题有 BDMV / ISO 等,或 Blu-ray + AVC / HEVC 且没有 x26x / H.26x,或副标题写「原盘」「BDMV」「自购自抓」「DIY」「Blu-ray Disc」,或带 LPCM / PCM 音轨又没有 x264 / x265 / BDRip / 压制说法)/ remux / encode 蓝光压制(BluRay)/ webdl(WEB-DL)/ hdtv / other(WEBRip、DVD 等);`site` 站点 ID;`promotion` free / 2x / 50% / other(30% 等)/ none,一条可同时属于 free 和 2x,促销已过期算 none;`hdr` DV / HDR10 / HDR10+ / other(HLG、HDR Vivid)/ SDR(没有 HDR 标记),一条可属于多个;`hr` yes / no / unknown;`releaseGroup` 制作组名(不分大小写);`season` 季号(多季合集展开到每一季);`episode` 集号,整季资源为 `pack`(这两个是旧维度,筛选条上已换成 `seasonEpisode`,旧链接仍生效)。
|
||||
- SEARCH-4 新增:`audioCodec` Atmos / TrueHD / DTS:X / DTS-HD(MA、HR)/ DTS / DDP(DD+、E-AC3)/ DD(AC3)/ AAC / FLAC / LPCM(含 PCM)/ other / unknown(标题没写),一条可属于多个,声道和 `2Audios` 不算编码;`subtitle` zh(任何中文字幕)/ zh-Hans / zh-Hant / en / ja / ko / unknown;`dub` cmn 国语 / yue 粤语 / en 英语 / unknown(来自「国语」「国配」「国粤双语」「国英双语」等标记);`feature` 10bit / hfr(50 帧以上)/ cut(加长、导演剪辑等剪辑版),没有的不落任何档;`year` 年份,新的在前;`category` 站点分类名,计数最多的 20 个;`size` lt1 / 1-5 / 5-10 / 10-20 / 20-50 / 50+(GiB)/ unknown;`seeders` 0 / 1-9 / 10-49 / 50+ / unknown。
|
||||
- 响应 `facets.<维度>` 为 `{value, label, count}`。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,0 表示在其它条件下没有结果。固定档位总会列出,标了"只在有资源时列出"的档位(other、unknown、日文 / 韩文、特性、DTS:X、FLAC、LPCM 等)只在有资源时列出;制作组、分类只列计数最多的 20 个(已选的补在后面);没有剧集资源时 `season` / `episode` 为空;已选但当前没有资源的值计数 0。
|
||||
- 响应 `facets.<维度>` 为 `{value, label, count}`。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,选项只列结果里实际出现、且在其它条件下计数不为 0 的值(对照 MP `initOptions`),固定档位按固定顺序;已选的值总会列出(当前没有资源时计数 0,可删除)。制作组、分类只列计数最多的 20 个(已选的补在后面);季集整季在前、单集在后,季号新的在前、同季集号大的在前(照 MP `sortSeasonOptions`);没有剧集资源时 `seasonEpisode` / `season` / `episode` 为空。
|
||||
- 站点资源列表:先按 `q` / 分类 / 促销 / 识别状态从资源表取出全部候选的解析字段、促销、分类名(列表快照 → RSS → 分类 ID)、大小、做种,在内存里算分面和筛选,再只为当前页取完整行。只读本地资源表,`qf` 不会触发实时补查;响应另有 `total`(快速筛选后)与 `unfilteredTotal`。配音语言是解析器 SEARCH-4 才补的字段,解析结果里没有时按标题 + 副标题现扫(不改库)。
|
||||
- 前端:`components/search/quick-filter-bar.tsx`(桌面弹层、手机底部抽屉),状态与 `qf` 拼接在 `quick-filter-state.ts`。筛选条顺序照 MP:站点、季/集、促销、编码、质量、HDR、分辨率、制作组;其余收进「更多筛选」(桌面一个宽弹层分节、手机一个抽屉):音频、字幕、配音、特性、年份、分类、大小、做种、H&R。搜索结果页已有精确的大小区间(「大小与排序」),不显示大小分档;站点资源 Tab 已有站点分类下拉(与 MP 站点资源一致),不显示分类分面,原来只筛已加载结果的「做种 / 大小」下拉换成分面(按全部本地资源)。
|
||||
- 前端:`components/search/quick-filter-bar.tsx`(桌面弹层、手机底部抽屉),状态与 `qf` 拼接在 `quick-filter-state.ts`。筛选条顺序照 MP,季集放最前:季集、站点、促销、编码、质量、HDR、分辨率、制作组;其余收进「更多筛选」(桌面一个宽弹层分节、手机一个抽屉):音频、字幕、配音、特性、年份、分类、大小、做种、H&R。搜索结果页已有精确的大小区间(「大小与排序」),不显示大小分档;站点资源 Tab 已有站点分类下拉(与 MP 站点资源一致),不显示分类分面,原来只筛已加载结果的「做种 / 大小」下拉换成分面(按全部本地资源)。
|
||||
- 本机记住上次选择(localStorage `umt.quickFilters.search` / `umt.quickFilters.site`),只记与结果无关的固定档位:分辨率、编码、质量、促销、HDR、H&R、音频、字幕、配音、特性、大小、做种;站点、制作组、季集、年份、分类随结果变化,不记。「清除」清全部,弹层 / 抽屉里的「清除」只清本节。
|
||||
- 弹层材质:全站悬浮层(`pg-float`:Popover、Select、下拉菜单、对话框、抽屉)从 SEARCH-4 起是不透明实底(`--mat-float-bg` = `--popover`),不做背景模糊,只改 token,组件侧不要再加半透明底。
|
||||
|
||||
@@ -76,7 +88,7 @@ MP 搜索结果页筛选条(`src/composables/useTorrentFilter.ts` + `components/
|
||||
| MP 维度(取值来源) | 本工具 | 解析字段来源 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 站点 `site`(site_name) | 已有 `site` | 站点 | 单站资源列表不显示 |
|
||||
| 季 `season`(season_episode,如 `S01 E03`) | 已有 `season` + `episode` | `season` / `seasonEnd` / `episodes` | MP 季集是一个字符串;这里季、集分两节,多季合集展开到每季,整季单列 |
|
||||
| 季 `season`(season_episode,如 `S01 E03`) | `seasonEpisode`(旧的 `season` + `episode` 保留) | `season` / `seasonEnd` / `episodes` | 与 MP 同一写法、同一排序(整季在前);放在筛选条最前 |
|
||||
| 促销 `freeState`(普通 / 免费 / 2X / 2X免费 / 50% / 2X 50% / 30%…) | 已有 `promotion` | 站点促销 + 到期 | 归到 免费 / 2x / 50% / 其他 / 无,2X免费同时算免费和 2x;过期算无 |
|
||||
| 视频编码 `videoCode`(video_encode) | 已有 `videoCodec` | `videoCodec` | H.265 / H.264 / AV1 / 其他 |
|
||||
| 质量 `edition`(resource_type + resource_effect,如 `BluRay DoVi`) | 已有 `releaseType`(质量)+ `hdr` | `source`、`hdr` | MP 把来源和特效拼成一个字符串;这里拆成「质量」和「HDR」两个维度,组合更灵活 |
|
||||
|
||||
@@ -3,14 +3,14 @@ import { NonNegInt } from "./common.ts";
|
||||
|
||||
// 资源列表快速筛选(SEARCH-3):GET /search/releases 与 GET /sites/{id}/releases 共用。
|
||||
// 口径实现在 @umt/domain release-facets:同一维度多选为 OR,不同维度为 AND;
|
||||
// 计数按其它维度的已选条件统计(0 = 在其它条件下没有结果)。
|
||||
// 计数按其它维度的已选条件统计;选项只列计数不为 0 的值(已选的值总会列出)。
|
||||
|
||||
export const FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt });
|
||||
export type FacetCount = z.infer<typeof FacetCount>;
|
||||
|
||||
/**
|
||||
* 快速筛选参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent。
|
||||
* 维度:resolution(2160p/1080p/720p/other)、videoCodec(H.265/H.264/AV1/other)、
|
||||
* 维度:seasonEpisode(季集,MP 写法 `S01` / `S01-S02` / `S02 E14` / `S01 E25-E48`)、resolution(2160p/1080p/720p/other)、videoCodec(H.265/H.264/AV1/other)、
|
||||
* releaseType(disc/remux/encode/webdl/hdtv/other)、site(站点 ID)、promotion(free/2x/50%/other/none)、
|
||||
* hdr(DV/HDR10/HDR10+/other/SDR)、hr(yes/no/unknown)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季);
|
||||
* SEARCH-4 起:audioCodec(Atmos/TrueHD/DTS:X/DTS-HD/DTS/DDP/DD/AAC/FLAC/LPCM/other/unknown)、
|
||||
@@ -20,6 +20,8 @@ export type FacetCount = z.infer<typeof FacetCount>;
|
||||
export const QuickFilterParam = z.string().max(4000).optional();
|
||||
|
||||
export const ReleaseQuickFacets = z.object({
|
||||
/** 季集:整季在前、单集在后,季号新的在前(对照 MP);没有剧集资源时为空 */
|
||||
seasonEpisode: z.array(FacetCount),
|
||||
resolution: z.array(FacetCount),
|
||||
videoCodec: z.array(FacetCount),
|
||||
releaseType: z.array(FacetCount),
|
||||
|
||||
@@ -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,在其它筛选条件之后、按其它维度的已选条件计数)
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
//
|
||||
// 组合逻辑:同一维度多选为 OR,不同维度之间为 AND。
|
||||
// 计数按"其它维度的已选条件"算(disjunctive facet):某个选项的计数 = 加上它之后本维度仍是 OR 时能看到的条数,
|
||||
// 所以已选维度里的其它选项仍有计数,0 计数表示在其它条件下没有结果。
|
||||
// 所以已选维度里的其它选项仍有计数。
|
||||
// 选项只列结果里实际出现、且在其它条件下计数不为 0 的值(对照 MP useTorrentFilter.initOptions);已选的值总会列出。
|
||||
|
||||
export const FACET_DIMS = [
|
||||
// 季集合并成一个维度(对照 MP season_episode):`S01`、`S01-S02`、`S02 E14`、`S01 E25-E48`
|
||||
"seasonEpisode",
|
||||
"resolution",
|
||||
"videoCodec",
|
||||
"releaseType",
|
||||
@@ -75,19 +78,19 @@ export interface FacetInput {
|
||||
seeders?: number | null;
|
||||
}
|
||||
|
||||
/** 固定档位(顺序即展示顺序);"other" 类档位只在有资源时列出 */
|
||||
const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; hideEmpty?: boolean }[]>> = {
|
||||
/** 固定档位(顺序即展示顺序与选项名);只列出有资源的档位 */
|
||||
const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string }[]>> = {
|
||||
resolution: [
|
||||
{ value: "2160p", label: "2160p" },
|
||||
{ value: "1080p", label: "1080p" },
|
||||
{ value: "720p", label: "720p" },
|
||||
{ value: "other", label: "其他", hideEmpty: true },
|
||||
{ value: "other", label: "其他" },
|
||||
],
|
||||
videoCodec: [
|
||||
{ value: "H.265", label: "H.265" },
|
||||
{ value: "H.264", label: "H.264" },
|
||||
{ value: "AV1", label: "AV1" },
|
||||
{ value: "other", label: "其他", hideEmpty: true },
|
||||
{ value: "other", label: "其他" },
|
||||
],
|
||||
releaseType: [
|
||||
{ value: "disc", label: "原盘" },
|
||||
@@ -95,60 +98,60 @@ const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; h
|
||||
{ value: "encode", label: "蓝光压制" },
|
||||
{ value: "webdl", label: "WEB-DL" },
|
||||
{ value: "hdtv", label: "HDTV" },
|
||||
{ value: "other", label: "其他", hideEmpty: true },
|
||||
{ value: "other", label: "其他" },
|
||||
],
|
||||
promotion: [
|
||||
{ value: "free", label: "免费" },
|
||||
{ value: "2x", label: "2x" },
|
||||
{ value: "50%", label: "50%" },
|
||||
{ value: "other", label: "其他", hideEmpty: true },
|
||||
{ value: "other", label: "其他" },
|
||||
{ value: "none", label: "无" },
|
||||
],
|
||||
hdr: [
|
||||
{ value: "DV", label: "DV" },
|
||||
{ value: "HDR10", label: "HDR10" },
|
||||
{ value: "HDR10+", label: "HDR10+" },
|
||||
{ value: "other", label: "其他 HDR", hideEmpty: true },
|
||||
{ value: "other", label: "其他 HDR" },
|
||||
{ value: "SDR", label: "SDR" },
|
||||
],
|
||||
hr: [
|
||||
{ value: "yes", label: "有" },
|
||||
{ value: "no", label: "无" },
|
||||
{ value: "unknown", label: "未知", hideEmpty: true },
|
||||
{ value: "unknown", label: "未知" },
|
||||
],
|
||||
audioCodec: [
|
||||
{ value: "Atmos", label: "Atmos" },
|
||||
{ value: "TrueHD", label: "TrueHD" },
|
||||
{ value: "DTS:X", label: "DTS:X", hideEmpty: true },
|
||||
{ value: "DTS:X", label: "DTS:X" },
|
||||
{ value: "DTS-HD", label: "DTS-HD MA" },
|
||||
{ value: "DTS", label: "DTS" },
|
||||
{ value: "DDP", label: "DD+" },
|
||||
{ value: "DD", label: "DD" },
|
||||
{ value: "AAC", label: "AAC" },
|
||||
{ value: "FLAC", label: "FLAC", hideEmpty: true },
|
||||
{ value: "LPCM", label: "LPCM", hideEmpty: true },
|
||||
{ value: "other", label: "其他", hideEmpty: true },
|
||||
{ value: "unknown", label: "未标注", hideEmpty: true },
|
||||
{ value: "FLAC", label: "FLAC" },
|
||||
{ value: "LPCM", label: "LPCM" },
|
||||
{ value: "other", label: "其他" },
|
||||
{ value: "unknown", label: "未标注" },
|
||||
],
|
||||
subtitle: [
|
||||
{ value: "zh", label: "中文字幕" },
|
||||
{ value: "zh-Hans", label: "简体" },
|
||||
{ value: "zh-Hant", label: "繁体" },
|
||||
{ value: "en", label: "英文" },
|
||||
{ value: "ja", label: "日文", hideEmpty: true },
|
||||
{ value: "ko", label: "韩文", hideEmpty: true },
|
||||
{ value: "unknown", label: "未标注", hideEmpty: true },
|
||||
{ value: "ja", label: "日文" },
|
||||
{ value: "ko", label: "韩文" },
|
||||
{ value: "unknown", label: "未标注" },
|
||||
],
|
||||
dub: [
|
||||
{ value: "cmn", label: "国语" },
|
||||
{ value: "yue", label: "粤语" },
|
||||
{ value: "en", label: "英语", hideEmpty: true },
|
||||
{ value: "unknown", label: "未标注", hideEmpty: true },
|
||||
{ value: "en", label: "英语" },
|
||||
{ value: "unknown", label: "未标注" },
|
||||
],
|
||||
feature: [
|
||||
{ value: "10bit", label: "10bit", hideEmpty: true },
|
||||
{ value: "hfr", label: "50 帧以上", hideEmpty: true },
|
||||
{ value: "cut", label: "特别剪辑版", hideEmpty: true },
|
||||
{ value: "10bit", label: "10bit" },
|
||||
{ value: "hfr", label: "50 帧以上" },
|
||||
{ value: "cut", label: "特别剪辑版" },
|
||||
],
|
||||
size: [
|
||||
{ value: "lt1", label: "1 GB 以下" },
|
||||
@@ -157,14 +160,14 @@ const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; h
|
||||
{ value: "10-20", label: "10–20 GB" },
|
||||
{ value: "20-50", label: "20–50 GB" },
|
||||
{ value: "50+", label: "50 GB 以上" },
|
||||
{ value: "unknown", label: "未知", hideEmpty: true },
|
||||
{ value: "unknown", label: "未知" },
|
||||
],
|
||||
seeders: [
|
||||
{ value: "0", label: "无人做种" },
|
||||
{ value: "1-9", label: "1–9" },
|
||||
{ value: "10-49", label: "10–49" },
|
||||
{ value: "50+", label: "50 以上" },
|
||||
{ value: "unknown", label: "未知", hideEmpty: true },
|
||||
{ value: "unknown", label: "未知" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -305,6 +308,37 @@ function seasonValues(r: FacetInput): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** 季集(MP 的 season_episode 写法):`S01`、`S01-S03`、`S01 E05`、`S01 E01-E12`;没有季集信息为空 */
|
||||
export function seasonEpisodeValue(r: Pick<FacetInput, "season" | "seasonEnd" | "episodes">): string | null {
|
||||
if (r.season === null || r.season === undefined) return null;
|
||||
const s = r.seasonEnd && r.seasonEnd > r.season ? `S${pad2(r.season)}-S${pad2(r.seasonEnd)}` : `S${pad2(r.season)}`;
|
||||
if (!r.episodes) return s;
|
||||
const { start, end } = r.episodes;
|
||||
return end > start ? `${s} E${pad2(start)}-E${pad2(end)}` : `${s} E${pad2(start)}`;
|
||||
}
|
||||
|
||||
const SEASON_EPISODE = /^S(\d+)(?:-S(\d+))?(?:\s+E(\d+)(?:-E(\d+))?)?$/;
|
||||
|
||||
/**
|
||||
* 季集选项排序(照 MP sortSeasonOptions):整季在前、单集在后;整季按季号新→旧,
|
||||
* 单集按季号新→旧、再按最大集号大→小、起始集号大→小;认不出的写法排在最后。
|
||||
*/
|
||||
export function compareSeasonEpisode(a: string, b: string): number {
|
||||
const key = (v: string) => {
|
||||
const m = SEASON_EPISODE.exec(v);
|
||||
if (!m) return { ok: false, whole: false, season: 0, ep: 0, maxEp: 0 };
|
||||
const ep = m[3] ? Number(m[3]) : 0;
|
||||
return { ok: true, whole: !m[3], season: Number(m[1]), ep, maxEp: m[4] ? Number(m[4]) : ep };
|
||||
};
|
||||
const x = key(a);
|
||||
const y = key(b);
|
||||
if (x.ok !== y.ok) return x.ok ? -1 : 1;
|
||||
if (x.whole !== y.whole) return x.whole ? -1 : 1;
|
||||
return y.season - x.season || y.maxEp - x.maxEp || y.ep - x.ep;
|
||||
}
|
||||
|
||||
function episodeValues(r: FacetInput): string[] {
|
||||
if (r.episodes) {
|
||||
const { start, end } = r.episodes;
|
||||
@@ -318,7 +352,9 @@ function episodeValues(r: FacetInput): string[] {
|
||||
|
||||
/** 一条资源在各维度上的档位(制作组按原名,匹配时不分大小写) */
|
||||
export function facetValues(r: FacetInput, now: number): Record<FacetDim, string[]> {
|
||||
const se = seasonEpisodeValue(r);
|
||||
return {
|
||||
seasonEpisode: se ? [se] : [],
|
||||
resolution: [resolutionBucket(r.resolution)],
|
||||
videoCodec: [codecBucket(r.videoCodec)],
|
||||
releaseType: [releaseTypeBucket(r.source)],
|
||||
@@ -367,7 +403,7 @@ export interface FacetOutcome<T> {
|
||||
|
||||
/**
|
||||
* 按已选条件筛选并统计各维度计数。
|
||||
* 选项来自全部输入条目(固定档位总会列出,"其他"类只在有资源时列出);已选但当前没有资源的值也会列出(计数 0)。
|
||||
* 选项只列在其它维度已选条件下计数不为 0 的值(固定档位按固定顺序);已选的值总会列出(当前没有资源时计数 0)。
|
||||
*/
|
||||
export function applyFacets<T>(
|
||||
items: readonly T[],
|
||||
@@ -400,10 +436,12 @@ export function applyFacets<T>(
|
||||
let options: FacetOption[];
|
||||
if (fixed) {
|
||||
options = fixed
|
||||
.filter((f) => !f.hideEmpty || universe.has(f.value) || chosen.has(f.value))
|
||||
.filter((f) => (counts.get(f.value) ?? 0) > 0 || chosen.has(f.value))
|
||||
.map((f) => ({ value: f.value, label: f.label, count: counts.get(f.value) ?? 0 }));
|
||||
} else {
|
||||
options = [...universe.entries()].map(([value, u]) => ({
|
||||
options = [...universe.entries()]
|
||||
.filter(([value]) => (counts.get(value) ?? 0) > 0 || chosen.has(value))
|
||||
.map(([value, u]) => ({
|
||||
value: dim === "releaseGroup" ? u.label : value,
|
||||
label: u.label,
|
||||
count: counts.get(value) ?? 0,
|
||||
@@ -435,6 +473,7 @@ function sortDynamic(
|
||||
label: o.label,
|
||||
count: o.count,
|
||||
});
|
||||
if (dim === "seasonEpisode") return options.sort((a, b) => compareSeasonEpisode(a.value, b.value)).map(strip);
|
||||
if (dim === "season" || dim === "episode") {
|
||||
const n = (v: string) => (v === "pack" ? -1 : Number(v));
|
||||
return options.sort((a, b) => n(a.value) - n(b.value)).map(strip);
|
||||
|
||||
@@ -45,6 +45,8 @@ type Draft = ParsedRelease & {
|
||||
_fin?: boolean;
|
||||
_disc?: boolean;
|
||||
_remux?: boolean;
|
||||
/** 标题里有 BDRip / BRRip(压制) */
|
||||
_rip?: boolean;
|
||||
/** `{[k=v;…]}` 显式身份块里的季/集/类型,finalize 时覆盖推断值 */
|
||||
_explicit?: { season?: number; seasonEnd?: number; episodes?: EpisodeRange; tv?: boolean };
|
||||
};
|
||||
@@ -746,6 +748,7 @@ function walkTokens(tokens: string[], d: Draft): void {
|
||||
const cnTitle = cn.join(" ").trim();
|
||||
if (enTitle) d.titleEn = enTitle;
|
||||
if (cnTitle) d.titleCn = cnTitle;
|
||||
if (ripWord) d._rip = true;
|
||||
if (d._remux) d.source = "REMUX";
|
||||
else if (d._disc) d.source = "BluRay-Disc";
|
||||
else if (blurayWord && discCodec && !encodeCodec && !ripWord && !d.encoder && sources.size === 1) {
|
||||
@@ -1077,9 +1080,40 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
|
||||
}
|
||||
}
|
||||
|
||||
// 14. 原盘线索:标题的编码字样不足以判定时,参考副标题 / 简介里的明确说法与无损 PCM 音轨
|
||||
refineDisc(d, s, subtitle);
|
||||
|
||||
return finalize(d, opts);
|
||||
}
|
||||
|
||||
/** 明确说是原盘:原盘(不含「原盘压制」)、BDMV、自购自抓 / 自抓自扫、Blu-ray Disc、DIY(原盘 DIY) */
|
||||
const DISC_CUE =
|
||||
/原[盘盤](?![\s::]*(?:压|壓|rip|转|轉))|BDMV|自[购購]自抓|自抓自[扫掃]|Blu-?ray[\s.]+Disc|(?<![A-Za-z])DIY(?![A-Za-z])/i;
|
||||
/** 明确说是压制或其他来源 */
|
||||
const ENCODE_CUE =
|
||||
/自[压壓]|[压壓]制|重[编編]码|(?<![A-Za-z])(?:[xX]\.?26[45]|BDRip|BRRip|WEB-?DL|WEBRip|HDTV|Remux)(?![A-Za-z])/i;
|
||||
|
||||
/**
|
||||
* 原盘补判(对照 MP 内置规则 BLU 之外的站点惯例):标题写成 `Bluray H.264` 这类压制字样、但副标题写「自购自抓自扫」,
|
||||
* 或带 LPCM / PCM 音轨又没有 x264 / x265 / BDRip / 压制说法的,按原盘。只在来源是 BluRay(或标题没写来源但有分辨率)时补判,
|
||||
* WEB / HDTV / Remux 不动。
|
||||
*/
|
||||
function refineDisc(d: Draft, title: string, subtitle: string | undefined): void {
|
||||
// 单个视频文件(mkv / mp4 …)不会是原盘
|
||||
if (d.isFile || d._rip || d.encoder || d._remux || d.source === "BluRay-Disc") return;
|
||||
if (d.source !== "BluRay" && !(d.source === undefined && d.resolution)) return;
|
||||
const text = subtitle ? `${title} | ${subtitle}` : title;
|
||||
if (ENCODE_CUE.test(text)) return;
|
||||
const pcm = d.source === "BluRay" && d.audio.some((a) => /^L?PCM\b/i.test(a));
|
||||
if (DISC_CUE.test(text)) {
|
||||
d.source = "BluRay-Disc";
|
||||
d.evidence.push("source:disc_hint");
|
||||
} else if (pcm) {
|
||||
d.source = "BluRay-Disc";
|
||||
d.evidence.push("source:disc_pcm");
|
||||
}
|
||||
}
|
||||
|
||||
function applyCnMarkers(d: Draft, cm: CnMarkers, prefix: string): void {
|
||||
const ev = d.evidence;
|
||||
const note = (k: string) => {
|
||||
@@ -1171,7 +1205,7 @@ function finalize(d: Draft, opts: ParseOptions): ParsedRelease {
|
||||
d.title = d.titleCn ?? d.titleEn;
|
||||
d.confidence = confidenceOf(d);
|
||||
|
||||
const { _complete, _fin, _disc, _remux, _explicit, ...out } = d;
|
||||
const { _complete, _fin, _disc, _remux, _rip, _explicit, ...out } = d;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyFacets,
|
||||
audioBuckets,
|
||||
compareSeasonEpisode,
|
||||
type FacetInput,
|
||||
formatFacetParam,
|
||||
parseFacetParam,
|
||||
parseRelease,
|
||||
promotionBuckets,
|
||||
releaseTypeBucket,
|
||||
seasonEpisodeValue,
|
||||
seedersBucket,
|
||||
sizeBucket,
|
||||
subtitleBuckets,
|
||||
@@ -76,8 +78,9 @@ describe("release-facets", () => {
|
||||
expect(counts(r.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 1, other: 1 });
|
||||
expect(counts(r.facets.releaseType)).toEqual({ disc: 1, remux: 1, encode: 1, webdl: 1, hdtv: 1 });
|
||||
expect(r.facets.releaseType.map((o) => o.label)).toEqual(["原盘", "Remux", "蓝光压制", "WEB-DL", "HDTV"]);
|
||||
expect(counts(r.facets.hdr)).toEqual({ DV: 1, HDR10: 2, "HDR10+": 0, SDR: 3 });
|
||||
expect(counts(r.facets.promotion)).toEqual({ free: 2, "2x": 1, "50%": 0, none: 3 });
|
||||
// 结果里没有的档位不列出(HDR10+、50%)
|
||||
expect(counts(r.facets.hdr)).toEqual({ DV: 1, HDR10: 2, SDR: 3 });
|
||||
expect(counts(r.facets.promotion)).toEqual({ free: 2, "2x": 1, none: 3 });
|
||||
expect(r.facets.site).toEqual([
|
||||
{ value: "a", label: "站 A", count: 3 },
|
||||
{ value: "b", label: "站 B", count: 2 },
|
||||
@@ -88,24 +91,25 @@ describe("release-facets", () => {
|
||||
{ value: "FraMeSToR", label: "FraMeSToR", count: 1 },
|
||||
]);
|
||||
expect(r.facets.season).toEqual([]);
|
||||
expect(r.facets.seasonEpisode).toEqual([]);
|
||||
});
|
||||
|
||||
it("同维度 OR、不同维度 AND;计数按其它维度的已选条件", () => {
|
||||
const r = run({ resolution: ["2160p", "1080p"], videoCodec: ["H.265"] });
|
||||
expect(ids(r)).toEqual(["1", "2"]);
|
||||
// 分辨率的计数只受编码条件影响:1080p 在 H.265 下没有结果 → 0
|
||||
expect(counts(r.facets.resolution)).toEqual({ "2160p": 2, "1080p": 0, "720p": 0, other: 0 });
|
||||
// 编码计数只受分辨率条件影响
|
||||
expect(counts(r.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0, other: 0 });
|
||||
// 其它维度按全部条件
|
||||
expect(counts(r.facets.releaseType)).toMatchObject({ remux: 1, webdl: 1, encode: 0 });
|
||||
// 分辨率的计数只受编码条件影响:1080p 在 H.265 下没有结果,但已选,仍列出(计数 0);720p / 其他不列
|
||||
expect(counts(r.facets.resolution)).toEqual({ "2160p": 2, "1080p": 0 });
|
||||
// 编码计数只受分辨率条件影响:在 2160p / 1080p 下没有 AV1 / 其他,不列
|
||||
expect(counts(r.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1 });
|
||||
// 其它维度按全部条件:计数为 0 的档位不列
|
||||
expect(counts(r.facets.releaseType)).toEqual({ remux: 1, webdl: 1 });
|
||||
expect(ids(run({ site: ["b"], releaseType: ["disc", "webdl"] }))).toEqual(["2", "5"]);
|
||||
expect(ids(run({ releaseGroup: ["flux"] }))).toEqual(["2", "3"]);
|
||||
expect(ids(run({ hdr: ["SDR"] }))).toEqual(["3", "4", "5"]);
|
||||
expect(ids(run({ promotion: ["2x"] }))).toEqual(["4"]);
|
||||
});
|
||||
|
||||
it("H&R 三态分面:缺省按未知;未知档只在有资源时列出", () => {
|
||||
it("H&R 三态分面:缺省按未知;只列有资源的档", () => {
|
||||
const hrItems = [mk("y", { hr: true }), mk("n", { hr: false }), mk("n2", { hr: false }), mk("u")];
|
||||
const r = applyFacets(hrItems, (x) => x, {}, { now: NOW });
|
||||
expect(r.facets.hr).toEqual([
|
||||
@@ -115,7 +119,7 @@ describe("release-facets", () => {
|
||||
]);
|
||||
expect(ids(applyFacets(hrItems, (x) => x, { hr: ["no", "unknown"] }, { now: NOW }))).toEqual(["n", "n2", "u"]);
|
||||
const known = applyFacets([mk("y", { hr: true })], (x) => x, {}, { now: NOW });
|
||||
expect(known.facets.hr.map((o) => o.value)).toEqual(["yes", "no"]);
|
||||
expect(known.facets.hr.map((o) => o.value)).toEqual(["yes"]);
|
||||
});
|
||||
|
||||
it("已选但没有结果的值仍列出(计数 0)", () => {
|
||||
@@ -150,14 +154,56 @@ describe("release-facets", () => {
|
||||
{ value: "2", label: "S02", count: 1 },
|
||||
{ value: "3", label: "S03", count: 1 },
|
||||
]);
|
||||
expect(r.facets.episode).toEqual([
|
||||
{ value: "pack", label: "整季", count: 1 },
|
||||
{ value: "2", label: "E02", count: 0 },
|
||||
{ value: "3", label: "E03", count: 0 },
|
||||
]);
|
||||
// 在 S02 下单集没有结果,不列
|
||||
expect(r.facets.episode).toEqual([{ value: "pack", label: "整季", count: 1 }]);
|
||||
expect(ids(applyFacets(tv, (x) => x, { season: ["1"], episode: ["3"] }, { now: NOW }))).toEqual(["s1e2"]);
|
||||
});
|
||||
|
||||
it("季集(MP season_episode):一个维度,整季在前、单集在后,季号新的在前", () => {
|
||||
const tv = [
|
||||
mk("s1", { season: 1, episodes: null }),
|
||||
mk("s1b", { season: 1, episodes: null }),
|
||||
mk("s2", { season: 2, episodes: null }),
|
||||
mk("s1-3", { season: 1, seasonEnd: 3, episodes: null }),
|
||||
mk("s1e25", { season: 1, episodes: { start: 25, end: 48 } }),
|
||||
mk("s2e14", { season: 2, episodes: { start: 14, end: 14 } }),
|
||||
mk("s2e1", { season: 2, episodes: { start: 1, end: 1 } }),
|
||||
mk("s2e14-15", { season: 2, episodes: { start: 14, end: 15 } }),
|
||||
mk("movie"),
|
||||
];
|
||||
const r = applyFacets(tv, (x) => x, {}, { now: NOW });
|
||||
expect(r.facets.seasonEpisode.map((o) => [o.value, o.count])).toEqual([
|
||||
["S02", 1],
|
||||
["S01", 2],
|
||||
["S01-S03", 1],
|
||||
["S02 E14-E15", 1],
|
||||
["S02 E14", 1],
|
||||
["S02 E01", 1],
|
||||
["S01 E25-E48", 1],
|
||||
]);
|
||||
expect(r.facets.seasonEpisode[0]?.label).toBe("S02");
|
||||
expect(ids(applyFacets(tv, (x) => x, { seasonEpisode: ["S01", "S02 E14"] }, { now: NOW }))).toEqual([
|
||||
"s1",
|
||||
"s1b",
|
||||
"s2e14",
|
||||
]);
|
||||
// 计数按其它维度已选条件;在其它条件下为 0 的不列
|
||||
const free = applyFacets(
|
||||
tv.map((t) => (t.id === "s2" ? { ...t, promotion: { ...none, type: "free", downloadFactor: 0 } } : t)),
|
||||
(x) => x,
|
||||
{ promotion: ["free"] },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(free.facets.seasonEpisode).toEqual([{ value: "S02", label: "S02", count: 1 }]);
|
||||
expect(seasonEpisodeValue({ season: null, episodes: { start: 1, end: 2 } })).toBeNull();
|
||||
expect(["S01 E02", "bogus", "S10", "S02 E10-E12"].sort(compareSeasonEpisode)).toEqual([
|
||||
"S10",
|
||||
"S02 E10-E12",
|
||||
"S01 E02",
|
||||
"bogus",
|
||||
]);
|
||||
});
|
||||
|
||||
it("促销与版本类型档位", () => {
|
||||
expect(promotionBuckets({ type: "2x50%", downloadFactor: 0.5, uploadFactor: 2, expiresAt: null }, NOW)).toEqual([
|
||||
"2x",
|
||||
@@ -227,10 +273,7 @@ describe("release-facets", () => {
|
||||
Atmos: 1,
|
||||
TrueHD: 1,
|
||||
"DTS-HD": 1,
|
||||
DTS: 0,
|
||||
DDP: 1,
|
||||
DD: 0,
|
||||
AAC: 0,
|
||||
unknown: 1,
|
||||
});
|
||||
expect(r.facets.audioCodec.find((o) => o.value === "DDP")?.label).toBe("DD+");
|
||||
@@ -246,16 +289,8 @@ describe("release-facets", () => {
|
||||
["电影", 2],
|
||||
["剧集", 1],
|
||||
]);
|
||||
expect(counts(r.facets.size)).toEqual({
|
||||
lt1: 0,
|
||||
"1-5": 1,
|
||||
"5-10": 1,
|
||||
"10-20": 0,
|
||||
"20-50": 0,
|
||||
"50+": 1,
|
||||
unknown: 1,
|
||||
});
|
||||
expect(counts(r.facets.seeders)).toEqual({ "0": 1, "1-9": 0, "10-49": 1, "50+": 1, unknown: 1 });
|
||||
expect(counts(r.facets.size)).toEqual({ "1-5": 1, "5-10": 1, "50+": 1, unknown: 1 });
|
||||
expect(counts(r.facets.seeders)).toEqual({ "0": 1, "10-49": 1, "50+": 1, unknown: 1 });
|
||||
});
|
||||
|
||||
it("新维度组合:同维度 OR、跨维度 AND,计数按其它维度已选条件", () => {
|
||||
|
||||
@@ -627,6 +627,113 @@ describe("MP 差分补迁写法", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 原盘补判(SEARCH 准确度):站点搜索「药屋少女的呢喃」的真实结果。标题写成压制字样、但副标题说「自购自抓自扫」的是原盘;
|
||||
// LPCM / PCM 音轨且没有 x264 / x265 / BDRip / 压制说法的也是原盘。普通压制、WEB-DL、Remux 不受影响。
|
||||
const DISC_HINTS: Sample[] = [
|
||||
{
|
||||
id: "DISC-sub-self-rip",
|
||||
input: "Kusuriya no Hitorigoto 2025 S02 1080p Bluray H.264 LPCM-@U2",
|
||||
subtitle: "自购自抓自扫 | Team HAYAKU | Animate特典附 | TBR35041D 药屋少女的呢喃 第二季",
|
||||
expect: { source: "BluRay-Disc", isEncode: false },
|
||||
},
|
||||
{
|
||||
id: "DISC-sub-bdmv",
|
||||
input: "The Apothecary Diaries 2025 S02 1080p Blu-ray H.264-GRP",
|
||||
subtitle: "药屋少女的呢喃 第二季 BDMV Vol.1",
|
||||
expect: { source: "BluRay-Disc" },
|
||||
},
|
||||
{
|
||||
id: "DISC-sub-yuanpan",
|
||||
input: "Some Show S01 1080p BluRay H.264 DTS-HD MA 5.1-GRP",
|
||||
subtitle: "某剧 第一季 原盘 中字",
|
||||
expect: { source: "BluRay-Disc" },
|
||||
},
|
||||
{
|
||||
id: "DISC-pcm",
|
||||
input: "Some Movie 2020 1080p BluRay LPCM 2.0-GRP",
|
||||
expect: { source: "BluRay-Disc", isEncode: false },
|
||||
},
|
||||
// 已按编码字样判成原盘的不变
|
||||
{
|
||||
id: "DISC-avc-lpcm",
|
||||
input: "Kusuriya no Hitorigoto S02 2025 Blu-ray 1080p AVC LPCM 2Audio-@U2",
|
||||
subtitle: "Team HAYAKU | Animate全卷特典附 药屋少女的呢喃 第二季",
|
||||
expect: { source: "BluRay-Disc" },
|
||||
},
|
||||
{
|
||||
id: "DISC-bdmv-rip-welcome",
|
||||
input: "The Apothecary Diaries Vol.3 2025 BDMV 1080p AVC LPCM 2.0-Arri-RAWS@U2",
|
||||
subtitle: "U2 Exclusive, RIPs welcomed with credits | 制作RIP请注明出处 | 药屋少女的呢喃/药师少女的独语 S2",
|
||||
expect: { source: "BluRay-Disc" },
|
||||
},
|
||||
// 反例:压制
|
||||
{
|
||||
id: "ENC-self-encode",
|
||||
input: "Kusuriya no Hitorigoto 2023 BDRip 1080p AVC FLAC@U2",
|
||||
subtitle: "药师少女的呢喃/Kusuriya no Hitorigoto/薬屋のひとりごと 1080p 自压(附相关专辑) TV 01-24 Fin+SP",
|
||||
expect: { source: "BluRay", isEncode: true },
|
||||
},
|
||||
{
|
||||
id: "ENC-x265",
|
||||
input: "The Apothecary Diaries S01 2023 1080p BluRay x265 10bit FLAC 2.0-ADE",
|
||||
subtitle: "药屋少女的呢喃 / 药师少女的独语 / The Apothecary Diaries / 薬屋のひとりごと 【简日|繁日|简|繁|日字幕】",
|
||||
expect: { source: "BluRay", isEncode: true },
|
||||
},
|
||||
{
|
||||
id: "ENC-x264",
|
||||
input: "Kusuriya no Hitorigoto S01 Complete 2023 1080p Blu-ray x264 FLAC-jsum@U2",
|
||||
subtitle: "药屋少女的呢喃 / 药师少女的独语 / 薬屋のひとりごと",
|
||||
expect: { source: "BluRay", isEncode: true },
|
||||
},
|
||||
{
|
||||
id: "ENC-av1",
|
||||
input: "Kusuriya no Hitorigoto S02 1080p BluRay AV1 OPUS 2.0-7³ACG@OurBits",
|
||||
subtitle: "药师少女的独语第2季/薬屋のひとりごと 第2期 | 01-24(25-48) 日语+简繁字幕",
|
||||
expect: { source: "BluRay", isEncode: true },
|
||||
},
|
||||
{ id: "ENC-x264-lpcm", input: "Some Movie 2020 1080p BluRay x264 LPCM 2.0-GRP", expect: { source: "BluRay" } },
|
||||
{
|
||||
id: "ENC-from-disc",
|
||||
input: "Some Movie 2020 1080p BluRay H.264 FLAC-GRP",
|
||||
subtitle: "基于原盘压制 简繁字幕",
|
||||
expect: { source: "BluRay" },
|
||||
},
|
||||
{
|
||||
id: "ENC-pcm-encode-note",
|
||||
input: "Some Movie 2020 1080p BluRay H.264 LPCM-GRP",
|
||||
subtitle: "自压 内封字幕",
|
||||
expect: { source: "BluRay" },
|
||||
},
|
||||
// 反例:其他来源不受副标题影响
|
||||
{
|
||||
id: "WEB-sub-yuanpan",
|
||||
input: "Some Movie 2020 1080p WEB-DL H.264 AAC-GRP",
|
||||
subtitle: "原盘 DIY 还没出,先发 WEB",
|
||||
expect: { source: "WEB-DL" },
|
||||
},
|
||||
{
|
||||
id: "REMUX-diy",
|
||||
input: "The Apothecary Diaries S01 2023 1080p BluRay Remux AVC FLAC 2.0-ADE",
|
||||
subtitle: "药屋少女的呢喃 【DIY / 双语简繁字幕】",
|
||||
expect: { source: "REMUX" },
|
||||
},
|
||||
// 标题里写着 2160P 的非发布名(播放器解码信息):按字面解析,不猜
|
||||
{
|
||||
id: "WEB-2160-literal",
|
||||
input:
|
||||
"The Apothecary Diaries 2023 S01E24 2160P TV WEB-DL MPEG-4 AVC1 - Native D3D9 DXVA Decoder VLD AAC LC -Kisssub",
|
||||
subtitle:
|
||||
"药屋少女的呢喃 薬屋のひとりごと (2023) 2160P TVS01 [01-24](AI增强1080P转2160P) AVC1 @5160kbps AAC @128kbps",
|
||||
expect: { source: "WEB-DL", resolution: "2160p" },
|
||||
},
|
||||
];
|
||||
|
||||
describe("原盘补判(副标题线索 / PCM 音轨)", () => {
|
||||
for (const s of DISC_HINTS) {
|
||||
it(`${s.id} ${s.input}`, () => check(parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {}), s.expect));
|
||||
}
|
||||
});
|
||||
|
||||
describe("parseRelease 样本表", () => {
|
||||
it("样本数量 ≥ 60", () => {
|
||||
expect(TITLES.length).toBeGreaterThanOrEqual(60);
|
||||
|
||||
Reference in new issue
Block a user