feat(adapters): qBittorrent, filesystem, TMDB and Emby adapters with testkit mocks (W1-3a)

- qbittorrent: WebUI API v2 client (cookie/API-key auth, add/info/files/filePrio,
  pause/resume with 5.x stop/start fallback, tags, delete with explicit deleteFiles,
  maindata), error classes, idempotent submit with local infohash (own bencode)
  and post-timeout hash/probe-tag lookup; state mapping per MoviePilot
- fs: copyWithVerify (tmp + fsync + size/sha256/sample + no-clobber commit),
  moveSafe (EXDEV surfaced), resumable crossDeviceMove, statSafe/checkRoot with
  root sentinel, budgeted incremental scan with cursor, ffprobe, statfs usage
- tmdb: multi-language search/details/seasons, LRU+TTL cache with coalescing,
  rate-limit backoff, MoviePilot match chain (name normalization, year +-1)
- emby: Emby/Jellyfin items/seasons/episodes/MediaSources, failures -> unknown
- testkit: mock qB / TMDB / Emby servers, torrent builder, temp-tree fixtures
- docs: W1-3a porting table appended to downloader-and-sites ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:07:48 +08:00
1 parent 9c881e737f
commit ef2e504a94
45 files changed
+5766 -1

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# tmdb
TMDB v3 查询:多语言搜索(默认 zh-CN + en-US,按 ID 合并标题)、`searchMulti`、详情(别名 alternative_titles、译名 translations、外部 ID、季列表)、季集列表;内存 LRU + TTL 缓存(默认 1024 条/24h,空结果 30min)与并发合并,并发/间隔限流,429 按 Retry-After 退避。输出归一化候选 `MetadataCandidate`(provider `tmdb`,entityType,externalId,titles,year,aliases,seasons?)。`matchTmdb`/`matchTmdbMulti` 迁移 MoviePilot 的匹配链(名称规范化后完全相等、年份 ±1、季年份优先、别名/译名回退)。错误为 `TmdbError`(auth/network/timeout/notfound/rate/protocol),不会把查询失败当成无结果。
仅对 mock 与官方 API 文档验证,未连接真实实例。
+93
View File
@@ -0,0 +1,93 @@
/** 内存 LRU + TTL 缓存,带并发合并(同 key 在途请求只发一次)。 */
export class TtlLruCache<V> {
private readonly map = new Map<string, { value: V; expiresAt: number }>();
private readonly inflight = new Map<string, Promise<V>>();
hits = 0;
misses = 0;
coalesced = 0;
constructor(
private readonly max: number,
private readonly ttlMs: number,
private readonly now: () => number = Date.now,
) {}
get(key: string): V | undefined {
const e = this.map.get(key);
if (!e) return undefined;
if (e.expiresAt <= this.now()) {
this.map.delete(key);
return undefined;
}
this.map.delete(key);
this.map.set(key, e);
return e.value;
}
set(key: string, value: V, ttlMs = this.ttlMs): void {
this.map.delete(key);
this.map.set(key, { value, expiresAt: this.now() + ttlMs });
while (this.map.size > this.max) {
const oldest = this.map.keys().next().value;
if (oldest === undefined) break;
this.map.delete(oldest);
}
}
/** 命中直接返回;未命中时合并同 key 的在途加载,成功才写缓存。 */
async getOrLoad(key: string, load: () => Promise<V>, ttlFor?: (v: V) => number | undefined): Promise<V> {
const hit = this.get(key);
if (hit !== undefined) {
this.hits++;
return hit;
}
const pending = this.inflight.get(key);
if (pending) {
this.coalesced++;
return pending;
}
this.misses++;
const p = load()
.then((v) => {
this.set(key, v, ttlFor?.(v) ?? this.ttlMs);
return v;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, p);
return p;
}
get size(): number {
return this.map.size;
}
clear(): void {
this.map.clear();
}
}
/** 并发上限 + 请求起始最小间隔的简单限流器。 */
export class RequestLimiter {
private active = 0;
private readonly queue: (() => void)[] = [];
private nextStart = 0;
constructor(
private readonly maxConcurrent: number,
private readonly minIntervalMs: number,
) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.active >= this.maxConcurrent) await new Promise<void>((r) => this.queue.push(r));
this.active++;
try {
const wait = this.nextStart - Date.now();
this.nextStart = Math.max(Date.now(), this.nextStart) + this.minIntervalMs;
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
return await fn();
} finally {
this.active--;
this.queue.shift()?.();
}
}
}
+380
View File
@@ -0,0 +1,380 @@
/**
* TMDB v3 查询适配。
* 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8)与 tmdbv3api/tmdb.py 的请求缓存:
* search_movies/search_tvs/search_multiis → search/searchAll/searchMulti;
* __get_movie_detail/__get_tv_detail(alternative_titles、translations、external_ids)→ details;
* get_tv_season_detail → season;tmdb.py request 的 @cached(maxsize/ttl、空结果短 TTL、业务失败不缓存)→ TtlLruCache;
* match/match_multi 的匹配链见 match.ts。
* 调整:网络/鉴权/限流错误以 TmdbError 抛出而非返回 None,调用方不得把"查询失败"当成"没有结果"。
*/
import { RequestLimiter, TtlLruCache } from "./cache";
export type TmdbErrorKind = "auth" | "network" | "timeout" | "notfound" | "rate" | "protocol";
export class TmdbError extends Error {
override readonly name = "TmdbError";
constructor(
readonly kind: TmdbErrorKind,
message: string,
readonly detail: { path?: string; status?: number; retryAfterMs?: number; cause?: unknown } = {},
) {
super(message);
}
}
export type MediaEntityType = "movie" | "tv";
export interface CandidateTitle {
text: string;
/** BCP47,如 zh-CN;原名为原始语言;别名为 null */
lang: string | null;
kind: "localized" | "original" | "alias" | "translation";
/** 别名/翻译的地区(iso_3166_1) */
region?: string;
}
export interface CandidateSeason {
seasonNumber: number;
episodeCount: number;
name: string | null;
airDate: string | null;
}
/** 归一化的元数据候选(provider 无关结构,供 domain 身份匹配使用)。 */
export interface MetadataCandidate {
provider: "tmdb";
entityType: MediaEntityType;
externalId: string;
/** 首选语言标题(配置 languages[0]) */
title: string;
originalTitle: string | null;
originalLanguage: string | null;
titles: CandidateTitle[];
year: number | null;
releaseDate: string | null;
aliases: string[];
originCountry: string[];
popularity: number | null;
overview: string | null;
seasons?: CandidateSeason[];
externalIds?: { imdb?: string; tvdb?: string };
/** 是否已拉过详情(别名/季信息只在详情中完整) */
detailed: boolean;
}
export interface TmdbEpisode {
seasonNumber: number;
episodeNumber: number;
name: string | null;
airDate: string | null;
}
export interface TmdbSeasonDetail {
tvId: string;
seasonNumber: number;
name: string | null;
airDate: string | null;
episodes: TmdbEpisode[];
}
export interface TmdbConfig {
baseUrl?: string;
/** v3 API key(query api_key) */
apiKey?: string;
/** v4 read access token(Bearer) */
accessToken?: string;
/** 查询语言,默认 ["zh-CN", "en-US"];第一个为首选 */
languages?: string[];
timeoutMs?: number;
/** 429/5xx/网络错误的重试次数,默认 3 */
retries?: number;
retryBaseMs?: number;
/** 429 Retry-After 的最大等待,默认 10s */
maxRetryAfterMs?: number;
maxConcurrent?: number;
minIntervalMs?: number;
/** 默认 max 1024、ttl 24h;空结果(搜索 0 条)用 emptyTtlMs,默认 30min(对照 MP EMPTY_RESULT_CACHE_TTL) */
cache?: { max?: number; ttlMs?: number; emptyTtlMs?: number };
fetch?: typeof fetch;
now?: () => number;
}
type Json = Record<string, unknown>;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const s = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const yearOf = (d: string | null) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null);
export class TmdbClient {
private readonly base: string;
private readonly langs: string[];
private readonly cache: TtlLruCache<unknown>;
private readonly limiter: RequestLimiter;
private readonly fetchImpl: typeof fetch;
constructor(private readonly cfg: TmdbConfig) {
if (!cfg.apiKey && !cfg.accessToken) throw new TypeError("TMDB apiKey or accessToken required");
this.base = (cfg.baseUrl ?? "https://api.themoviedb.org/3").replace(/\/+$/, "");
this.langs = cfg.languages?.length ? cfg.languages : ["zh-CN", "en-US"];
this.cache = new TtlLruCache(cfg.cache?.max ?? 1024, cfg.cache?.ttlMs ?? 24 * 3600_000, cfg.now);
this.limiter = new RequestLimiter(cfg.maxConcurrent ?? 8, cfg.minIntervalMs ?? 0);
this.fetchImpl = cfg.fetch ?? fetch;
}
cacheStats() {
return { size: this.cache.size, hits: this.cache.hits, misses: this.cache.misses, coalesced: this.cache.coalesced };
}
private async get<T>(path: string, params: Record<string, string | undefined>): Promise<T> {
const qs = new URLSearchParams();
for (const k of Object.keys(params).sort()) if (params[k] !== undefined) qs.set(k, params[k]!);
const key = `${path}?${qs}`;
const emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000;
return this.cache.getOrLoad(
key,
() => this.limiter.run(() => this.fetchWithRetry(path, qs)),
(v) =>
Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0
? emptyTtl
: undefined,
) as Promise<T>;
}
private async fetchWithRetry(path: string, qs: URLSearchParams): Promise<unknown> {
const retries = this.cfg.retries ?? 3;
let last: TmdbError | null = null;
for (let i = 0; i <= retries; i++) {
try {
return await this.fetchOnce(path, qs);
} catch (e) {
if (!(e instanceof TmdbError)) throw e;
last = e;
if (!["rate", "network", "timeout", "protocol"].includes(e.kind) || i === retries) break;
const ra = e.detail.retryAfterMs;
await sleep(ra ?? (this.cfg.retryBaseMs ?? 500) * 2 ** i);
}
}
throw last!;
}
private async fetchOnce(path: string, qs: URLSearchParams): Promise<unknown> {
const q = new URLSearchParams(qs);
const headers: Record<string, string> = { accept: "application/json" };
if (this.cfg.accessToken) headers.authorization = `Bearer ${this.cfg.accessToken}`;
else q.set("api_key", this.cfg.apiKey!);
let res: Response;
try {
res = await this.fetchImpl(`${this.base}${path}?${q}`, {
headers,
signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000),
});
} catch (e) {
const name = (e as { name?: string })?.name;
if (name === "TimeoutError" || name === "AbortError")
throw new TmdbError("timeout", "TMDB request timed out", { path });
throw new TmdbError("network", `TMDB request failed: ${(e as Error)?.message}`, { path, cause: e });
}
if (res.status === 401) {
await res.body?.cancel();
throw new TmdbError("auth", "TMDB rejected credentials", { path, status: 401 });
}
if (res.status === 404) {
await res.body?.cancel();
throw new TmdbError("notfound", `TMDB resource not found: ${path}`, { path, status: 404 });
}
if (res.status === 429) {
await res.body?.cancel();
const ra = Number(res.headers.get("retry-after"));
const err = new TmdbError("rate", "TMDB rate limited", { path, status: 429 });
if (Number.isFinite(ra) && ra >= 0) {
err.detail.retryAfterMs = Math.min(ra * 1000, this.cfg.maxRetryAfterMs ?? 10_000);
}
throw err;
}
if (!res.ok) {
await res.body?.cancel();
throw new TmdbError("protocol", `TMDB HTTP ${res.status}`, { path, status: res.status });
}
try {
return await res.json();
} catch (e) {
throw new TmdbError("protocol", "TMDB returned invalid JSON", { path, cause: e });
}
}
/** 单语言原始搜索(匹配链使用,对照 MP search.movies/tv_shows,语言取首选)。 */
async searchRaw(type: MediaEntityType, query: string, year?: string | number): Promise<MetadataCandidate[]> {
const lang = this.langs[0]!;
const p = await this.get<Json>(`/search/${type}`, {
query: query.trim(),
language: lang,
include_adult: "false",
page: "1",
[type === "movie" ? "year" : "first_air_date_year"]: year !== undefined && year !== "" ? String(year) : undefined,
});
return ((Array.isArray(p.results) ? p.results : []) as Json[]).map((r) => this.fromBrief(type, r, lang));
}
/** /search/multi,只保留 movie/tv(对照 _project_multi_search_results)。 */
async searchMulti(query: string): Promise<MetadataCandidate[]> {
const lang = this.langs[0]!;
const p = await this.get<Json>("/search/multi", {
query: query.trim(),
language: lang,
include_adult: "false",
page: "1",
});
return ((Array.isArray(p.results) ? p.results : []) as Json[])
.filter((r) => r.media_type === "movie" || r.media_type === "tv")
.map((r) => this.fromBrief(r.media_type as MediaEntityType, r, lang));
}
private fromBrief(type: MediaEntityType, r: Json, lang: string): MetadataCandidate {
const title = s(type === "movie" ? r.title : r.name) ?? "";
const original = s(type === "movie" ? r.original_title : r.original_name);
const date = s(type === "movie" ? r.release_date : r.first_air_date);
const origLang = s(r.original_language);
const titles: CandidateTitle[] = [];
if (title) titles.push({ text: title, lang, kind: "localized" });
if (original) titles.push({ text: original, lang: origLang, kind: "original" });
return {
provider: "tmdb",
entityType: type,
externalId: String(r.id),
title,
originalTitle: original,
originalLanguage: origLang,
titles,
year: yearOf(date),
releaseDate: date,
aliases: [],
originCountry: Array.isArray(r.origin_country) ? (r.origin_country as string[]) : [],
popularity: n(r.popularity),
overview: s(r.overview),
detailed: false,
};
}
private static mergeTitle(c: MetadataCandidate, t: CandidateTitle) {
if (!c.titles.some((x) => x.text === t.text && x.lang === t.lang && x.kind === t.kind)) c.titles.push(t);
}
/** 按配置的多语言搜索并按 ID 合并;首选语言结果顺序优先。 */
async search(query: string, opts: { type: MediaEntityType; year?: number }): Promise<MetadataCandidate[]> {
const q = query.trim();
if (!q) return [];
const yearKey = opts.type === "movie" ? "year" : "first_air_date_year";
const pages = await Promise.all(
this.langs.map((lang) =>
this.get<Json>(`/search/${opts.type}`, {
query: q,
language: lang,
include_adult: "false",
page: "1",
[yearKey]: opts.year !== undefined ? String(opts.year) : undefined,
}).then((p) => ({ lang, results: (Array.isArray(p.results) ? p.results : []) as Json[] })),
),
);
const byId = new Map<string, MetadataCandidate>();
for (const { lang, results } of pages) {
for (const r of results) {
const c = this.fromBrief(opts.type, r, lang);
const prev = byId.get(c.externalId);
if (!prev) byId.set(c.externalId, c);
else for (const t of c.titles) TmdbClient.mergeTitle(prev, t);
}
}
return [...byId.values()];
}
/** 同时搜电影与剧集。 */
async searchAll(query: string, opts: { year?: number } = {}): Promise<MetadataCandidate[]> {
const [m, t] = await Promise.all([
this.search(query, { type: "movie", ...opts }),
this.search(query, { type: "tv", ...opts }),
]);
return [...m, ...t].sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0));
}
/** 详情:多语言标题、别名(alternative_titles)、外部 ID、季列表(剧集)。不存在抛 notfound。 */
async details(type: MediaEntityType, id: string | number): Promise<MetadataCandidate> {
const [first, ...rest] = await Promise.all(
this.langs.map((lang, i) =>
this.get<Json>(`/${type}/${id}`, {
language: lang,
append_to_response: i === 0 ? "alternative_titles,external_ids,translations" : undefined,
}).then((j) => ({ lang, j })),
),
);
const c = this.fromBrief(type, first!.j, first!.lang);
for (const { lang, j } of rest) {
const t = s(type === "movie" ? j.title : j.name);
if (t) TmdbClient.mergeTitle(c, { text: t, lang, kind: "localized" });
}
const alt = first!.j.alternative_titles as Json | undefined;
const altList = ((type === "movie" ? alt?.titles : alt?.results) ?? []) as Json[];
const aliases = new Set<string>();
for (const a of altList) {
const text = s(a.title);
if (!text) continue;
aliases.add(text);
const region = s(a.iso_3166_1);
c.titles.push({ text, lang: null, kind: "alias", ...(region ? { region } : {}) });
}
// translations:MP __get_names 同样把各语言译名计入可匹配名称
const trs = (((first!.j.translations as Json | undefined)?.translations ?? []) as Json[]).filter(Boolean);
for (const tr of trs) {
const data = tr.data as Json | undefined;
const text = s(type === "movie" ? data?.title : data?.name);
if (!text) continue;
aliases.add(text);
const l = s(tr.iso_639_1);
const region = s(tr.iso_3166_1);
TmdbClient.mergeTitle(c, {
text,
lang: l ? (region ? `${l}-${region}` : l) : null,
kind: "translation",
...(region ? { region } : {}),
});
}
c.aliases = [...aliases];
const ext = first!.j.external_ids as Json | undefined;
if (ext) {
const imdb = s(ext.imdb_id);
const tvdb = ext.tvdb_id != null ? String(ext.tvdb_id) : null;
c.externalIds = { ...(imdb ? { imdb } : {}), ...(tvdb ? { tvdb } : {}) };
}
if (type === "tv") {
c.seasons = ((first!.j.seasons ?? []) as Json[]).map((x) => ({
seasonNumber: n(x.season_number) ?? 0,
episodeCount: n(x.episode_count) ?? 0,
name: s(x.name),
airDate: s(x.air_date),
}));
}
c.detailed = true;
return c;
}
/** 季详情与集列表(首选语言)。 */
async season(tvId: string | number, seasonNumber: number): Promise<TmdbSeasonDetail> {
const j = await this.get<Json>(`/tv/${tvId}/season/${seasonNumber}`, { language: this.langs[0] });
return {
tvId: String(tvId),
seasonNumber: n(j.season_number) ?? seasonNumber,
name: s(j.name),
airDate: s(j.air_date),
episodes: ((j.episodes ?? []) as Json[]).map((e) => ({
seasonNumber: n(e.season_number) ?? seasonNumber,
episodeNumber: n(e.episode_number) ?? 0,
name: s(e.name),
airDate: s(e.air_date),
})),
};
}
}
export function createTmdbClient(cfg: TmdbConfig): TmdbClient {
return new TmdbClient(cfg);
}
+3
View File
@@ -0,0 +1,3 @@
export { RequestLimiter, TtlLruCache } from "./cache";
export * from "./client";
export * from "./match";
+139
View File
@@ -0,0 +1,139 @@
/**
* TMDB 名称匹配链(API 侧行为),对照 MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8):
* __compare_names + foundation/text.py remove_punctuation → normalizeTitleForCompare / compareNames
* _generate_year_range → yearRange(year, year+1, year-1)
* _build_match_plan / _match_resolution → matchTmdb 的步骤顺序(季年份步骤优先,再按名称+年份容差)
* __search_movie_by_name / __search_tv_by_name / _build_name_candidate_plan → 按日期降序、年份前缀过滤、
* 标题/原标题直达,否则逐个拉详情用别名+译名比较(_alias_matches)
* __search_tv_by_season / _season_candidate_matches / _season_year_matches → 季年份匹配
* match_multi / _sort_multi_results → matchTmdbMulti(电影优先、日期降序)
* 调整:MP 查询失败返回 None 后继续下一步;这里 TMDB 错误直接抛出,避免把"查不到"与"查询失败"混为一谈。
* 未迁移:剧集组(group_seasons)季年份判断。打分/身份判定在 packages/domain。
*/
import type { MediaEntityType, MetadataCandidate, TmdbClient } from "./client";
const PUNCT = /[、.。,,·::;;!!??'’"“”()()[\]【】「」\-—―+|\\_/&#~~]/g;
/** 对照 remove_punctuation(allow_space=False)+ upper:去标点、零宽字符与所有空白后转大写。 */
export function normalizeTitleForCompare(v: string): string {
return v.replace(PUNCT, "").replace(/[​-‍]/g, "").replace(/\s+/g, "").toUpperCase();
}
/** 规范化后完全相等才算匹配(不是模糊包含)。 */
export function compareNames(
name: string,
candidates: (string | null | undefined)[] | string | null | undefined,
): boolean {
if (!name || !candidates) return false;
const list = Array.isArray(candidates) ? candidates : [candidates];
const n = normalizeTitleForCompare(name);
return list.some((c) => !!c && normalizeTitleForCompare(c.trim()) === n);
}
/** [year, year+1, year-1];无年份时 [undefined]。 */
export function yearRange(year?: string | number | null): (string | undefined)[] {
if (year === undefined || year === null || year === "") return [undefined];
const y = Number(year);
return [String(y), String(y + 1), String(y - 1)];
}
const dateOf = (c: MetadataCandidate) => c.releaseDate ?? "0000-00-00";
const titleMatches = (name: string, c: MetadataCandidate) => compareNames(name, [c.title, c.originalTitle]);
async function aliasResolve(
tmdb: TmdbClient,
candidates: MetadataCandidate[],
accepts: (d: MetadataCandidate) => boolean,
): Promise<MetadataCandidate | null> {
for (const c of candidates) {
const d = await tmdb.details(c.entityType, c.externalId);
if (accepts(d)) return d;
}
return null;
}
async function searchByName(
tmdb: TmdbClient,
name: string,
type: MediaEntityType,
year: string | undefined,
): Promise<MetadataCandidate | null> {
const items = await tmdb.searchRaw(type, name, year);
if (!items.length) return null;
let cands = [...items].sort((a, b) => (dateOf(a) < dateOf(b) ? 1 : dateOf(a) > dateOf(b) ? -1 : 0));
if (year) cands = cands.filter((c) => (c.releaseDate ?? "").slice(0, 4) === year);
const direct = cands.find((c) => titleMatches(name, c));
if (direct) return direct;
return aliasResolve(tmdb, cands, (d) => compareNames(name, d.aliases));
}
function seasonYearMatches(d: MetadataCandidate, seasonYear: string, seasonNumber: number): boolean {
return (d.seasons ?? []).some((s) => s.seasonNumber === seasonNumber && (s.airDate ?? "").slice(0, 4) === seasonYear);
}
async function searchBySeason(
tmdb: TmdbClient,
name: string,
seasonYear: string,
seasonNumber: number,
): Promise<MetadataCandidate | null> {
const items = await tmdb.searchRaw("tv", name);
if (!items.length) return null;
const cands = [...items].sort((a, b) => (dateOf(a) < dateOf(b) ? 1 : dateOf(a) > dateOf(b) ? -1 : 0));
const direct = cands.find((c) => titleMatches(name, c) && (c.releaseDate ?? "").slice(0, 4) === seasonYear);
if (direct) return direct;
return aliasResolve(
tmdb,
cands,
(d) =>
(titleMatches(name, d) || compareNames(name, d.aliases)) &&
((d.releaseDate ?? "").slice(0, 4) === seasonYear || seasonYearMatches(d, seasonYear, seasonNumber)),
);
}
export interface TmdbMatchInput {
name: string;
type: MediaEntityType;
/** 电影上映年份 / 剧集首播年份 */
year?: string | number;
/** 当前季首播年份(与 seasonNumber 同时给出时先按季匹配) */
seasonYear?: string | number;
seasonNumber?: number;
}
export interface TmdbMatchResult {
candidate: MetadataCandidate;
/** 命中的步骤,便于记录证据 */
step: { target: "movie_name" | "tv_name" | "tv_season"; year: string | undefined };
}
/** 对照 TmdbApi.match:按冻结计划逐步查询,首个命中短路;全部未命中返回 null。 */
export async function matchTmdb(tmdb: TmdbClient, input: TmdbMatchInput): Promise<TmdbMatchResult | null> {
if (!input.name) return null;
const steps: TmdbMatchResult["step"][] = [];
if (input.type === "tv" && input.seasonYear !== undefined && input.seasonNumber !== undefined) {
steps.push({ target: "tv_season", year: String(input.seasonYear) });
}
for (const y of yearRange(input.year))
steps.push({ target: input.type === "tv" ? "tv_name" : "movie_name", year: y });
for (const step of steps) {
const hit =
step.target === "tv_season"
? await searchBySeason(tmdb, input.name, step.year!, input.seasonNumber!)
: await searchByName(tmdb, input.name, input.type, step.year);
if (hit) return { candidate: hit, step };
}
return null;
}
/** 对照 TmdbApi.match_multi:无类型无年份时用 /search/multi;电影优先,日期降序。 */
export async function matchTmdbMulti(tmdb: TmdbClient, name: string): Promise<MetadataCandidate | null> {
if (!name) return null;
const multis = await tmdb.searchMulti(name);
if (!multis.length) return null;
const key = (c: MetadataCandidate) => (c.entityType === "movie" ? "1" : "0") + dateOf(c);
const cands = [...multis].sort((a, b) => (key(a) < key(b) ? 1 : key(a) > key(b) ? -1 : 0));
const direct = cands.find((c) => titleMatches(name, c));
if (direct) return direct;
return aliasResolve(tmdb, cands, (d) => compareNames(name, d.aliases));
}
+187
View File
@@ -0,0 +1,187 @@
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
compareNames,
createTmdbClient,
matchTmdb,
matchTmdbMulti,
type TmdbConfig,
TmdbError,
yearRange,
} from "./index";
let mock: MockTmdb | null = null;
afterEach(async () => {
await mock?.close();
mock = null;
});
async function setup(extra: Partial<TmdbConfig> = {}) {
mock = await createMockTmdb();
const tmdb = createTmdbClient({
baseUrl: `${mock.url}/3`,
apiKey: "test-tmdb-key",
retries: 2,
retryBaseMs: 5,
timeoutMs: 300,
...extra,
});
return { mock, tmdb };
}
describe("TmdbClient 搜索与详情", () => {
it("中文搜索电影,中英标题合并为一个候选", async () => {
const { tmdb } = await setup();
const r = await tmdb.search("奥本海默", { type: "movie" });
expect(r).toHaveLength(1);
expect(r[0]).toMatchObject({ provider: "tmdb", entityType: "movie", externalId: "872585", year: 2023 });
expect(r[0]!.title).toBe("奥本海默");
expect(r[0]!.titles.map((t) => `${t.kind}:${t.lang}:${t.text}`)).toEqual([
"localized:zh-CN:奥本海默",
"original:en:Oppenheimer",
"localized:en-US:Oppenheimer",
]);
});
it("英文搜剧 + 年份过滤", async () => {
const { tmdb } = await setup();
const r = await tmdb.search("the bear", { type: "tv" });
expect(r.map((c) => c.externalId)).toEqual(["136315"]);
expect(await tmdb.search("the bear", { type: "tv", year: 2019 })).toEqual([]);
const all = await tmdb.searchAll("流浪地球");
expect(all.map((c) => `${c.entityType}:${c.externalId}`)).toEqual(["movie:842675"]);
});
it("电影详情:别名与外部 ID", async () => {
const { tmdb } = await setup();
const d = await tmdb.details("movie", 693134);
expect(d.detailed).toBe(true);
expect(d.title).toBe("沙丘2");
expect(d.aliases).toEqual(["Dune 2", "沙丘:第二部", "沙丘:第二部", "沙丘2", "Dune: Part Two"]);
expect(d.titles.find((t) => t.kind === "alias" && t.region === "TW")?.text).toBe("沙丘:第二部");
expect(d.externalIds).toEqual({ imdb: "tt15239678" });
expect(d.seasons).toBeUndefined();
});
it("剧集详情:季列表与集列表", async () => {
const { tmdb } = await setup();
const d = await tmdb.details("tv", 136315);
expect(d.seasons?.map((s) => [s.seasonNumber, s.episodeCount])).toEqual([
[1, 8],
[2, 10],
[3, 10],
]);
expect(d.externalIds).toEqual({ imdb: "tt14452776", tvdb: "401654" });
const s2 = await tmdb.season(136315, 2);
expect(s2.episodes).toHaveLength(10);
expect(s2.episodes[0]).toMatchObject({ seasonNumber: 2, episodeNumber: 1, name: "第 1 集" });
});
it("国产综艺:特别篇季 0、按期命名、简称别名可召回", async () => {
const { tmdb } = await setup();
const r = await tmdb.search("向往", { type: "tv" });
expect(r.map((c) => c.externalId)).toEqual(["900001"]);
const d = await tmdb.details("tv", 900001);
expect(d.originalLanguage).toBe("zh");
expect(d.seasons?.map((s) => s.seasonNumber)).toEqual([0, 1, 2]);
expect(d.externalIds).toEqual({});
const s1 = await tmdb.season(900001, 1);
expect(s1.episodes.at(-1)).toMatchObject({ episodeNumber: 12, name: "第 12 期" });
});
});
describe("TmdbClient 缓存/限流/错误分类", () => {
it("LRU+TTL 缓存:命中不发请求,过期后重新拉取", async () => {
let now = 1_000_000;
const { mock, tmdb } = await setup({ now: () => now, cache: { ttlMs: 1000, max: 100 } });
await tmdb.details("movie", 872585);
const n1 = mock.calls.length;
await tmdb.details("movie", 872585);
expect(mock.calls.length).toBe(n1);
expect(tmdb.cacheStats().hits).toBeGreaterThan(0);
now += 5000;
await tmdb.details("movie", 872585);
expect(mock.calls.length).toBe(n1 * 2);
});
it("并发合并:同一请求并发只发一次", async () => {
const { mock, tmdb } = await setup();
await Promise.all(Array.from({ length: 5 }, () => tmdb.season(100088, 1)));
expect(mock.callsTo("/3/tv/100088/season/1")).toHaveLength(1);
expect(tmdb.cacheStats().coalesced).toBe(4);
});
it("429 按 Retry-After 退避后成功;持续 429 → rate", async () => {
const { mock, tmdb } = await setup();
mock.fault("/3/movie/842675", { status: 429, headers: { "retry-after": "0" }, times: 1 });
const d = await tmdb.details("movie", 842675);
expect(d.title).toBe("流浪地球2");
mock.fault("/3/tv/100088", { status: 429, headers: { "retry-after": "0" }, times: 10 });
const e = await tmdb.details("tv", 100088).catch((x) => x);
expect(e).toBeInstanceOf(TmdbError);
expect(e.kind).toBe("rate");
});
it("失败不进缓存:5xx 后重试成功,notfound 分类", async () => {
const { mock, tmdb } = await setup({ retries: 0 });
mock.fault("/3/tv/136315/season/3", { status: 503 });
expect((await tmdb.season(136315, 3).catch((e) => e)).kind).toBe("protocol");
expect((await tmdb.season(136315, 3)).episodes).toHaveLength(10);
expect((await tmdb.details("movie", 1).catch((e) => e)).kind).toBe("notfound");
expect((await tmdb.season(136315, 9).catch((e) => e)).kind).toBe("notfound");
});
it("凭据错误 → auth;超时 → timeout;连接失败 → network", async () => {
const { mock } = await setup();
const bad = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "wrong", retries: 0 });
expect((await bad.search("x", { type: "movie" }).catch((e) => e)).kind).toBe("auth");
const slow = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 50 });
mock.fault("/3/search", { delayMs: 300, times: 2 });
expect((await slow.search("dune", { type: "movie" }).catch((e) => e)).kind).toBe("timeout");
const dead = createTmdbClient({ baseUrl: "http://127.0.0.1:1/3", apiKey: "k", retries: 0 });
expect((await dead.details("movie", 1).catch((e) => e)).kind).toBe("network");
});
it("Bearer token 鉴权可用", async () => {
mock = await createMockTmdb({ accessToken: "v4-token" });
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, accessToken: "v4-token" });
expect((await tmdb.search("last of us", { type: "tv" }))[0]?.externalId).toBe("100088");
expect(mock.calls.every((c) => c.query.api_key === undefined)).toBe(true);
});
});
describe("TMDB 匹配链(对照 MP TmdbApi.match)", () => {
it("名称比较:去标点/空白、大小写不敏感、完全相等", () => {
expect(compareNames("Dune Part Two", "Dune: Part Two")).toBe(true);
expect(compareNames("沙丘 第二部", ["沙丘:第二部"])).toBe(true);
expect(compareNames("Dune", "Dune: Part Two")).toBe(false);
expect(yearRange(2023)).toEqual(["2023", "2024", "2022"]);
expect(yearRange(undefined)).toEqual([undefined]);
});
it("年份容差 ±1:2023 未命中后按 2024 命中", async () => {
const { tmdb, mock } = await setup();
const r = await matchTmdb(tmdb, { name: "沙丘2", type: "movie", year: 2023 });
expect(r?.candidate.externalId).toBe("693134");
expect(r?.step).toEqual({ target: "movie_name", year: "2024" });
expect(mock.callsTo("/3/search/movie").map((c) => c.query.year)).toEqual(["2023", "2024"]);
});
it("标题不直达时拉详情用别名/译名匹配", async () => {
const { tmdb } = await setup();
const r = await matchTmdb(tmdb, { name: "奥本海默传", type: "movie" });
expect(r?.candidate).toMatchObject({ externalId: "872585", detailed: true });
const ja = await matchTmdb(tmdb, { name: "オッペンハイマー", type: "movie", year: 2023 });
expect(ja).toBeNull(); // mock 搜索不索引译名 → 搜不到候选,与 TMDB 行为一致:译名只在详情比较阶段参与
expect(await matchTmdb(tmdb, { name: "完全不存在", type: "movie", year: 2000 })).toBeNull();
});
it("季年份匹配优先:按季首播年份命中剧集;multi 电影优先", async () => {
const { tmdb } = await setup();
const r = await matchTmdb(tmdb, { name: "熊家餐馆", type: "tv", seasonYear: 2024, seasonNumber: 3, year: 2022 });
expect(r?.candidate.externalId).toBe("136315");
expect(r?.step.target).toBe("tv_season");
const m = await matchTmdbMulti(tmdb, "The Last of Us");
expect(m).toMatchObject({ entityType: "tv", externalId: "100088" });
});
});