/** * 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; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 });