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:
1 parent
9c881e737f
commit
ef2e504a94
45 files changed
+5766
-1
No files matched your search
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user