fp/services/worker/src/util/qbittorrent.ts
2025-11-26 03:34:32 -08:00

611 lines
18 KiB
TypeScript

/**
* src/utils/qbittorrent.ts
*
* qBittorrent API client
* Used for creating torrent v1/v2 hybrids.
*
*
* to keep things simple,
* we use the same mounted volume directory names inside the docker container
* as on the host.
*
* /srv/futureporn/worker/qbittorrent/downloads
*
*/
import path from "node:path";
import env from "../../.config/env";
import { readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, basename } from "node:path";
import { nanoid } from 'nanoid';
import semverParse from 'semver/functions/parse';
import { type SemVer } from 'semver';
import retry from "./retry";
interface QBittorrentClientOptions {
host?: string;
port?: number;
username?: string;
password?: string;
}
export interface TorrentCreatorTaskStatus {
errorMessage: string;
optimizeAlignment: boolean;
paddedFileSizeLimit: number;
pieceSize: number;
private: boolean;
sourcePath: string;
status: "Running" | "Finished" | "Failed" | string; // API may expand
taskID: string;
timeAdded: string; // raw string from qBittorrent
timeFinished: string; // raw string
timeStarted: string; // raw string
trackers: string[];
urlSeeds: string[];
}
export type TorrentCreatorTaskStatusMap = Record<string, TorrentCreatorTaskStatus>;
export interface QBTorrentInfo {
added_on: number;
amount_left: number;
auto_tmm: boolean;
availability: number;
category: string;
comment: string;
completed: number;
completion_on: number;
content_path: string;
dl_limit: number;
dlspeed: number;
download_path: string;
downloaded: number;
downloaded_session: number;
eta: number;
f_l_piece_prio: boolean;
force_start: boolean;
has_metadata: boolean;
hash: string;
inactive_seeding_time_limit: number;
infohash_v1: string;
infohash_v2: string;
last_activity: number;
magnet_uri: string;
max_inactive_seeding_time: number;
max_ratio: number;
max_seeding_time: number;
name: string;
num_complete: number;
num_incomplete: number;
num_leechs: number;
num_seeds: number;
popularity: number;
priority: number;
private: boolean;
progress: number;
ratio: number;
ratio_limit: number;
reannounce: number;
root_path: string;
save_path: string;
seeding_time: number;
seeding_time_limit: number;
seen_complete: number;
seq_dl: boolean;
size: number;
state: string;
super_seeding: boolean;
tags: string;
time_active: number;
total_size: number;
tracker: string;
trackers_count: number;
up_limit: number;
uploaded: number;
uploaded_session: number;
upspeed: number;
}
/**
* QBittorrentClient
*
* @see https://qbittorrent-api.readthedocs.io/en/latest/apidoc/torrentcreator.html
*/
export class QBittorrentClient {
private readonly host: string;
private readonly port: number;
private readonly username: string;
private readonly password: string;
private readonly baseUrl: string;
private sidCookie: string | null = null;
constructor(options: QBittorrentClientOptions = {}) {
const defaults = {
host: "localhost",
port: 8083,
username: "admin",
password: "adminadmin",
};
const envOptions = {
host: env.QBT_HOST!,
port: Number(env.QBT_PORT!),
username: env.QBT_USERNAME!,
password: env.QBT_PASSWORD!,
};
const { host, port, username, password } = {
...defaults,
...envOptions,
...options,
};
this.host = host;
this.port = port;
this.username = username;
this.password = password;
this.baseUrl = `http://${this.host}:${this.port}`;
}
/**
* idempotently login to qBittorrent.
*
*
*/
async connect(): Promise<void> {
if (!this.sidCookie) {
console.log(`Connecting to qBittorrent at ${this.baseUrl}`);
await this.__login();
}
}
/**
* Throw if QBittorrent version is less than 5
*/
async versionCheck(): Promise<void> {
await this.connect();
const { major, version } = (await this.__getVersion());
if (major < 5) throw new Error(`QBittorrent is outdated. Expected version >5. Got ${version}`);
}
/**
*
* Query QBittorrent to get it's version.
*/
async __getVersion(): Promise<SemVer> {
const url = `${this.baseUrl}/api/v2/app/version`;
const res = await fetch(url, { headers: { "Cookie": this.sidCookie! } });
const text = await res.text();
const v = semverParse(text);
if (!v?.version) throw new Error(`failed to parse version from body text=${text}`);
return v;
}
/**
* Logs into qBittorrent Web API.
*
* Example (cURL):
* curl -i \
* --header 'Referer: http://localhost:8080' \
* --data 'username=admin&password=adminadmin' \
* http://localhost:8080/api/v2/auth/login
*
* Then use the returned SID cookie for subsequent requests.
*/
private async __login(): Promise<void> {
console.log(`login() begin. using username=${this.username}, password=${this.password} env=${env.NODE_ENV}`);
const response = await fetch(`${this.baseUrl}/api/v2/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Referer: this.baseUrl,
},
body: new URLSearchParams({
username: this.username,
password: this.password,
}),
});
const responseBody = await response.text();
if (!response.ok) {
const msg = `Login request failed (${response.status} ${response.statusText}). body=${responseBody}`;
console.error(msg);
throw new Error(msg);
}
console.log(`Login response: status=${response.status} ${response.statusText}`);
console.log(`Headers: ${JSON.stringify([...response.headers.entries()])}`);
// Extract SID cookie
const setCookie = response.headers.get("set-cookie");
if (!setCookie) {
const msg = `Login failed: No SID cookie was returned. status=${response.status} ${response.statusText}. body=${responseBody}`;
console.error(msg);
throw new Error(msg);
}
this.sidCookie = setCookie;
console.log(`sidCookie=${this.sidCookie}`);
console.log("Successfully logged into qBittorrent.");
}
/**
* addTorrentCreationTask
*
* @param sourcePath - a file on disk that we want to turn into a torrent
* @returns torrentFilePath - a newly created torrent file
* @IMPORTANT we do not send a multipart form here.
* we only send a application/x-www-form-urlencoded
* form, and we do not upload file data. we only send a file path.
*/
private async addTorrentCreationTask(sourcePath: string): Promise<string> {
const torrentFilePath = join(tmpdir(), `${nanoid()}.torrent`);
const url = `${this.baseUrl}/api/v2/torrentcreator/addTask`;
console.log(`addTorrentCreationTask using sourcePath=${sourcePath}, url=${url}`);
console.log(`addTorrent using sourcePath=${sourcePath}`)
if (!this.sidCookie) {
throw new Error("Not connected: SID cookie missing");
}
const trackers = [
'udp://tracker.future.porn:6969/announce'
]
const params = new URLSearchParams({
sourcePath,
isPrivate: "false",
format: "hybrid",
torrentFilePath,
comment: "https://futureporn.net",
source: "https://futureporn.net",
// trackers: trackers.join('\n'), // this doesn't work. the two trackers appear on the same line in a single, invalid URL
urlSeeds: "",
startSeeding: "false", // it always fails to seed right away (it works later.)
});
params.append('trackers', trackers[0]);
// params.append('trackers', trackers[1]); // this is a problem. it overrides the first.
const res = await fetch(url, {
method: "POST",
headers: {
Cookie: this.sidCookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params as any,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`addTorrentCreationTask failed: ${res.status} ${res.statusText} ${body}`);
}
console.log('addTorrent success.');
if (!res.ok) {
const body = await res.text()
throw new Error(`addTask failed. status=${res.status} statusText=${res.statusText} body=${body}`);
}
const text = await res.text();
if (text.includes('Fail')) {
throw new Error('the response shows a failure--' + text);
}
const data = JSON.parse(text);
console.log({ addTaskResponse: data });
return data.taskID;
}
private async pollTorrentStatus(taskId: string): Promise<TorrentCreatorTaskStatus> {
while (true) {
console.log(`Polling torrent creation taskID=${taskId}`);
const res = await fetch(
`${this.baseUrl}/api/v2/torrentcreator/status?taskId=${taskId}`,
{ headers: { Cookie: this.sidCookie! } }
);
if (!res.ok) {
throw new Error(`status failed: ${res.status} ${res.statusText}`);
}
console.log('the request to poll for torrent status was successful.')
const statusMap = (await res.json()) as TorrentCreatorTaskStatusMap;
console.log({ statusMap: statusMap })
const task = Object.values(statusMap).find((t) => t.taskID === taskId);
console.log({ task: task })
if (!task) {
throw new Error(`Task ${taskId} not found in status response`);
}
console.log(` Torrent creator task status=${task.status}`);
switch (task.status) {
case "Failed":
const msg = `Torrent creation failed: ${task.errorMessage}`;
console.error(msg);
console.log('here is the task that failed', task);
throw new Error(msg);
case "Finished":
return task;
default:
// still running.. wait 1s and retry
await new Promise((r) => setTimeout(r, 1000));
}
}
}
/**
* Fetch a .torrent file from qBittorrent.
* qBittorrent writes the .torrent file to a given local path
*
* @param taskId
* @param outputDir - the torrent will be written to this directory
* @returns
*/
private async __fetchTorrentFile(taskId: string, outputDir: string): Promise<string> {
const url = `${this.baseUrl}/api/v2/torrentcreator/torrentFile?taskID=${taskId}`;
const res = await fetch(url, {
headers: { Cookie: this.sidCookie! }, // or use Authorization header
});
if (!res.ok) {
const body = await res.text();
throw new Error(`torrentFile failed: ${res.status} ${res.statusText} ${body}`);
}
const arrayBuffer = await res.arrayBuffer();
const filePath = join(outputDir, `${taskId}.torrent`);
await writeFile(filePath, new Uint8Array(arrayBuffer));
return filePath;
}
/**
* get torrent info from qBittorrent
* When a torrent is created, It can take some time before it appears in the list. So we retry it.
*
* @param torrentName
* @returns
*/
async getTorrentInfos(torrentName: string): Promise<QBTorrentInfo> {
return retry(
() => this.__getTorrentInfos(torrentName),
6,
500
);
}
/**
* Generally it's preferred to use this.getTorrentInfos over this.__getTorrentInfos because it is more fault tolerant.
*
* @param torrentName
* @returns
*/
private async __getTorrentInfos(torrentName: string): Promise<QBTorrentInfo> {
if (!torrentName) throw new Error('__getTorrentInfos requires torrentName as first arg. However, arg was falsy. ');
// ensure we're logged in
if (!this.sidCookie) throw new Error('We are not logged into QBittorrent. (sidCookie was missing.)');
// qBittorrent does NOT return infoHash directly here
// we have to get it by querying the torrents list
const torrentsRes = await fetch(`${this.baseUrl}/api/v2/torrents/info`, {
headers: { Cookie: this.sidCookie! },
});
if (!torrentsRes.ok) {
console.error('__getTorrentInfos failed to fetch() torrent info.');
const body = await torrentsRes.text();
console.error(`${torrentsRes.status} ${torrentsRes.statusText} ${body}`);
}
const torrents = await torrentsRes.json() as Array<{ hash: string; name: string }>;
const torrent = torrents.find((t) => t.name === torrentName) as QBTorrentInfo;
if (!torrent) {
throw new Error(`__getTorrentInfos failure. Torrent ${torrentName} not found in qBittorrent`);
}
return torrent;
}
async getInfoHashV2(torrentName: string): Promise<string> {
console.log(`getInfoHashV2 using torrentName=${torrentName}`)
const torrent = await this.getTorrentInfos(torrentName);
return torrent.infohash_v2;
}
/**
* Add a local torrent file to qBittorrent.
*/
async addTorrent(localFilePath: string) {
if (!localFilePath) throw new Error('addTorrent requires a path to a local file, but it was undefined.');
const bn = basename(localFilePath).replace('.torrent', '');
await this.connect();
await this.__addTorrent(localFilePath);
return (await this.getTorrentInfos(bn));
}
/**
* Add a local torrent file to qBittorrent.
*/
private async __addTorrent(localFilePath: string): Promise<void> {
console.log(`__addTorrent using localFilePath=${localFilePath}`)
if (!this.sidCookie) {
throw new Error("Not connected. (SID cookie missing.)");
}
const form = new FormData();
const fileBuffer = await readFile(localFilePath);
const blob = new Blob([fileBuffer as any]); // wrap Buffer in Blob (necessary for MDN FormData)
form.append("torrents", blob, path.basename(localFilePath)); // this is for MDN FormData
// form.append("torrents", fileBuffer); // this is for npm:form-data
form.append("savepath", "/tmp"); // optional: specify download path
form.append("paused", "true"); // start downloading immediately
const res = await fetch(`${this.baseUrl}/api/v2/torrents/add`, {
method: "POST",
headers: {
Cookie: this.sidCookie,
},
body: form as any,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`__addTorrent failed: ${res.status} ${res.statusText} ${body}`);
}
console.log('__addTorrent success.');
}
/*
* @see https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#delete-torrents
* @gripe (eww wtf qBittorrent, this isn't RESTful. If we're deleting a resource, we should be using DELETE. Oh well.)
*
* @param id {String} - a hash or a torrent name
*/
async deleteTorrent(id: string): Promise<void> {
await this.connect();
console.log(`Deleting torrent ${id}...`);
if (!this.sidCookie) {
throw new Error('Not logged in. sidCookie missing.');
}
// Detect if `id` is already a 40-character hex hash
const isHexHash = /^[a-f0-9]{40}$/.test(id);
let hashToDelete: string;
if (isHexHash) {
// Already a hash → safe to delete
hashToDelete = id;
} else {
// Not a hash → treat as name → look up hash
const info = await this.getTorrentInfos(id);
console.log('info', info);
hashToDelete = info.hash;
}
console.log(`deleting ${id} (${hashToDelete})`);
await this.__deleteTorrent(hashToDelete);
console.log(`deleteTorrent success for: ${hashToDelete}`);
}
/**
*
* @param hashes {String} - Example: 8c212779b4abde7c6bc608063a0d008b7e40ce32|54eddd830a5b58480a6143d616a97e3a6c23c439
*/
private async __deleteTorrent(hashes: string): Promise<void> {
if (!hashes) throw new Error('__deleteTorrent hashes arg missing');
if (!this.sidCookie) throw new Error('__deleteTorrent missing sidCookie');
console.log(`deleting hashes`, hashes)
const body = new URLSearchParams({
hashes,
deleteFiles: "false",
});
const res = await fetch(`${this.baseUrl}/api/v2/torrents/delete?hashes=${hashes}&deleteFiles=false`, {
method: "POST",
headers: {
Cookie: this.sidCookie,
},
body
});
if (!res.ok) {
const body = await res.text();
throw new Error(`__deleteTorrent failed: ${res.status} ${res.statusText} ${body}`);
}
}
/**
*
* @deprecated use getTorrentInfos instead
*/
async getMagnetLink(fileName: string): Promise<string> {
console.log(`getMagnetLink using fileName=${fileName}`)
// qBittorrent does NOT return infoHash directly here
// we have to get it by querying the torrents list
const torrent = await this.getTorrentInfos(fileName);
if (!torrent) {
throw new Error(`Torrent ${fileName} not found in qBittorrent after adding`);
}
return torrent.magnet_uri;
}
async createTorrent(localFilePath: string): Promise<{ torrentFilePath: string; magnetLink: string, info: QBTorrentInfo }> {
console.log(`Creating torrent from file: ${localFilePath}`);
await this.connect();
if (!this.sidCookie) {
throw new Error("sidCookie was missing. This is likely a bug.");
}
// 1. start task
const taskId = await this.addTorrentCreationTask(localFilePath);
console.log(`Created torrent task ${taskId}`);
// 2. poll until finished
await this.pollTorrentStatus(taskId);
// 3. fetch torrent file
const torrentFilePath = await this.__fetchTorrentFile(taskId, tmpdir());
// 4. add the torrent to qBittorrent
// We *could* add the torrent in the torrentcreator,
// but that usually errors right away,
// so we add it here instead. More robust this way.
await this.__addTorrent(torrentFilePath);
// 5. Get magnet link
console.log('lets get the torrent infos');
const info = await this.getTorrentInfos(basename(localFilePath))
const magnetLink = info.magnet_uri;
return {
magnetLink, torrentFilePath, info
};
}
}
export const qbtClient = new QBittorrentClient({ host: env.QBT_HOST });