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

@@ -0,0 +1,5 @@
# qbittorrent
qBittorrent WebUI API v2 适配(`QbClient` 实现 `DownloaderClient`,按实例创建,`DownloaderRegistry` 管多实例):Cookie 登录与会话过期自动重登、API Key(Bearer)、`torrents/add`(磁力/URL/.torrent,savepath/category/tags/paused+stopped/rename/contentLayout,autoTMM 固定关闭)、`torrents/info`(hashes/tag/category/filter,多标签子集过滤)、`files`、`filePrio`、pause/resume(5.x 自动切到 stop/start)、标签增删与定义删除、`delete`(`deleteFiles` 必须显式)、`setForceStart`/`recheck`/`setLocation`/`setCategory`/限速与分享限制、只读偏好、`sync/maindata`、`app/version`。错误统一为 `DownloaderError`(auth/network/timeout/notfound/rate/rejected/protocol),读请求按配置重试,写请求不自动重试。`submit()` 本地算 infohash(bencode 自写)→ 预查已存在 → 提交 → 按 hash 或探针标签确认;收单超时/断连时先回查再给结论(`added`/`already_exists`/`accepted_unconfirmed`/`rejected`/`unreachable`)。状态映射与完成判定对照 MoviePilot,未知状态不兜底为完成。
仅对 mock 与官方 API 文档验证,未连接真实实例。
+754
View File
@@ -0,0 +1,754 @@
/**
* qBittorrent WebUI API v2 客户端。
* 迁移来源:MoviePilot app/modules/qbittorrent/qbittorrent.py(e6820a8),逐函数对照见
* docs/porting/moviepilot-downloader-and-sites.md「W1-3a 实际迁移对照」。
* 叠加:多实例、错误分类(DownloaderError)、提交幂等(本地 infohash 预查 + 收单超时按 hash 回查)。
*/
import { randomBytes } from "node:crypto";
import { DownloaderError, type DownloaderErrorKind } from "./errors";
import { isDownloadComplete, mapQbState, type QbObservedState } from "./state";
import { BencodeError, parseMagnet, parseTorrent } from "./torrent";
export interface QbInstanceConfig {
/** 本系统内的下载器实例 ID(downloader_instance.id) */
instanceId: string;
/** 例:http://127.0.0.1:8080(不带 /api/v2) */
baseUrl: string;
username?: string;
password?: string;
/** qB 5.2+ WebUI API Key;有则走 Bearer,不登录 */
apiKey?: string;
/** 单次请求超时,默认 15000 */
timeoutMs?: number;
/** 幂等读请求的重试次数(不含首次),默认 2;写请求不自动重试 */
retries?: number;
/** 重试退避基数,指数增长,默认 300ms */
retryBaseMs?: number;
/** 提交后按 infohash/探针标签回查的次数与间隔,默认 9 次 × 3000ms(对照 MP get_torrent_id_by_tag;首查不等待) */
lookup?: { attempts: number; intervalMs: number };
/** 探针标签前缀,默认 uhd-probe- */
probeTagPrefix?: string;
/** 实例级默认:顺序下载 / 首尾块优先(对照 MP sequentail / first_last_piece 配置) */
sequentialDownload?: boolean;
firstLastPiecePrio?: boolean;
/** 注入 fetch(测试/代理) */
fetch?: typeof fetch;
}
export interface QbTorrent {
instanceId: string;
hash: string;
infohashV1: string | null;
infohashV2: string | null;
name: string;
rawState: string;
state: QbObservedState;
/** 严格完成判定结果(progress>=1 && amountLeft<=0 && 终态) */
complete: boolean;
progress: number;
amountLeft: number;
size: number;
totalSize: number | null;
savePath: string;
contentPath: string | null;
category: string;
tags: string[];
addedOn: number | null;
completionOn: number | null;
dlspeed: number;
upspeed: number;
eta: number | null;
ratio: number | null;
seedingTimeSec: number | null;
}
export interface QbFile {
index: number;
name: string;
size: number;
progress: number;
/** 0 不下载,1 普通,6 高,7 最高 */
priority: number;
isSeed: boolean | null;
}
export type TorrentSource =
| { kind: "magnet"; uri: string }
| { kind: "url"; url: string }
| { kind: "file"; bytes: Uint8Array; filename?: string };
export interface AddTorrentRequest {
source: TorrentSource;
savePath: string;
category?: string;
tags?: string[];
paused?: boolean;
rename?: string;
contentLayout?: "Original" | "Subfolder" | "NoSubfolder";
sequential?: boolean;
firstLastPiecePrio?: boolean;
}
export interface AddTorrentResult {
accepted: boolean;
addedIds: string[];
raw: string;
}
export type SubmitOutcome =
| { kind: "added"; hash: string; via: "confirmed" | "added_ids" | "probe_tag" | "recovered_after_timeout" }
| { kind: "already_exists"; hash: string; existing: QbTorrent }
/** 下载器已接收(或可能已接收),但回查窗口内未找到任务;调用方稍后按 hash/probeTag 对账,不得盲目重提 */
| { kind: "accepted_unconfirmed"; hash: string | null; probeTag: string | null }
| { kind: "rejected"; reason: string; hash: string | null }
/** 确认未提交成功(回查未命中且无歧义)或下载器不可达;有 hash 时用同一 hash 重试是幂等的 */
| { kind: "unreachable"; error: DownloaderError; hash: string | null };
export interface TorrentQuery {
hashes?: string[];
tag?: string;
/** 须同时具备的标签(对照 MP get_torrents 的 tags 子集判断);第一个走 qB 原生 tag 参数 */
tags?: string[];
category?: string;
filter?:
| "all"
| "downloading"
| "seeding"
| "completed"
| "paused"
| "stopped"
| "active"
| "inactive"
| "stalled"
| "errored";
}
export interface QbMainData {
rid: number;
fullUpdate: boolean;
torrents: Record<string, Record<string, unknown>>;
torrentsRemoved: string[];
serverState: Record<string, unknown> | null;
}
/** 下载器端口(实例维度)。上层 tasks/agent 只依赖此接口。 */
export interface DownloaderClient {
readonly instanceId: string;
version(): Promise<{ app: string; webApi: string }>;
add(req: AddTorrentRequest): Promise<AddTorrentResult>;
submit(req: AddTorrentRequest): Promise<SubmitOutcome>;
list(q?: TorrentQuery): Promise<QbTorrent[]>;
/** 严格完成判定后的已完成任务(对照 get_completed_torrents) */
getCompleted(q?: Pick<TorrentQuery, "hashes" | "tags">): Promise<QbTorrent[]>;
getDownloading(q?: Pick<TorrentQuery, "hashes" | "tags">): Promise<QbTorrent[]>;
findByHash(hash: string): Promise<QbTorrent | null>;
files(hash: string, opts?: { retries?: number; intervalMs?: number }): Promise<QbFile[]>;
/** priority 0 不下载;fileIds 为空返回 false 且不发请求(对照 set_files) */
setFilePriority(hash: string, fileIds: number[], priority: 0 | 1 | 6 | 7): Promise<boolean>;
setForceStart(hashes: string[], enable?: boolean): Promise<void>;
recheck(hashes: string[]): Promise<void>;
setLocation(hashes: string[], location: string): Promise<void>;
setCategory(hashes: string[], category: string): Promise<void>;
changeTorrent(hash: string, opts: TorrentLimits): Promise<void>;
/** 只读下载器全局偏好(不改写,见 QA6) */
preferences(): Promise<Record<string, unknown>>;
contentLayout(): Promise<"Original" | "Subfolder" | "NoSubfolder">;
transferInfo(): Promise<Record<string, unknown>>;
pause(hashes: string[]): Promise<void>;
resume(hashes: string[]): Promise<void>;
addTags(hashes: string[], tags: string[]): Promise<void>;
removeTags(hashes: string[], tags: string[]): Promise<void>;
deleteTagDefinitions(tags: string[]): Promise<void>;
/** 从任务移除标签并删除全局定义;hashes 为 null 时只删定义(对照 delete_torrents_tag) */
deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise<void>;
/** deleteFiles 必须显式给出;删数据前的引用检查由上层 assertSafeToDeleteData 负责 */
delete(hashes: string[], opts: { deleteFiles: boolean }): Promise<void>;
mainData(rid?: number): Promise<QbMainData>;
}
export interface TorrentLimits {
/** KB/s,0 不限速 */
uploadLimitKBps?: number;
downloadLimitKBps?: number;
ratioLimit?: number;
/** 分钟 */
seedingTimeLimitMin?: number;
}
type Body = { kind: "form"; data: Record<string, string> } | { kind: "multipart"; data: FormData };
interface ReqOpts {
query?: Record<string, string | undefined>;
body?: Body;
idempotent: boolean;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function num(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
function str(v: unknown): string | null {
return typeof v === "string" && v !== "" ? v : null;
}
export function normalizeTorrent(instanceId: string, r: Record<string, unknown>): QbTorrent {
const rawState = typeof r.state === "string" ? r.state : "";
const progress = num(r.progress) ?? 0;
const amountLeft = num(r.amount_left) ?? Number.POSITIVE_INFINITY;
const tags =
typeof r.tags === "string"
? r.tags
.split(",")
.map((t) => t.trim())
.filter(Boolean)
: [];
return {
instanceId,
hash: String(r.hash ?? "").toLowerCase(),
infohashV1: str(r.infohash_v1),
infohashV2: str(r.infohash_v2),
name: String(r.name ?? ""),
rawState,
state: mapQbState(rawState),
complete: isDownloadComplete({ progress, amountLeft, state: rawState }),
progress,
amountLeft,
size: num(r.size) ?? 0,
totalSize: num(r.total_size),
savePath: String(r.save_path ?? ""),
contentPath: str(r.content_path),
category: String(r.category ?? ""),
tags,
addedOn: num(r.added_on),
completionOn: (num(r.completion_on) ?? -1) > 0 ? num(r.completion_on) : null,
dlspeed: num(r.dlspeed) ?? 0,
upspeed: num(r.upspeed) ?? 0,
eta: num(r.eta),
ratio: num(r.ratio),
seedingTimeSec: num(r.seeding_time),
};
}
export class QbClient implements DownloaderClient {
readonly instanceId: string;
private readonly base: string;
private readonly timeoutMs: number;
private readonly retries: number;
private readonly retryBaseMs: number;
private readonly lookup: { attempts: number; intervalMs: number };
private readonly probePrefix: string;
private readonly fetchImpl: typeof fetch;
private sid: string | null = null;
private loginInFlight: Promise<void> | null = null;
/** qB 5.x 把 pause/resume 改名为 stop/start;探测到后缓存 */
private legacyPauseApi: boolean | null = null;
constructor(private readonly cfg: QbInstanceConfig) {
this.instanceId = cfg.instanceId;
this.base = cfg.baseUrl.replace(/\/+$/, "");
this.timeoutMs = cfg.timeoutMs ?? 15_000;
this.retries = cfg.retries ?? 2;
this.retryBaseMs = cfg.retryBaseMs ?? 300;
this.lookup = cfg.lookup ?? { attempts: 9, intervalMs: 3000 };
this.probePrefix = cfg.probeTagPrefix ?? "uhd-probe-";
this.fetchImpl = cfg.fetch ?? fetch;
}
private err(kind: DownloaderErrorKind, message: string, endpoint: string, status?: number, cause?: unknown) {
return new DownloaderError(kind, `[qb:${this.instanceId}] ${message}`, {
instanceId: this.instanceId,
endpoint,
...(status !== undefined ? { status } : {}),
...(cause !== undefined ? { cause } : {}),
});
}
private async rawFetch(endpoint: string, init: RequestInit): Promise<Response> {
try {
return await this.fetchImpl(`${this.base}/api/v2/${endpoint}`, {
...init,
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (e) {
const name = (e as { name?: string })?.name;
if (name === "TimeoutError" || name === "AbortError") {
throw this.err("timeout", `request timed out after ${this.timeoutMs}ms`, endpoint, undefined, e);
}
throw this.err("network", `request failed: ${(e as Error)?.message ?? String(e)}`, endpoint, undefined, e);
}
}
/** 登录;并发调用合并为一次。 */
async login(): Promise<void> {
if (this.cfg.apiKey) return;
if (!this.loginInFlight) {
this.loginInFlight = this.doLogin().finally(() => {
this.loginInFlight = null;
});
}
return this.loginInFlight;
}
private async doLogin(): Promise<void> {
const endpoint = "auth/login";
const res = await this.rawFetch(endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded", referer: this.base },
body: new URLSearchParams({ username: this.cfg.username ?? "", password: this.cfg.password ?? "" }),
});
const text = await res.text();
if (res.status === 403) throw this.err("auth", "login forbidden (IP banned after failed attempts?)", endpoint, 403);
if (res.status === 429) throw this.err("rate", "login rate limited", endpoint, 429);
if (!res.ok)
throw this.err(res.status >= 500 ? "protocol" : "auth", `login HTTP ${res.status}`, endpoint, res.status);
if (!text.trim().startsWith("Ok")) throw this.err("auth", "login rejected (bad credentials)", endpoint, res.status);
const cookie = res.headers
.getSetCookie()
.map((c) => /^SID=([^;]*)/.exec(c)?.[1])
.find((v) => v !== undefined);
// 关闭鉴权或白名单 IP 时 qB 可能不下发 cookie,仍视为成功
this.sid = cookie ?? "";
}
private async send(endpoint: string, opts: ReqOpts): Promise<Response> {
const attempts = opts.idempotent ? this.retries + 1 : 1;
let lastErr: DownloaderError | null = null;
for (let i = 0; i < attempts; i++) {
if (i > 0) await sleep(this.retryBaseMs * 2 ** (i - 1));
try {
return await this.sendOnce(endpoint, opts, false);
} catch (e) {
if (!(e instanceof DownloaderError)) throw e;
lastErr = e;
const retryable = e.kind === "network" || e.kind === "timeout" || e.kind === "rate" || e.kind === "protocol";
if (!retryable) throw e;
}
}
throw lastErr!;
}
private async sendOnce(endpoint: string, opts: ReqOpts, relogged: boolean): Promise<Response> {
if (!this.cfg.apiKey && this.sid === null) await this.login();
const headers: Record<string, string> = { referer: this.base };
if (this.cfg.apiKey) headers.authorization = `Bearer ${this.cfg.apiKey}`;
else if (this.sid) headers.cookie = `SID=${this.sid}`;
let body: RequestInit["body"] | undefined;
if (opts.body?.kind === "form") {
headers["content-type"] = "application/x-www-form-urlencoded";
body = new URLSearchParams(opts.body.data);
} else if (opts.body?.kind === "multipart") {
body = opts.body.data;
}
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) qs.set(k, v);
const path = qs.size ? `${endpoint}?${qs}` : endpoint;
const res = await this.rawFetch(path, { method: body ? "POST" : "GET", headers, ...(body ? { body } : {}) });
const s = res.status;
if (s === 401 || s === 403) {
await res.body?.cancel();
if (!this.cfg.apiKey && !relogged) {
this.sid = null;
await this.login();
return this.sendOnce(endpoint, opts, true);
}
throw this.err("auth", `unauthorized (HTTP ${s})`, endpoint, s);
}
if (s === 404) {
const t = await res.text();
throw this.err("notfound", t || "not found", endpoint, s);
}
if (s === 429) {
await res.body?.cancel();
throw this.err("rate", "rate limited", endpoint, s);
}
if (s === 400 || s === 409 || s === 415) {
const t = await res.text();
throw this.err("rejected", t || `HTTP ${s}`, endpoint, s);
}
if (!res.ok) {
await res.body?.cancel();
throw this.err("protocol", `unexpected HTTP ${s}`, endpoint, s);
}
return res;
}
private async json<T>(endpoint: string, query?: Record<string, string | undefined>): Promise<T> {
const res = await this.send(endpoint, { idempotent: true, ...(query ? { query } : {}) });
const text = await res.text();
try {
return JSON.parse(text) as T;
} catch (e) {
throw this.err("protocol", "invalid JSON response", endpoint, res.status, e);
}
}
private async post(endpoint: string, data: Record<string, string>, idempotent = true): Promise<string> {
const res = await this.send(endpoint, { idempotent, body: { kind: "form", data } });
return res.text();
}
async version(): Promise<{ app: string; webApi: string }> {
const app = await (await this.send("app/version", { idempotent: true })).text();
const webApi = await (await this.send("app/webapiVersion", { idempotent: true })).text();
return { app: app.trim(), webApi: webApi.trim() };
}
async add(req: AddTorrentRequest): Promise<AddTorrentResult> {
const form = new FormData();
const src = req.source;
if (src.kind === "file") {
form.append(
"torrents",
new Blob([new Uint8Array(src.bytes)], { type: "application/x-bittorrent" }),
src.filename ?? "upload.torrent",
);
} else {
form.append("urls", src.kind === "magnet" ? src.uri : src.url);
}
form.append("savepath", req.savePath);
if (req.category !== undefined) form.append("category", req.category);
if (req.tags?.length) form.append("tags", req.tags.join(","));
const paused = req.paused ? "true" : "false";
form.append("paused", paused); // <= 4.x
form.append("stopped", paused); // >= 5.x
if (req.rename) form.append("rename", req.rename);
form.append("contentLayout", req.contentLayout ?? "Original");
form.append("autoTMM", "false");
const seq = req.sequential ?? this.cfg.sequentialDownload;
const flp = req.firstLastPiecePrio ?? this.cfg.firstLastPiecePrio;
if (seq !== undefined) form.append("sequentialDownload", String(seq));
if (flp !== undefined) form.append("firstLastPiecePrio", String(flp));
const res = await this.send("torrents/add", { idempotent: false, body: { kind: "multipart", data: form } });
const raw = (await res.text()).trim();
if (raw.startsWith("{")) {
try {
const j = JSON.parse(raw) as {
success_count?: number;
pending_count?: number;
added_torrent_ids?: string[];
};
const ids = (j.added_torrent_ids ?? []).map((x) => x.toLowerCase());
return { accepted: ids.length > 0 || (j.success_count ?? 0) + (j.pending_count ?? 0) > 0, addedIds: ids, raw };
} catch {
// 落到文本判定
}
}
return { accepted: raw.startsWith("Ok"), addedIds: [], raw };
}
async list(q: TorrentQuery = {}): Promise<QbTorrent[]> {
const need = [...(q.tag !== undefined ? [q.tag] : []), ...(q.tags ?? [])];
const rows = await this.json<Record<string, unknown>[]>("torrents/info", {
filter: q.filter,
category: q.category,
tag: need[0],
hashes: q.hashes?.length ? q.hashes.map((h) => h.toLowerCase()).join("|") : undefined,
});
if (!Array.isArray(rows)) throw this.err("protocol", "torrents/info did not return an array", "torrents/info");
const list = rows.map((r) => normalizeTorrent(this.instanceId, r));
return need.length > 1 ? list.filter((t) => need.every((tag) => t.tags.includes(tag))) : list;
}
async getCompleted(q: Pick<TorrentQuery, "hashes" | "tags"> = {}): Promise<QbTorrent[]> {
return (await this.list({ ...q, filter: "completed" })).filter((t) => t.complete);
}
async getDownloading(q: Pick<TorrentQuery, "hashes" | "tags"> = {}): Promise<QbTorrent[]> {
return this.list({ ...q, filter: "downloading" });
}
/** 按 hash 精确查询,不受管理标签限制。查不到返回 null;下载器不可达抛错(不能当作不存在)。 */
async findByHash(hash: string): Promise<QbTorrent | null> {
const h = hash.toLowerCase();
const rows = await this.list({ hashes: [h] });
return rows.find((t) => t.hash === h || t.infohashV1 === h || t.infohashV2?.startsWith(h)) ?? null;
}
/** 文件列表;空列表(磁力元数据未就绪)按 retries/intervalMs 重试(对照 get_files)。 */
async files(hash: string, opts: { retries?: number; intervalMs?: number } = {}): Promise<QbFile[]> {
const tries = Math.max(1, opts.retries ?? 1);
let rows: Record<string, unknown>[] = [];
for (let i = 0; i < tries; i++) {
if (i > 0 && opts.intervalMs) await sleep(opts.intervalMs);
rows = await this.json<Record<string, unknown>[]>("torrents/files", { hash: hash.toLowerCase() });
if (rows.length) break;
}
return rows.map((r, i) => ({
index: num(r.index) ?? i,
name: String(r.name ?? ""),
size: num(r.size) ?? 0,
progress: num(r.progress) ?? 0,
priority: num(r.priority) ?? 1,
isSeed: typeof r.is_seed === "boolean" ? r.is_seed : null,
}));
}
private async pauseLike(hashes: string[], legacy: string, modern: string): Promise<void> {
if (!hashes.length) return;
const data = { hashes: hashes.join("|") };
if (this.legacyPauseApi !== false) {
try {
await this.post(legacy, data);
this.legacyPauseApi = true;
return;
} catch (e) {
if (!(e instanceof DownloaderError && e.kind === "notfound" && this.legacyPauseApi === null)) throw e;
this.legacyPauseApi = false;
}
}
await this.post(modern, data);
}
pause(hashes: string[]): Promise<void> {
return this.pauseLike(hashes, "torrents/pause", "torrents/stop");
}
resume(hashes: string[]): Promise<void> {
return this.pauseLike(hashes, "torrents/resume", "torrents/start");
}
async addTags(hashes: string[], tags: string[]): Promise<void> {
await this.post("torrents/addTags", { hashes: hashes.join("|"), tags: tags.join(",") });
}
async removeTags(hashes: string[], tags: string[]): Promise<void> {
await this.post("torrents/removeTags", { hashes: hashes.join("|"), tags: tags.join(",") });
}
async deleteTagDefinitions(tags: string[]): Promise<void> {
await this.post("torrents/deleteTags", { tags: tags.join(",") });
}
async deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise<void> {
if (hashes?.length) await this.removeTags(hashes, tags);
await this.deleteTagDefinitions(tags);
}
async setFilePriority(hash: string, fileIds: number[], priority: 0 | 1 | 6 | 7): Promise<boolean> {
if (!hash || !fileIds.length) return false;
await this.post("torrents/filePrio", {
hash: hash.toLowerCase(),
id: fileIds.join("|"),
priority: String(priority),
});
return true;
}
async setForceStart(hashes: string[], enable = true): Promise<void> {
if (!hashes.length) return;
await this.post("torrents/setForceStart", { hashes: hashes.join("|"), value: String(enable) });
}
async recheck(hashes: string[]): Promise<void> {
if (!hashes.length) return;
await this.post("torrents/recheck", { hashes: hashes.join("|") });
}
async setLocation(hashes: string[], location: string): Promise<void> {
if (!hashes.length || !location) return;
await this.post("torrents/setLocation", { hashes: hashes.join("|"), location });
}
async setCategory(hashes: string[], category: string): Promise<void> {
if (!hashes.length) return;
await this.post("torrents/setCategory", { hashes: hashes.join("|"), category });
}
/** 对照 change_torrent:限速单位 KB/s → B/s;只给 seedingTime 时 ratioLimit=-2(沿用全局)。 */
async changeTorrent(hash: string, o: TorrentLimits): Promise<void> {
const h = hash.toLowerCase();
if (o.uploadLimitKBps !== undefined) {
await this.post("torrents/setUploadLimit", { hashes: h, limit: String(Math.trunc(o.uploadLimitKBps * 1024)) });
}
if (o.downloadLimitKBps !== undefined) {
await this.post("torrents/setDownloadLimit", {
hashes: h,
limit: String(Math.trunc(o.downloadLimitKBps * 1024)),
});
}
if (o.ratioLimit !== undefined || o.seedingTimeLimitMin !== undefined) {
await this.post("torrents/setShareLimits", {
hashes: h,
ratioLimit: o.ratioLimit !== undefined ? String(Math.round(o.ratioLimit * 100) / 100) : "-2",
seedingTimeLimit: String(Math.trunc(o.seedingTimeLimitMin ?? -1)),
inactiveSeedingTimeLimit: "-1",
});
}
}
async preferences(): Promise<Record<string, unknown>> {
return this.json<Record<string, unknown>>("app/preferences");
}
async contentLayout(): Promise<"Original" | "Subfolder" | "NoSubfolder"> {
const v = (await this.preferences()).torrent_content_layout;
return v === "Subfolder" || v === "NoSubfolder" ? v : "Original";
}
async transferInfo(): Promise<Record<string, unknown>> {
return this.json<Record<string, unknown>>("transfer/info");
}
async delete(hashes: string[], opts: { deleteFiles: boolean }): Promise<void> {
if (typeof opts?.deleteFiles !== "boolean") throw new TypeError("deleteFiles must be explicit");
if (!hashes.length) return;
await this.post("torrents/delete", { hashes: hashes.join("|"), deleteFiles: String(opts.deleteFiles) });
}
async mainData(rid = 0): Promise<QbMainData> {
const j = await this.json<Record<string, unknown>>("sync/maindata", { rid: String(rid) });
return {
rid: num(j.rid) ?? 0,
fullUpdate: j.full_update === true,
torrents: (j.torrents as Record<string, Record<string, unknown>>) ?? {},
torrentsRemoved: (j.torrents_removed as string[]) ?? [],
serverState: (j.server_state as Record<string, unknown>) ?? null,
};
}
private async pollHash(hash: string): Promise<QbTorrent | null> {
for (let i = 0; i < this.lookup.attempts; i++) {
if (i > 0) await sleep(this.lookup.intervalMs);
try {
const t = await this.findByHash(hash);
if (t) return t;
} catch (e) {
if (!(e instanceof DownloaderError) || e.kind === "auth") throw e;
}
}
return null;
}
private async pollTag(tag: string): Promise<QbTorrent | null> {
for (let i = 0; i < this.lookup.attempts; i++) {
if (i > 0) await sleep(this.lookup.intervalMs);
try {
const rows = await this.list({ tag });
if (rows[0]) return rows[0];
} catch (e) {
if (!(e instanceof DownloaderError) || e.kind === "auth") throw e;
}
}
return null;
}
private async dropProbe(tag: string, hash: string | null): Promise<void> {
try {
if (hash) await this.removeTags([hash], [tag]);
await this.deleteTagDefinitions([tag]);
} catch {
// 残留探针标签由对账任务按前缀清理
}
}
/**
* 幂等提交:本地算 infohash → 预查是否已存在 → add → 按 hash(或探针标签)确认。
* add 超时/断连时不重提,先回查(REQ §8.4)。
*/
async submit(req: AddTorrentRequest): Promise<SubmitOutcome> {
let hash: string | null = null;
const src = req.source;
if (src.kind === "file") {
try {
hash = parseTorrent(src.bytes).hash;
} catch (e) {
if (e instanceof BencodeError) return { kind: "rejected", reason: `invalid torrent: ${e.message}`, hash: null };
throw e;
}
} else if (src.kind === "magnet") {
const m = parseMagnet(src.uri);
if (!m) return { kind: "rejected", reason: "invalid magnet uri", hash: null };
hash = m.hash;
} else {
hash = parseMagnet(src.url)?.hash ?? null;
}
if (hash) {
try {
const existing = await this.findByHash(hash);
if (existing) return { kind: "already_exists", hash: existing.hash, existing };
} catch (e) {
if (e instanceof DownloaderError) return { kind: "unreachable", error: e, hash };
throw e;
}
}
const probeTag = hash ? null : `${this.probePrefix}${Date.now().toString(36)}${randomBytes(5).toString("hex")}`;
const tags = probeTag ? [...(req.tags ?? []), probeTag] : req.tags;
let result: AddTorrentResult;
try {
result = await this.add({ ...req, ...(tags ? { tags } : {}) });
} catch (e) {
if (!(e instanceof DownloaderError)) throw e;
if (e.ambiguous) {
if (hash) {
const t = await this.pollHash(hash).catch(() => null);
if (t) return { kind: "added", hash: t.hash, via: "recovered_after_timeout" };
return { kind: "unreachable", error: e, hash };
}
const t = await this.pollTag(probeTag!).catch(() => null);
if (t) {
await this.dropProbe(probeTag!, t.hash);
return { kind: "added", hash: t.hash, via: "recovered_after_timeout" };
}
return { kind: "accepted_unconfirmed", hash: null, probeTag };
}
if (e.kind === "rejected") {
if (hash) {
const t = await this.findByHash(hash).catch(() => null);
if (t) return { kind: "already_exists", hash: t.hash, existing: t };
}
if (probeTag) await this.dropProbe(probeTag, null);
return { kind: "rejected", reason: e.message, hash };
}
return { kind: "unreachable", error: e, hash };
}
if (!result.accepted) {
// 4.x:已存在/非法链接都返回 Fails.;先查是否已存在
if (hash) {
const t = await this.findByHash(hash).catch(() => null);
if (t) return { kind: "already_exists", hash: t.hash, existing: t };
}
if (probeTag) await this.dropProbe(probeTag, null);
return { kind: "rejected", reason: `downloader refused: ${result.raw.slice(0, 200)}`, hash };
}
const knownHash = hash ?? result.addedIds[0] ?? null;
if (knownHash) {
const t = await this.pollHash(knownHash).catch(() => null);
if (probeTag) await this.dropProbe(probeTag, t?.hash ?? knownHash);
if (t) return { kind: "added", hash: t.hash, via: hash ? "confirmed" : "added_ids" };
return { kind: "accepted_unconfirmed", hash: knownHash, probeTag: null };
}
const t = await this.pollTag(probeTag!).catch(() => null);
if (t) {
await this.dropProbe(probeTag!, t.hash);
return { kind: "added", hash: t.hash, via: "probe_tag" };
}
return { kind: "accepted_unconfirmed", hash: null, probeTag };
}
}
export function createQbClient(cfg: QbInstanceConfig): DownloaderClient & { login(): Promise<void> } {
return new QbClient(cfg);
}
/** 多实例注册表:按 instanceId 取客户端。 */
export class DownloaderRegistry {
private readonly clients = new Map<string, DownloaderClient>();
constructor(configs: QbInstanceConfig[] = []) {
for (const c of configs) this.register(c);
}
register(cfg: QbInstanceConfig): DownloaderClient {
const c = new QbClient(cfg);
this.clients.set(cfg.instanceId, c);
return c;
}
get(instanceId: string): DownloaderClient | undefined {
return this.clients.get(instanceId);
}
remove(instanceId: string): boolean {
return this.clients.delete(instanceId);
}
list(): DownloaderClient[] {
return [...this.clients.values()];
}
}
@@ -0,0 +1,29 @@
/** 下载器错误分类。调用方据此决定重试/回查/告警,不解析 message。 */
export type DownloaderErrorKind =
| "auth" // 凭据错误、会话失效且重登失败、IP 被封禁
| "network" // 连接失败/连接被重置:请求是否到达下载器未知
| "timeout" // 超时:请求是否被执行未知(需按 infohash 回查)
| "notfound" // 目标 hash 不存在
| "rate" // 429 / 限流
| "rejected" // 下载器明确拒绝(如 torrents/add 返回 Fails.、415 非法种子)
| "protocol"; // 响应不符合预期(版本不兼容、5xx 等)
export class DownloaderError extends Error {
override readonly name = "DownloaderError";
constructor(
readonly kind: DownloaderErrorKind,
message: string,
readonly detail: { instanceId: string; endpoint?: string; status?: number; cause?: unknown } = { instanceId: "" },
) {
super(message);
}
/** 该错误发生时,写操作是否可能已在下载器侧生效。 */
get ambiguous(): boolean {
return this.kind === "timeout" || this.kind === "network";
}
}
export function isDownloaderError(e: unknown): e is DownloaderError {
return e instanceof DownloaderError;
}
@@ -0,0 +1,5 @@
export * from "./client";
export * from "./errors";
export * from "./paths";
export * from "./state";
export * from "./torrent";
@@ -0,0 +1,31 @@
// 迁移来源:MoviePilot app/modules/__init__.py normalize_path / normalize_return_path(Q27,e6820a8)。
/** 下载器视角路径 ↔ 本机存储路径的前缀映射,首条命中即停(对照 MP normalize_path)。 */
export interface PathMapping {
/** 本机(本服务进程)看到的路径前缀 */
local: string;
/** 下载器看到的路径前缀 */
downloader: string;
}
function swap(p: string, from: string, to: string): string | null {
const f = from.replace(/\/+$/, "");
if (p === f) return to.replace(/\/+$/, "") || "/";
if (p.startsWith(`${f}/`)) return `${to.replace(/\/+$/, "")}${p.slice(f.length)}`;
return null;
}
export function toDownloaderPath(p: string, map: PathMapping[]): string {
for (const m of map) {
const r = swap(p, m.local, m.downloader);
if (r !== null) return r;
}
return p;
}
export function fromDownloaderPath(p: string, map: PathMapping[]): string {
for (const m of map) {
const r = swap(p, m.downloader, m.local);
if (r !== null) return r;
}
return p;
}
@@ -0,0 +1,338 @@
import { createMockQb, type MockQb, makeMagnet, makeTorrent } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import { createQbClient, DownloaderError, isDownloadComplete, mapQbState, parseMagnet, parseTorrent } from "./index";
import { fromDownloaderPath, toDownloaderPath } from "./paths";
let mock: MockQb | null = null;
afterEach(async () => {
await mock?.close();
mock = null;
});
const fast = { retries: 1, retryBaseMs: 5, lookup: { attempts: 3, intervalMs: 10 }, timeoutMs: 500 };
async function setup(opts: Parameters<typeof createMockQb>[0] = {}) {
mock = await createMockQb(opts);
const qb = createQbClient({
instanceId: "qb-1",
baseUrl: mock.url,
username: "admin",
password: "adminadmin",
...fast,
});
return { mock, qb };
}
const movie = () =>
makeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC.DTS-HD.MA.5.1-FGT.mkv", length: 80e9 });
describe("torrent / magnet 解析", () => {
it("本地 infohash 与独立实现一致,多文件列表完整", () => {
const t = makeTorrent({
name: "The.Bear.S03.1080p",
files: [
{ path: "E01.mkv", length: 100 },
{ path: "Subs/E01.ass", length: 5 },
],
});
const meta = parseTorrent(t.bytes);
expect(meta.hash).toBe(t.hash);
expect(meta.infohashV1).toBe(t.hash);
expect(meta.totalSize).toBe(105);
expect(meta.files.map((f) => f.path)).toEqual(["The.Bear.S03.1080p/E01.mkv", "The.Bear.S03.1080p/Subs/E01.ass"]);
expect(() => parseTorrent(new TextEncoder().encode("not bencode"))).toThrow();
});
it("磁力 btih hex/base32 都能取出 hash", () => {
const hex = "3f5a0000000000000000000000000000000000c9";
expect(parseMagnet(makeMagnet(hex, "x"))?.hash).toBe(hex);
// base32 of 20 zero bytes = 32 个 A
expect(parseMagnet(`magnet:?xt=urn:btih:${"A".repeat(32)}`)?.hash).toBe("0".repeat(40));
expect(parseMagnet("http://example.com/a.torrent")).toBeNull();
});
it("状态映射不兜底为完成", () => {
expect(mapQbState("stalledDL")).toBe("stalled");
expect(mapQbState("pausedUP")).toBe("completed_paused");
expect(mapQbState("missingFiles")).toBe("missing_files");
expect(mapQbState("whatever")).toBe("unknown");
expect(isDownloadComplete({ progress: 1, amountLeft: 0, state: "error" })).toBe(false);
expect(isDownloadComplete({ progress: 1, amountLeft: 0, state: "checkingUP" })).toBe(false);
expect(isDownloadComplete({ progress: 1, amountLeft: 0, state: "forcedUP" })).toBe(true);
expect(isDownloadComplete({ progress: 1, amountLeft: 0, state: "brandNewState" })).toBe(false);
});
it("路径映射首条命中", () => {
const map = [{ local: "/mnt/cache/downloads", downloader: "/downloads" }];
expect(toDownloaderPath("/mnt/cache/downloads/pt/a.mkv", map)).toBe("/downloads/pt/a.mkv");
expect(fromDownloaderPath("/downloads/pt/a.mkv", map)).toBe("/mnt/cache/downloads/pt/a.mkv");
expect(fromDownloaderPath("/downloadsX/a.mkv", map)).toBe("/downloadsX/a.mkv");
});
});
describe("QbClient 鉴权与错误分类", () => {
it("登录后读版本,会话复用只登录一次", async () => {
const { mock, qb } = await setup();
expect(await qb.version()).toEqual({ app: "v4.6.7", webApi: "2.9.3" });
await qb.list();
expect(mock.callsTo("auth/login")).toHaveLength(1);
});
it("错误凭据 → auth", async () => {
mock = await createMockQb();
const qb = createQbClient({ instanceId: "qb-x", baseUrl: mock.url, username: "admin", password: "bad", ...fast });
const e = await qb.version().catch((x) => x);
expect(e).toBeInstanceOf(DownloaderError);
expect(e.kind).toBe("auth");
});
it("会话过期 403 → 自动重登一次后成功", async () => {
const { mock, qb } = await setup();
await qb.list();
mock.expireSessions();
await expect(qb.list()).resolves.toEqual([]);
expect(mock.callsTo("auth/login")).toHaveLength(2);
});
it("5.x API Key(Bearer)无需登录", async () => {
mock = await createMockQb({ version: "5.1.0", apiKey: "k-123" });
const qb = createQbClient({ instanceId: "qb-5", baseUrl: mock.url, apiKey: "k-123", ...fast });
expect((await qb.version()).webApi).toBe("2.11.4");
expect(mock.callsTo("auth/login")).toHaveLength(0);
});
it("连接失败 → network;超时 → timeout;429 → rate;5xx 重试后成功", async () => {
const { mock: m, qb } = await setup();
await qb.list();
m.fault("torrents/info", { status: 429, times: 5 });
expect((await qb.list().catch((e) => e)).kind).toBe("rate");
m.clearFaults();
m.fault("torrents/info", { status: 502 });
await expect(qb.list()).resolves.toEqual([]);
m.fault("torrents/info", { delayMs: 800, times: 5 });
expect((await qb.list().catch((e) => e)).kind).toBe("timeout");
m.clearFaults();
const url = m.url;
await m.close();
mock = null;
const dead = createQbClient({ instanceId: "dead", baseUrl: url, username: "a", password: "b", ...fast });
const e = await dead.list().catch((x) => x);
expect(e.kind).toBe("network");
expect(e.ambiguous).toBe(true);
});
it("files 对不存在的 hash → notfound;findByHash 返回 null", async () => {
const { qb } = await setup();
expect(await qb.findByHash("0".repeat(40))).toBeNull();
expect((await qb.files("0".repeat(40)).catch((e) => e)).kind).toBe("notfound");
});
});
describe("QbClient 提交与回查", () => {
it("4.x .torrent 提交:参数透传,本地 hash 确认", async () => {
const { mock, qb } = await setup();
const t = movie();
const out = await qb.submit({
source: { kind: "file", bytes: t.bytes, filename: "a.torrent" },
savePath: "/downloads/uhd",
category: "movies",
tags: ["uhd-managed"],
paused: true,
});
expect(out).toEqual({ kind: "added", hash: t.hash, via: "confirmed" });
const m = mock.get(t.hash)!;
expect(m.save_path).toBe("/downloads/uhd");
expect(m.category).toBe("movies");
expect(m.tags).toEqual(["uhd-managed"]);
expect(m.state).toBe("pausedDL");
});
it("5.x 磁力提交:JSON 响应,added_torrent_ids 生效", async () => {
const { mock, qb } = await setup({ version: "5.1.0" });
const hash = "ab".repeat(20);
const out = await qb.submit({ source: { kind: "magnet", uri: makeMagnet(hash, "Dune") }, savePath: "/d" });
expect(out).toMatchObject({ kind: "added", hash });
expect(mock.callsTo("torrents/add")).toHaveLength(1);
});
it("hash 未知(URL):探针标签轮询找回,用后删除", async () => {
const { mock, qb } = await setup({ addVisibleAfterPolls: 1 });
const hash = "cd".repeat(20);
// mock 能从 URL 中取出 btih;adapter 视其为普通 URL(不是 magnet: 开头)
const out = await qb.submit({
source: { kind: "url", url: `https://site.invalid/dl?x=1&xt=urn:btih:${hash}` },
savePath: "/d",
tags: ["uhd-managed"],
});
expect(out).toEqual({ kind: "added", hash, via: "probe_tag" });
expect(mock.get(hash)!.tags).toEqual(["uhd-managed"]);
expect(mock.tagDefinitions().some((t) => t.startsWith("uhd-probe-"))).toBe(false);
});
it("已存在同 infohash:不重复提交、不改其路径/标签", async () => {
const { mock, qb } = await setup();
const t = movie();
mock.seed({ hash: t.hash, name: t.name, save_path: "/downloads/pt", tags: ["BT", "keep"] });
const out = await qb.submit({ source: { kind: "file", bytes: t.bytes }, savePath: "/x", tags: ["uhd"] });
expect(out.kind).toBe("already_exists");
if (out.kind === "already_exists") expect(out.existing.tags).toEqual(["BT", "keep"]);
expect(mock.callsTo("torrents/add")).toHaveLength(0);
expect(mock.get(t.hash)!.save_path).toBe("/downloads/pt");
});
it("同名同大小但 infohash 不同:不视为已存在", async () => {
const { mock, qb } = await setup();
const a = makeTorrent({ name: "The.Bear.S03.1080p.DSNP.WEB-DL", length: 5000, pieceSeed: 1 });
const b = makeTorrent({ name: "The.Bear.S03.1080p.DSNP.WEB-DL", length: 5000, pieceSeed: 2 });
expect(a.hash).not.toBe(b.hash);
mock.seed({ hash: a.hash, name: a.name, size: 5000 });
const out = await qb.submit({ source: { kind: "file", bytes: b.bytes }, savePath: "/d" });
expect(out).toMatchObject({ kind: "added", hash: b.hash });
});
it("add 响应丢失(已收单):按 infohash 回查找回,不重提", async () => {
const { mock, qb } = await setup();
await qb.list();
const t = movie();
mock.fault("torrents/add", { dropAfterApply: true });
const out = await qb.submit({ source: { kind: "file", bytes: t.bytes }, savePath: "/d" });
expect(out).toEqual({ kind: "added", hash: t.hash, via: "recovered_after_timeout" });
expect(mock.callsTo("torrents/add")).toHaveLength(1);
});
it("add 超时但已执行:回查命中", async () => {
const { mock, qb } = await setup();
await qb.list();
const hash = "ef".repeat(20);
mock.fault("torrents/add", { delayMs: 900 });
const out = await qb.submit({ source: { kind: "magnet", uri: makeMagnet(hash) }, savePath: "/d" });
expect(out).toEqual({ kind: "added", hash, via: "recovered_after_timeout" });
});
it("add 连接断开且未执行:unreachable 并带 hash 供幂等重试", async () => {
const { mock, qb } = await setup();
await qb.list();
const hash = "12".repeat(20);
mock.fault("torrents/add", { drop: true });
const out = await qb.submit({ source: { kind: "magnet", uri: makeMagnet(hash) }, savePath: "/d" });
expect(out.kind).toBe("unreachable");
if (out.kind === "unreachable") {
expect(out.hash).toBe(hash);
expect(out.error.kind).toBe("network");
}
expect(mock.get(hash)).toBeUndefined();
});
it("非法种子 → rejected,不发请求", async () => {
const { mock, qb } = await setup();
const out = await qb.submit({ source: { kind: "file", bytes: new Uint8Array([1, 2, 3]) }, savePath: "/d" });
expect(out.kind).toBe("rejected");
expect(mock.callsTo("torrents/add")).toHaveLength(0);
});
});
describe("QbClient 查询与操作", () => {
it("按 tag 查询、进度推进、完成/出错/缺文件状态", async () => {
const { mock, qb } = await setup();
mock.seed({ hash: "a".repeat(40), name: "A", tags: ["uhd"] });
mock.seed({ hash: "b".repeat(40), name: "B" });
expect((await qb.list({ tag: "uhd" })).map((t) => t.name)).toEqual(["A"]);
mock.setProgress("a".repeat(40), 0.5);
let a = (await qb.findByHash("a".repeat(40)))!;
expect(a.state).toBe("downloading");
expect(a.complete).toBe(false);
mock.complete("a".repeat(40));
a = (await qb.findByHash("a".repeat(40)))!;
expect(a.state).toBe("seeding");
expect(a.complete).toBe(true);
mock.setError("a".repeat(40));
expect((await qb.findByHash("a".repeat(40)))!.complete).toBe(false);
mock.setMissingFiles("b".repeat(40));
expect((await qb.findByHash("b".repeat(40)))!.state).toBe("missing_files");
});
it("文件列表、标签增删、删除必须显式 deleteFiles", async () => {
const { mock, qb } = await setup();
const t = makeTorrent({
name: "Show.S01",
files: [
{ path: "E01.mkv", length: 10 },
{ path: "E02.mkv", length: 10 },
],
});
await qb.submit({ source: { kind: "file", bytes: t.bytes }, savePath: "/d" });
expect((await qb.files(t.hash)).map((f) => f.name)).toEqual(["Show.S01/E01.mkv", "Show.S01/E02.mkv"]);
await qb.addTags([t.hash], ["uhd", "x"]);
await qb.removeTags([t.hash], ["x"]);
expect(mock.get(t.hash)!.tags).toEqual(["uhd"]);
// @ts-expect-error deleteFiles 缺失应被拒绝
await expect(qb.delete([t.hash], {})).rejects.toThrow(TypeError);
await qb.delete([t.hash], { deleteFiles: false });
expect(mock.callsTo("torrents/delete")[0]!.form.deleteFiles).toBe("false");
expect(mock.get(t.hash)).toBeUndefined();
});
it("5.x 暂停/恢复自动切换到 stop/start,maindata 可读", async () => {
const { mock, qb } = await setup({ version: "5.1.0" });
mock.seed({ hash: "c".repeat(40), name: "C" });
await qb.pause(["c".repeat(40)]);
expect(mock.get("c".repeat(40))!.state).toBe("stoppedDL");
await qb.resume(["c".repeat(40)]);
expect(mock.get("c".repeat(40))!.state).toBe("downloading");
expect(mock.callsTo("torrents/pause")).toHaveLength(1);
expect(mock.callsTo("torrents/resume")).toHaveLength(0);
const md = await qb.mainData();
expect(Object.keys(md.torrents)).toEqual(["c".repeat(40)]);
});
});
describe("QbClient 对照 MP 的补充操作", () => {
it("选文件(set_files)、强制开始、重新校验、改路径/分类、限速与分享限制", async () => {
const { mock, qb } = await setup();
const t = makeTorrent({
name: "The.Bear.S03",
files: [
{ path: "E01.mkv", length: 10 },
{ path: "E02.mkv", length: 10 },
{ path: "E03.mkv", length: 10 },
],
});
await qb.submit({ source: { kind: "file", bytes: t.bytes }, savePath: "/d", paused: true });
expect(await qb.setFilePriority(t.hash, [], 0)).toBe(false);
expect(await qb.setFilePriority(t.hash, [0, 2], 0)).toBe(true);
expect((await qb.files(t.hash)).map((f) => f.priority)).toEqual([0, 1, 0]);
await qb.setForceStart([t.hash]);
expect(mock.get(t.hash)!.force_start).toBe(true);
await qb.recheck([t.hash]);
expect(mock.get(t.hash)!.state).toBe("checkingDL");
await qb.setLocation([t.hash], "/downloads/tv");
await qb.setCategory([t.hash], "tv");
expect(mock.get(t.hash)).toMatchObject({ save_path: "/downloads/tv", category: "tv" });
await qb.changeTorrent(t.hash, { uploadLimitKBps: 100, seedingTimeLimitMin: 4320 });
expect(mock.get(t.hash)).toMatchObject({ up_limit: 102400, ratio_limit: -2, seeding_time_limit: 4320 });
});
it("getCompleted 严格判定;多标签子集过滤;只读偏好与内容布局", async () => {
const { mock, qb } = await setup({ preferences: { torrent_content_layout: "Subfolder" } });
mock.seed({ hash: "1".repeat(40), name: "done", tags: ["uhd", "movie"], progress: 1 });
mock.seed({ hash: "2".repeat(40), name: "err", tags: ["uhd"], progress: 1 });
mock.setError("2".repeat(40));
mock.seed({ hash: "3".repeat(40), name: "dl", tags: ["uhd"], progress: 0.2 });
expect((await qb.getCompleted({ tags: ["uhd"] })).map((t) => t.name)).toEqual(["done"]);
expect((await qb.getDownloading()).map((t) => t.name)).toEqual(["dl"]);
expect((await qb.list({ tags: ["uhd", "movie"] })).map((t) => t.name)).toEqual(["done"]);
expect(await qb.contentLayout()).toBe("Subfolder");
expect(mock.callsTo("app/setPreferences")).toHaveLength(0);
await qb.deleteTorrentsTag(["1".repeat(40)], ["movie"]);
expect(mock.tagDefinitions()).not.toContain("movie");
expect((await qb.transferInfo()).connection_status).toBe("connected");
});
it("磁力元数据未就绪:files 空列表按次数重试", async () => {
const { mock, qb } = await setup();
const h = "9".repeat(40);
mock.seed({ hash: h, name: "meta", files: [] });
expect(await qb.files(h, { retries: 3, intervalMs: 5 })).toEqual([]);
expect(mock.callsTo("torrents/files")).toHaveLength(3);
});
});
@@ -0,0 +1,71 @@
// 迁移来源:MoviePilot app/modules/qbittorrent/qbittorrent.py get_completed_torrents(Q6)与 __init__.py __normalize_torrent_state(Q23),e6820a8。
/**
* qB 原始 state → 观测态。与 MoviePilot 不同:未识别/error/missingFiles 绝不兜底为完成
* (见 docs/porting/moviepilot-downloader-and-sites.md §1.4、QA5)。
*/
export type QbObservedState =
| "downloading"
| "stalled"
| "paused"
| "completed_paused"
| "seeding"
| "checking"
| "moving"
| "error"
| "missing_files"
| "queued"
| "metadata"
| "unknown";
const MAP: Record<string, QbObservedState> = {
downloading: "downloading",
forceddl: "downloading",
allocating: "downloading",
checkingresumedata: "checking",
checkingdl: "checking",
checkingup: "checking",
metadl: "metadata",
forcedmetadl: "metadata",
queueddl: "queued",
stalleddl: "stalled",
pauseddl: "paused",
stoppeddl: "paused",
pausedup: "completed_paused",
stoppedup: "completed_paused",
uploading: "seeding",
stalledup: "seeding",
forcedup: "seeding",
queuedup: "seeding",
moving: "moving",
error: "error",
missingfiles: "missing_files",
};
export function mapQbState(raw: string | null | undefined): QbObservedState {
if (!raw) return "unknown";
return MAP[raw.toLowerCase()] ?? "unknown";
}
const NOT_DONE = new Set([
"allocating",
"checkingdl",
"checkingup",
"checkingresumedata",
"downloading",
"error",
"forceddl",
"missingfiles",
"metadl",
"forcedmetadl",
"moving",
"queueddl",
"stalleddl",
"unknown",
]);
/** 严格完成判定(QK4):progress>=1 && amountLeft<=0 && state 属于已知终态。 */
export function isDownloadComplete(t: { progress: number; amountLeft: number; state: string }): boolean {
const s = t.state.toLowerCase();
if (!(s in MAP) || NOT_DONE.has(s)) return false;
return t.progress >= 1 && t.amountLeft <= 0;
}
@@ -0,0 +1,218 @@
// 迁移来源:MoviePilot app/modules/_base/downloader.py _get_torrent_info(Q26,e6820a8);本地 infohash 计算为本项目新增(QK2)。
import { createHash } from "node:crypto";
/**
* 最小 bencode 解码 + infohash 计算。
* v1 infohash = sha1(info 原始字节);v2 = sha256(info 原始字节)(仅 meta version 2)。
* 直接对原始字节切片求哈希,不做 decode→encode 往返,避免非规范编码导致 hash 漂移。
*/
export type BencodeValue = number | Uint8Array | BencodeValue[] | { [k: string]: BencodeValue };
export class BencodeError extends Error {
override readonly name = "BencodeError";
}
interface DecodeState {
buf: Uint8Array;
pos: number;
infoStart: number;
infoEnd: number;
depth: number;
}
const C_I = 0x69;
const C_L = 0x6c;
const C_D = 0x64;
const C_E = 0x65;
const C_COLON = 0x3a;
function readInt(s: DecodeState, end: number): number {
let str = "";
while (s.pos < s.buf.length && s.buf[s.pos] !== end) {
str += String.fromCharCode(s.buf[s.pos]!);
s.pos++;
}
if (s.pos >= s.buf.length) throw new BencodeError("unexpected end of input");
s.pos++;
if (!/^-?\d+$/.test(str)) throw new BencodeError(`invalid integer: ${str}`);
const n = Number(str);
if (!Number.isSafeInteger(n)) throw new BencodeError(`integer out of range: ${str}`);
return n;
}
function decodeValue(s: DecodeState): BencodeValue {
if (s.depth > 64) throw new BencodeError("nesting too deep");
const c = s.buf[s.pos];
if (c === undefined) throw new BencodeError("unexpected end of input");
if (c === C_I) {
s.pos++;
return readInt(s, C_E);
}
if (c === C_L) {
s.pos++;
s.depth++;
const out: BencodeValue[] = [];
while (s.buf[s.pos] !== C_E) {
if (s.pos >= s.buf.length) throw new BencodeError("unterminated list");
out.push(decodeValue(s));
}
s.pos++;
s.depth--;
return out;
}
if (c === C_D) {
s.pos++;
s.depth++;
const out: { [k: string]: BencodeValue } = {};
while (s.buf[s.pos] !== C_E) {
if (s.pos >= s.buf.length) throw new BencodeError("unterminated dict");
const key = Buffer.from(decodeBytes(s)).toString("utf8");
const start = s.pos;
out[key] = decodeValue(s);
if (key === "info" && s.depth === 1) {
s.infoStart = start;
s.infoEnd = s.pos;
}
}
s.pos++;
s.depth--;
return out;
}
return decodeBytes(s);
}
function decodeBytes(s: DecodeState): Uint8Array {
const len = readInt(s, C_COLON);
if (len < 0 || s.pos + len > s.buf.length) throw new BencodeError("byte string out of bounds");
const out = s.buf.subarray(s.pos, s.pos + len);
s.pos += len;
return out;
}
export function bdecode(buf: Uint8Array): BencodeValue {
const s: DecodeState = { buf, pos: 0, infoStart: -1, infoEnd: -1, depth: 0 };
const v = decodeValue(s);
if (s.pos !== buf.length) throw new BencodeError("trailing bytes after root value");
return v;
}
export interface TorrentFileEntry {
path: string;
length: number;
}
export interface TorrentMeta {
/** qB 用作任务 ID 的 hash:v1 优先,纯 v2 时为 v2 前 40 位 */
hash: string;
infohashV1: string | null;
infohashV2: string | null;
name: string;
totalSize: number;
files: TorrentFileEntry[];
private: boolean;
}
function asBytes(v: BencodeValue | undefined): Uint8Array | undefined {
return v instanceof Uint8Array ? v : undefined;
}
function asStr(v: BencodeValue | undefined): string | undefined {
const b = asBytes(v);
return b ? Buffer.from(b).toString("utf8") : undefined;
}
function asDict(v: BencodeValue | undefined): { [k: string]: BencodeValue } | undefined {
return v && typeof v === "object" && !(v instanceof Uint8Array) && !Array.isArray(v) ? v : undefined;
}
function walkV2Tree(node: { [k: string]: BencodeValue }, prefix: string[], out: TorrentFileEntry[]): void {
for (const [k, v] of Object.entries(node)) {
const d = asDict(v);
if (!d) continue;
if (k === "") {
const len = d.length;
if (typeof len === "number") out.push({ path: prefix.join("/"), length: len });
} else {
walkV2Tree(d, [...prefix, k], out);
}
}
}
/** 解析 .torrent 字节;非法内容抛 BencodeError。 */
export function parseTorrent(bytes: Uint8Array): TorrentMeta {
const s: DecodeState = { buf: bytes, pos: 0, infoStart: -1, infoEnd: -1, depth: 0 };
const root = asDict(decodeValue(s));
if (!root || s.infoStart < 0) throw new BencodeError("missing info dictionary");
const info = asDict(root.info);
if (!info) throw new BencodeError("info is not a dictionary");
const raw = bytes.subarray(s.infoStart, s.infoEnd);
const isV2 = info["meta version"] === 2;
const hasV1 = asBytes(info.pieces) !== undefined;
const infohashV1 = hasV1 ? createHash("sha1").update(raw).digest("hex") : null;
const infohashV2 = isV2 ? createHash("sha256").update(raw).digest("hex") : null;
if (!infohashV1 && !infohashV2) throw new BencodeError("neither v1 pieces nor v2 meta version present");
const name = asStr(info["name.utf-8"]) ?? asStr(info.name) ?? "";
const files: TorrentFileEntry[] = [];
const list = info.files;
if (Array.isArray(list)) {
for (const f of list) {
const fd = asDict(f);
if (!fd) continue;
const parts = (Array.isArray(fd["path.utf-8"]) ? fd["path.utf-8"] : fd.path) as BencodeValue[] | undefined;
const segs = (parts ?? []).map((p) => asStr(p) ?? "");
if (fd.attr && asStr(fd.attr)?.includes("p")) continue; // BEP47 padding
files.push({ path: [name, ...segs].join("/"), length: typeof fd.length === "number" ? fd.length : 0 });
}
} else if (typeof info.length === "number") {
files.push({ path: name, length: info.length });
} else if (isV2 && asDict(info["file tree"])) {
walkV2Tree(asDict(info["file tree"])!, [name], files);
}
return {
hash: infohashV1 ?? infohashV2!.slice(0, 40),
infohashV1,
infohashV2,
name,
totalSize: files.reduce((a, f) => a + f.length, 0),
files,
private: info.private === 1,
};
}
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function base32ToHex(s: string): string | null {
let bits = "";
for (const ch of s.toUpperCase()) {
const i = B32.indexOf(ch);
if (i < 0) return null;
bits += i.toString(2).padStart(5, "0");
}
let hex = "";
for (let i = 0; i + 4 <= bits.length; i += 4) hex += Number.parseInt(bits.slice(i, i + 4), 2).toString(16);
return hex.slice(0, 40);
}
export interface MagnetMeta {
hash: string;
infohashV1: string | null;
infohashV2: string | null;
displayName: string | null;
}
/** 从磁力链接取 infohash(btih hex/base32,btmh sha256 multihash)。不是合法磁力返回 null。 */
export function parseMagnet(uri: string): MagnetMeta | null {
if (!uri.toLowerCase().startsWith("magnet:?")) return null;
const params = new URLSearchParams(uri.slice(8));
let v1: string | null = null;
let v2: string | null = null;
for (const xt of params.getAll("xt")) {
const m1 = /^urn:btih:([0-9a-fA-F]{40}|[A-Za-z2-7]{32})$/.exec(xt);
if (m1) {
const h = m1[1]!;
v1 = h.length === 40 ? h.toLowerCase() : base32ToHex(h);
continue;
}
const m2 = /^urn:btmh:1220([0-9a-fA-F]{64})$/.exec(xt);
if (m2) v2 = m2[1]!.toLowerCase();
}
if (!v1 && !v2) return null;
return { hash: v1 ?? v2!.slice(0, 40), infohashV1: v1, infohashV2: v2, displayName: params.get("dn") };
}