@innei/qbittorrent-browser
Version:
TypeScript api wrapper for qbittorrent using got
780 lines (779 loc) • 28.4 kB
JavaScript
import { joinURL } from "ufo";
import { base64ToUint8Array, isUint8Array, stringToUint8Array, } from "uint8array-extras";
import { magnetDecode } from "@ctrl/magnet-link";
import { hash } from "@ctrl/torrent-file";
import { normalizeTorrentData } from "./normalizeTorrentData.js";
const defaults = {
baseUrl: "http://localhost:9091/",
path: "/api/v2",
username: "",
password: "",
timeout: 5000,
fetch,
};
/**
* Fetch adapter to replicate ofetch.raw() functionality
*/
async function fetchRaw(url, options, customFetch = fetch) {
const controller = new AbortController();
const timeoutId = options.timeout
? setTimeout(() => controller.abort(), options.timeout)
: null;
try {
const response = await customFetch(url, {
method: options.method,
headers: options.headers,
body: options.body,
redirect: options.redirect,
signal: controller.signal,
});
return {
status: response.status,
ok: response.ok,
};
}
finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
/**
* Fetch adapter to replicate ofetch() functionality
*/
async function fetchJson(url, options, customFetch = fetch) {
const controller = new AbortController();
const timeoutId = options.timeout
? setTimeout(() => controller.abort(), options.timeout)
: null;
try {
let finalUrl = url;
if (options.params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(options.params)) {
searchParams.append(key, value.toString());
}
finalUrl = `${url}?${searchParams.toString()}`;
}
const response = await customFetch(finalUrl, {
method: options.method,
headers: options.headers,
body: options.body,
signal: controller.signal,
credentials: "include",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const isJSON = response.headers
.get("Content-Type")
?.includes("application/json");
try {
const clonedResponse = response.clone();
if (isJSON) {
return (await clonedResponse.json());
}
return (await clonedResponse.text());
}
catch (error) {
console.error(error);
return (await response.text());
}
}
finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
export class QBittorrent {
config;
state = {};
constructor(options = {}) {
this.config = { ...defaults, ...options };
}
/**
* Export the state of the client as JSON
*/
exportState() {
return JSON.parse(JSON.stringify(this.state));
}
/**
* @deprecated
*/
async version() {
return this.getAppVersion();
}
/**
* Get application version
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-application-version}
*/
async getAppVersion() {
const res = await this.request("/app/version", "GET", undefined, undefined, undefined);
return res;
}
async getApiVersion() {
const res = await this.request("/app/webapiVersion", "GET", undefined, undefined, undefined);
return res;
}
/**
* Get default save path
*/
async getDefaultSavePath() {
const res = await this.request("/app/defaultSavePath", "GET", undefined, undefined, undefined);
return res;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-build-info}
*/
async getBuildInfo() {
const res = await this.request("/app/buildInfo", "GET");
return res;
}
async getTorrent(hash) {
const torrentsResponse = await this.listTorrents({ hashes: hash });
const torrentData = torrentsResponse[0];
if (!torrentData) {
throw new Error("Torrent not found");
}
return normalizeTorrentData(torrentData);
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-download-limit}
*/
async getTorrentDownloadLimit(hash) {
const downloadLimit = await this.request("/torrents/downloadLimit", "POST", undefined, objToUrlSearchParams({
hashes: normalizeHashes(hash),
}), undefined);
return downloadLimit;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#set-torrent-download-limit}
*/
async setTorrentDownloadLimit(hash, limitBytesPerSecond) {
const data = {
limit: limitBytesPerSecond.toString(),
hashes: normalizeHashes(hash),
};
await this.request("/torrents/setDownloadLimit", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-upload-limit}
*/
async getTorrentUploadLimit(hash) {
const UploadLimit = await this.request("/torrents/uploadLimit", "POST", undefined, objToUrlSearchParams({
hashes: normalizeHashes(hash),
}), undefined);
return UploadLimit;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#set-torrent-upload-limit}
*/
async setTorrentUploadLimit(hash, limitBytesPerSecond) {
const data = {
limit: limitBytesPerSecond.toString(),
hashes: normalizeHashes(hash),
};
await this.request("/torrents/setUploadLimit", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-application-preferences}
*/
async getPreferences() {
const res = await this.request("/app/preferences", "GET");
return res;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#set-application-preferences}
*/
async setPreferences(preferences) {
await this.request("/app/setPreferences", "POST", undefined, objToUrlSearchParams({
json: JSON.stringify(preferences),
}), {});
return true;
}
/**
* Torrents list
* @param hashes Filter by torrent hashes
* @param [filter] Filter torrent list
* @param category Get torrents with the given category (empty string means "without category"; no "category" parameter means "any category")
* @returns list of torrents
*/
async listTorrents({ hashes, filter, category, sort, offset, reverse, tag, limit, isPrivate, includeTrackers, } = {}) {
const params = {};
if (hashes) {
params.hashes = normalizeHashes(hashes);
}
if (filter) {
if (this.state.version?.isVersion5OrHigher) {
if (filter === "paused") {
filter = "stopped";
}
else if (filter === "resumed") {
filter = "running";
}
}
else if (filter === "stopped") {
// For versions < 5
filter = "paused";
}
else if (filter === "running") {
// For versions < 5
filter = "resumed";
}
params.filter = filter;
}
if (category !== undefined) {
params.category = category;
}
if (tag !== undefined) {
params.tag = tag;
}
if (offset !== undefined) {
params.offset = `${offset}`;
}
if (limit !== undefined) {
params.limit = `${limit}`;
}
if (sort) {
params.sort = sort;
}
if (reverse) {
params.reverse = JSON.stringify(reverse);
}
if (isPrivate) {
params.private = JSON.stringify(isPrivate);
}
if (includeTrackers) {
params.includeTrackers = JSON.stringify(includeTrackers);
}
const res = await this.request("/torrents/info", "GET", params);
return res;
}
async getAllData() {
const listTorrents = await this.listTorrents();
const results = {
torrents: [],
labels: [],
raw: listTorrents,
};
const labels = {};
for (const torrent of listTorrents) {
const torrentData = normalizeTorrentData(torrent);
results.torrents.push(torrentData);
// setup label
if (torrentData.label) {
if (labels[torrentData.label] === undefined) {
labels[torrentData.label] = {
id: torrentData.label,
name: torrentData.label,
count: 1,
};
}
else {
labels[torrentData.label].count += 1;
}
}
}
return results;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-generic-properties}
*/
async torrentProperties(hash) {
const res = await this.request("/torrents/properties", "GET", { hash });
return res;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-trackers}
*/
async torrentTrackers(hash) {
const res = await this.request("/torrents/trackers", "GET", { hash });
return res;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-web-seeds}
*/
async torrentWebSeeds(hash) {
const res = await this.request("/torrents/webseeds", "GET", {
hash,
});
return res;
}
async torrentFiles(hash) {
const res = await this.request("/torrents/files", "GET", {
hash,
});
return res;
}
async setFilePriority(hash, fileIds, priority) {
await this.request("/torrents/filePrio", "POST", undefined, objToUrlSearchParams({
hash,
id: normalizeHashes(fileIds),
priority: priority.toString(),
}), undefined);
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-pieces-states}
*/
async torrentPieceStates(hash) {
const res = await this.request("/torrents/pieceStates", "GET", { hash });
return res;
}
/**
* Torrents piece hashes
* @returns an array of hashes (strings) of all pieces (in order) of a specific torrent
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-pieces-hashes}
*/
async torrentPieceHashes(hash) {
const res = await this.request("/torrents/pieceHashes", "GET", {
hash,
});
return res;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#set-torrent-location}
*/
async setTorrentLocation(hashes, location) {
const data = {
location,
hashes: normalizeHashes(hashes),
};
await this.request("/torrents/setLocation", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#set-torrent-name}
*/
async setTorrentName(hash, name) {
const data = { hash, name };
await this.request("/torrents/rename", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-all-tags}
*/
async getTags() {
const res = await this.request("/torrents/tags", "GET");
return res;
}
/**
* @param tags comma separated list
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#create-tags}
*/
async createTags(tags) {
const data = { tags };
await this.request("/torrents/createTags", "POST", undefined, objToUrlSearchParams(data), undefined);
return true;
}
/**
* @param tags comma separated list
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#delete-tags}
*/
async deleteTags(tags) {
const data = { tags };
await this.request("/torrents/deleteTags", "POST", undefined, objToUrlSearchParams(data), undefined);
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-all-categories}
*/
async getCategories() {
const res = await this.request("/torrents/categories", "GET");
return res;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#add-new-category}
*/
async createCategory(category, savePath = "") {
const data = { category, savePath };
await this.request("/torrents/createCategory", "POST", undefined, objToUrlSearchParams(data), undefined);
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#edit-category}
*/
async editCategory(category, savePath = "") {
const data = { category, savePath };
await this.request("/torrents/editCategory", "POST", undefined, objToUrlSearchParams(data), undefined);
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#remove-categories}
*/
async removeCategory(categories) {
const data = { categories };
await this.request("/torrents/removeCategories", "POST", undefined, objToUrlSearchParams(data), undefined);
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#add-torrent-tags}
*/
async addTorrentTags(hashes, tags) {
const data = { hashes: normalizeHashes(hashes), tags };
await this.request("/torrents/addTags", "POST", undefined, objToUrlSearchParams(data), undefined);
return true;
}
/**
* if tags are not passed, removes all tags
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#remove-torrent-tags}
*/
async removeTorrentTags(hashes, tags) {
const data = { hashes: normalizeHashes(hashes) };
if (tags) {
data.tags = tags;
}
await this.request("/torrents/removeTags", "POST", undefined, objToUrlSearchParams(data), undefined);
return true;
}
/**
* helper function to remove torrent category
*/
async resetTorrentCategory(hashes) {
return this.setTorrentCategory(hashes);
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#set-torrent-category}
*/
async setTorrentCategory(hashes, category = "") {
const data = {
hashes: normalizeHashes(hashes),
category,
};
await this.request("/torrents/setCategory", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#pause-torrents}
*/
async stopTorrent(hashes) {
const endpoint = this.state.version?.isVersion5OrHigher
? "/torrents/stop"
: "/torrents/pause";
const data = { hashes: normalizeHashes(hashes) };
await this.request(endpoint, "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* @deprecated Alias for {@link stopTorrent}.
*/
async pauseTorrent(hashes) {
return this.stopTorrent(hashes);
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#resume-torrents}
*/
async startTorrent(hashes) {
const endpoint = this.state.version?.isVersion5OrHigher
? "/torrents/start"
: "/torrents/resume";
const data = { hashes: normalizeHashes(hashes) };
await this.request(endpoint, "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* @deprecated Alias for {@link startTorrent}.
*/
async resumeTorrent(hashes) {
return this.startTorrent(hashes);
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#delete-torrents}
* @param deleteFiles (default: false) remove files from disk
*/
async removeTorrent(hashes, deleteFiles = false) {
const data = {
hashes: normalizeHashes(hashes),
deleteFiles,
};
await this.request("/torrents/delete", "POST", undefined, objToUrlSearchParams(data), undefined);
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#recheck-torrents}
*/
async recheckTorrent(hashes) {
const data = { hashes: normalizeHashes(hashes) };
await this.request("/torrents/recheck", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#reannounce-torrents}
*/
async reannounceTorrent(hashes) {
const data = { hashes: normalizeHashes(hashes) };
await this.request("/torrents/reannounce", "POST", undefined, objToUrlSearchParams(data));
return true;
}
async addTorrent(torrent, options = {}) {
const form = new FormData();
// remove options.filename, not used in form
if (options.filename) {
delete options.filename;
}
const type = { type: "application/x-bittorrent" };
if (typeof torrent === "string") {
form.set("file", new File([base64ToUint8Array(torrent)], "file.torrent", type));
}
else {
const file = new File([torrent], options.filename ?? "torrent", type);
form.set("file", file);
}
if (options) {
// Handle version-specific paused/stopped parameter
if (this.state.version?.isVersion5OrHigher && "paused" in options) {
form.append("stopped", options.paused);
delete options.paused;
}
// disable savepath when autoTMM is defined
if (options.useAutoTMM === "true") {
options.savepath = "";
}
else {
options.useAutoTMM = "false";
}
for (const [key, value] of Object.entries(options)) {
form.append(key, `${value}`);
}
}
const res = await this.request("/torrents/add", "POST", undefined, form, undefined);
if (res === "Fails.") {
throw new Error("Failed to add torrent");
}
return true;
}
async normalizedAddTorrent(torrent, options = {}) {
const torrentOptions = {};
if (options.startPaused) {
torrentOptions.paused = "true";
}
if (options.label) {
torrentOptions.category = options.label;
}
let torrentHash;
if (typeof torrent === "string" && torrent.startsWith("magnet:")) {
torrentHash = magnetDecode(torrent).infoHash;
if (!torrentHash) {
throw new Error("Magnet did not contain hash");
}
await this.addMagnet(torrent, torrentOptions);
}
else {
if (!isUint8Array(torrent)) {
torrent = stringToUint8Array(torrent);
}
torrentHash = hash(torrent);
await this.addTorrent(torrent, torrentOptions);
}
return this.getTorrent(torrentHash);
}
/**
* @param hash Hash for desired torrent
* @param oldPath id of the file to be renamed
* @param newPath new name to be assigned to the file
*/
async renameFile(hash, oldPath, newPath) {
await this.request("/torrents/renameFile", "POST", undefined, objToUrlSearchParams({
hash,
oldPath,
newPath,
}), undefined);
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#rename-folder}
*/
async renameFolder(hash, oldPath, newPath) {
await this.request("/torrents/renameFolder", "POST", undefined, objToUrlSearchParams({
hash,
oldPath,
newPath,
}), undefined);
return true;
}
/**
* @param urls URLs separated with newlines
* @param options
*/
async addMagnet(urls, options = {}) {
const form = new FormData();
form.append("urls", urls);
if (options) {
// Handle version-specific paused/stopped parameter
if (this.state.version?.isVersion5OrHigher && "paused" in options) {
form.append("stopped", options.paused);
delete options.paused;
}
// disable savepath when autoTMM is defined
if (options.useAutoTMM === "true") {
options.savepath = "";
}
else {
options.useAutoTMM = "false";
}
for (const [key, value] of Object.entries(options)) {
form.append(key, `${value}`);
}
}
const res = await this.request("/torrents/add", "POST", undefined, form, undefined);
if (res === "Fails.") {
throw new Error("Failed to add torrent");
}
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#add-trackers-to-torrent}
*/
async addTrackers(hash, urls) {
const data = { hash, urls };
await this.request("/torrents/addTrackers", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#edit-trackers}
*/
async editTrackers(hash, origUrl, newUrl) {
const data = { hash, origUrl, newUrl };
await this.request("/torrents/editTrackers", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#remove-trackers}
*/
async removeTrackers(hash, urls) {
const data = { hash, urls };
await this.request("/torrents/removeTrackers", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#increase-torrent-priority}
*/
async queueUp(hashes) {
const data = { hashes: normalizeHashes(hashes) };
await this.request("/torrents/increasePrio", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#decrease-torrent-priority}
*/
async queueDown(hashes) {
const data = { hashes: normalizeHashes(hashes) };
await this.request("/torrents/decreasePrio", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#maximal-torrent-priority}
*/
async topPriority(hashes) {
const data = { hashes: normalizeHashes(hashes) };
await this.request("/torrents/topPrio", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#minimal-torrent-priority}
*/
async bottomPriority(hashes) {
const data = { hashes: normalizeHashes(hashes) };
await this.request("/torrents/bottomPrio", "POST", undefined, objToUrlSearchParams(data));
return true;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-peers-data}
* @param rid - Response ID. If not provided, rid=0 will be assumed. If the given rid is
* different from the one of last server reply, full_update will be true (see the server reply details for more info)
*/
async torrentPeers(hash, rid) {
const params = { hash };
if (rid) {
params.rid = rid;
}
const res = await this.request("/sync/torrentPeers", "GET", params);
return res;
}
/**
* {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#login}
* Browser version: Uses 401/403 status codes for auth validation instead of cookie parsing
*/
async login() {
const url = joinURL(this.config.baseUrl, this.config.path ?? "", "/auth/login");
const res = await fetchRaw(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
username: this.config.username ?? "",
password: this.config.password ?? "",
}).toString(),
redirect: "manual",
timeout: this.config.timeout,
dispatcher: this.config.dispatcher,
}, this.config.fetch);
// In browser, we rely on the server setting httpOnly cookies automatically
// We validate auth success by checking if the response is OK (not 401/403)
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
throw new Error("Authentication failed. Invalid credentials.");
}
throw new Error(`Login failed with status ${res.status}`);
}
// Check version after successful login
await this.checkVersion();
return true;
}
async logout() {
await this.request("/auth/logout", "POST");
return true;
}
// eslint-disable-next-line max-params
async request(path, method, params, body, headers = {}) {
const url = joinURL(this.config.baseUrl, this.config.path ?? "", path);
const res = await fetchJson(url, {
method,
headers: {
// In browser, cookies are handled automatically by the browser
// No need to manually set Cookie header
...headers,
},
body,
params,
timeout: this.config.timeout,
dispatcher: this.config.dispatcher,
}, this.config.fetch);
return res;
}
async checkVersion() {
if (!this.state.version?.version) {
const newVersion = await this.getAppVersion();
// Remove potential 'v' prefix and any extra info after version number
const cleanVersion = newVersion.replace(/^v/, "").split("-")[0];
this.state.version = {
version: newVersion,
isVersion5OrHigher: cleanVersion === "5.0.0" || isGreater(cleanVersion, "5.0.0"),
};
}
}
}
/**
* Normalizes hashes
* @returns hashes as string seperated by `|`
*/
function normalizeHashes(hashes) {
if (Array.isArray(hashes)) {
return hashes.join("|");
}
return hashes;
}
function objToUrlSearchParams(obj) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(obj)) {
params.append(key, value.toString());
}
return params;
}
function isGreater(a, b) {
return a.localeCompare(b, undefined, { numeric: true }) === 1;
}