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

+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));
}