UNPKG

@ctrl/qbittorrent

Version:

TypeScript api wrapper for qbittorrent using got

730 lines (729 loc) 27.1 kB
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; }