@ctrl/qbittorrent
Version:
TypeScript api wrapper for qbittorrent using got
730 lines (729 loc) • 27.1 kB
JavaScript
import { parse as cookieParse } from 'cookie';
import { FormData } from 'node-fetch-native';
import { ofetch } from 'ofetch';
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,
};
export class QBittorrent {
/**
* Create a new QBittorrent client from a state
*/
static createFromState(config, state) {
const client = new QBittorrent(config);
client.state = {
...state,
auth: state.auth ? { ...state.auth, expires: new Date(state.auth.expires) } : undefined,
};
return client;
}
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, false);
return res;
}
async getApiVersion() {
const res = await this.request('/app/webapiVersion', 'GET', undefined, undefined, undefined, false);
return res;
}
/**
* Get default save path
*/
async getDefaultSavePath() {
const res = await this.request('/app/defaultSavePath', 'GET', undefined, undefined, undefined, false);
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, false);
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, false);
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, false);
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, false);
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, false);
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, false);
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, false);
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, false);
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, false);
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, false);
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, false);
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, false);
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, false);
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}
*/
async login() {
const url = joinURL(this.config.baseUrl, this.config.path ?? '', '/auth/login');
const res = await ofetch.raw(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',
retry: false,
timeout: this.config.timeout,
dispatcher: this.config.dispatcher,
});
if (!res.headers.get('set-cookie')?.length) {
throw new Error('Cookie not found. Auth Failed.');
}
const cookie = cookieParse(res.headers.get('set-cookie') ?? '');
if (!cookie.SID) {
throw new Error('Invalid cookie');
}
const expires = cookie.Expires ?? cookie.expires;
const maxAge = cookie['Max-Age'] ?? cookie['max-age'];
this.state.auth = {
sid: cookie.SID,
expires: expires
? new Date(expires)
: maxAge
? new Date(Number(maxAge) * 1000)
: new Date(Date.now() + 3600000),
};
// Check version after successful login
await this.checkVersion();
return true;
}
logout() {
delete this.state.auth;
return true;
}
// eslint-disable-next-line max-params
async request(path, method, params, body, headers = {}, isJson = true) {
if (!this.state.auth?.sid ||
!this.state.auth.expires ||
this.state.auth.expires.getTime() < new Date().getTime()) {
const authed = await this.login();
if (!authed) {
throw new Error('Auth Failed');
}
}
const url = joinURL(this.config.baseUrl, this.config.path ?? '', path);
const res = await ofetch(url, {
method,
headers: {
Cookie: `SID=${this.state.auth.sid ?? ''}`,
...headers,
},
body,
params,
retry: 0,
timeout: this.config.timeout,
// casting to json to avoid type error
responseType: isJson ? 'json' : 'text',
// allow proxy agent
dispatcher: this.config.dispatcher,
});
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;
}