501 lines
15 KiB
TypeScript
501 lines
15 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';
|
|
|
|
|
|
|
|
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}`;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
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("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}, torrentFilePath=${torrentFilePath}, 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: 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;
|
|
}
|
|
|
|
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! },
|
|
});
|
|
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(`Torrent ${torrentName} not found in qBittorrent after adding`);
|
|
}
|
|
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]); // 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.');
|
|
}
|
|
|
|
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.
|
|
await this.__addTorrent(torrentFilePath);
|
|
|
|
// 5. Get magnet link
|
|
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 });
|