Skip to content

Instantly share code, notes, and snippets.

@Hannah-GBS
Last active April 27, 2024 00:03
Show Gist options
  • Save Hannah-GBS/43343a8ea4948eb8ca4cc5ed12d8b60a to your computer and use it in GitHub Desktop.
Save Hannah-GBS/43343a8ea4948eb8ca4cc5ed12d8b60a to your computer and use it in GitHub Desktop.
import { dirname } from "path";
import {
Decision,
InjectionResult,
TORRENT_TAG,
TORRENT_CATEGORY_SUFFIX,
} from "../constants.js";
import { CrossSeedError } from "../errors.js";
import { Label, logger } from "../logger.js";
import { Metafile } from "../parseTorrent.js";
import { getRuntimeConfig } from "../runtimeConfig.js";
import { Searchee, SearcheeWithInfoHash } from "../searchee.js";
import { determineSkipRecheck, extractCredentialsFromUrl } from "../utils.js";
import { TorrentClient } from "./TorrentClient.js";
import { Result, resultOf, resultOfErr } from "../Result.js";
import { BodyInit } from "undici-types";
const X_WWW_FORM_URLENCODED = {
"Content-Type": "application/x-www-form-urlencoded",
};
interface TorrentInfo {
added_on: number;
amount_left: number;
auto_tmm: boolean;
availability: number;
category: 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;
hash: string;
infohash_v1: string;
infohash_v2: string;
last_activity: number;
magnet_uri: string;
max_ratio: number;
max_seeding_time: number;
name: string;
num_complete: number;
num_incomplete: number;
num_leechs: number;
num_seeds: number;
priority: number;
progress: number;
ratio: number;
ratio_limit: number;
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;
}
/* Unused variable - removed for build.
interface TorrentFiles {
availability: number;
index: number;
is_seed: boolean;
name: string;
piece_range: [number, number];
priority: number;
progress: number;
size: number;
}
*/
interface TorrentConfiguration {
save_path: string;
isComplete: boolean;
autoTMM: boolean;
category: string;
}
export default class QBittorrent implements TorrentClient {
cookie: string;
url: { username: string; password: string; href: string };
constructor() {
const { qbittorrentUrl } = getRuntimeConfig();
this.url = extractCredentialsFromUrl(
qbittorrentUrl,
"/api/v2",
).unwrapOrThrow(
new CrossSeedError("qBittorrent url must be percent-encoded"),
);
}
async login(): Promise<void> {
let response: Response;
const { href, username, password } = this.url;
try {
response = await fetch(`${href}/auth/login`, {
method: "POST",
body: new URLSearchParams({ username, password }),
});
} catch (e) {
throw new CrossSeedError(`qBittorrent login failed: ${e.message}`);
}
if (response.status !== 200) {
throw new CrossSeedError(
`qBittorrent login failed with code ${response.status}`,
);
}
this.cookie = response.headers.getSetCookie()[0];
if (!this.cookie) {
throw new CrossSeedError(
`qBittorrent login failed: Invalid username or password`,
);
}
}
async validateConfig(): Promise<void> {
await this.login();
await this.createTag();
}
private async request(
path: string,
body: BodyInit,
headers: Record<string, string> = {},
retries = 3,
): Promise<string> {
logger.verbose({
label: Label.QBITTORRENT,
message: `Making request (${retries}) to ${path} with body ${body!.toString()}`,
});
let response: Response = new Response();
try {
response = await fetch(`${this.url.href}${path}`, {
method: "post",
headers: { Cookie: this.cookie, ...headers },
body,
});
if (response.status === 403 && retries > 0) {
logger.verbose({
label: Label.QBITTORRENT,
message:
"Received 403 from API. Logging in again and retrying",
});
await this.login();
return this.request(path, body, headers, retries - 1);
}
} catch (e) {
logger.verbose({
label: Label.QBITTORRENT,
message: `Request failed: ${e.message}`,
});
if (retries > 0) {
return this.request(path, body, headers, retries - 1);
}
}
return response.text();
}
private getTagsForNewTorrent(
searchee: Searchee,
injectionConfiguration: TorrentConfiguration,
): string {
const { duplicateCategories } = getRuntimeConfig();
const { category } = injectionConfiguration;
if (!category) {
return TORRENT_TAG;
}
if (category.endsWith(TORRENT_CATEGORY_SUFFIX)) {
if (duplicateCategories) {
return `${category},${TORRENT_TAG}`;
} else {
return `${TORRENT_TAG}`;
}
}
if (searchee.path) {
return `${TORRENT_TAG}-data,${TORRENT_TAG}`;
} else if (duplicateCategories) {
return `${category}${suffix},${TORRENT_TAG}`;
} else {
return `${TORRENT_TAG}`;
}
}
async createTag(): Promise<void> {
await this.request(
"/torrents/createTags",
`tags=${TORRENT_TAG}`,
X_WWW_FORM_URLENCODED,
);
}
async isInfoHashInClient(infoHash: string): Promise<boolean> {
const torrent = await this.getTorrentInfo(infoHash);
return torrent.length > 0;
}
async addTorrent(formData: FormData): Promise<void> {
await this.request("/torrents/add", formData);
}
async recheckTorrent(infoHash: string): Promise<void> {
const torrent = await this.getTorrentInfo(infoHash);
if (torrent.length === 0) {
throw new Error("Torrent not found in client");
}
await this.request(
"/torrents/recheck",
`hashes=${torrent[0].hash}`,
X_WWW_FORM_URLENCODED,
);
}
/*
@param searchee the Searchee we are generating off (in client)
@return either a string containing the path or a error mesage
*/
async getDownloadDir(
searchee: SearcheeWithInfoHash,
): Promise<
Result<string, "NOT_FOUND" | "TORRENT_NOT_COMPLETE" | "UNKNOWN_ERROR">
> {
try {
const result = await this.getTorrentInfo(searchee.infoHash);
if (result.length === 0) {
return resultOfErr("NOT_FOUND");
}
const torrentInfo = result[0];
const savePath = await this.generateCorrectSavePath(
searchee,
torrentInfo,
);
return resultOf(savePath);
} catch (e) {
if (e.message.includes("retrieve")) {
return resultOfErr("NOT_FOUND");
}
return resultOfErr("UNKNOWN_ERROR");
}
}
/*
@param searchee the Searchee we are generating off (in client)
@param subfolderLayout whether it is subfolder layout or not
@return string absolute location from client with content layout considered
*/
async generateCorrectSavePath(
searchee: Searchee,
torrentInfo: TorrentInfo,
): Promise<string> {
const subfolderContentLayout = await this.isSubfolderContentLayout(
searchee,
torrentInfo,
);
if (subfolderContentLayout) {
return dirname(torrentInfo.content_path);
}
return torrentInfo.save_path;
}
/*
@return array of query results
*/
async getAllTorrentInfo(): Promise<TorrentInfo[]> {
const responseText = await this.request("/torrents/info", "");
return JSON.parse(responseText);
}
/*
@param hash the hash of the torrent or undefined for all torrents
@return array of query results
*/
async getTorrentInfo(hash: string): Promise<TorrentInfo[]> {
const torrents = await this.getAllTorrentInfo();
return torrents.filter(
(torrent) =>
hash === torrent.hash ||
hash === torrent.infohash_v1 ||
hash === torrent.infohash_v2,
);
}
/*
@param searchee the searchee we are querying about
@return object with save_path, autoTMM, isComplete, and category from qBit
*/
async getTorrentConfiguration(
searchee: SearcheeWithInfoHash,
): Promise<TorrentConfiguration> {
const searchResult = await this.getTorrentInfo(searchee.infoHash);
if (searchResult.length === 0) {
throw new Error(
"Failed to retrieve data dir; torrent not found in client",
);
}
const { save_path, state, auto_tmm, category } = searchResult[0];
const isComplete = [
"uploading",
"pausedUP",
"stoppedUP",
"queuedUP",
"stalledUP",
"checkingUP",
"forcedUP",
].includes(state);
return {
save_path,
isComplete: isComplete,
autoTMM: auto_tmm,
category,
};
}
async isSubfolderContentLayout(
searchee: Searchee,
torrentInfo: TorrentInfo,
): Promise<boolean> {
if (searchee.files.length > 1) return false;
if (dirname(searchee.files[0].path) !== ".") return false;
return dirname(torrentInfo.content_path) !== torrentInfo.save_path;
}
async inject(
newTorrent: Metafile,
searchee: Searchee,
decision:
| Decision.MATCH
| Decision.MATCH_SIZE_ONLY
| Decision.MATCH_PARTIAL,
path?: string,
): Promise<InjectionResult> {
const { flatLinking, linkCategory } = getRuntimeConfig();
try {
if (await this.isInfoHashInClient(newTorrent.infoHash)) {
return InjectionResult.ALREADY_EXISTS;
}
const filename = `${newTorrent.getFileSystemSafeName()}.${TORRENT_TAG}.torrent`;
const buffer = new Blob([newTorrent.encode()], {
type: "application/x-bittorrent",
});
const { save_path, isComplete, autoTMM, category } = path
? {
save_path: path,
isComplete: true,
autoTMM: false,
category: linkCategory,
}
: await this.getTorrentConfiguration(
searchee as SearcheeWithInfoHash,
);
const tags = this.getTagsForNewTorrent(searchee, {
save_path,
isComplete,
autoTMM,
category,
});
if (!isComplete) return InjectionResult.TORRENT_NOT_COMPLETE;
const contentLayout = path
? "Original"
: (await this.isSubfolderContentLayout(
searchee,
(await this.getTorrentInfo(searchee.infoHash!))[0],
))
? "Subfolder"
: "Original";
const formData = new FormData();
formData.append("torrents", buffer, filename);
if (path) {
// we were provided a path, so set it
formData.append("savepath", save_path);
}
formData.append(
"autoTMM",
flatLinking && searchee.infoHash ? autoTMM.toString() : "false",
);
formData.append("contentLayout", path ? "Original" : contentLayout);
formData.append("category", category);
formData.append("tags", tags);
const skipRecheck = determineSkipRecheck(decision);
formData.append("skip_checking", skipRecheck.toString());
formData.append("paused", !skipRecheck.toString());
// for some reason the parser parses the last kv pair incorrectly
// it concats the value and the sentinel
formData.append("foo", "bar");
await this.addTorrent(formData);
//if we have a linked file and skiprecheck is false
if (!skipRecheck) {
await new Promise((resolve) => setTimeout(resolve, 100));
await this.recheckTorrent(newTorrent.infoHash);
}
return InjectionResult.SUCCESS;
} catch (e) {
logger.debug({
label: Label.QBITTORRENT,
message: `injection failed: ${e.message}`,
});
return InjectionResult.FAILURE;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment