UNPKG

@innei/qbittorrent-browser

Version:

TypeScript api wrapper for qbittorrent using got

771 lines (770 loc) 28.3 kB
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}`); } if (options.responseType === "text") { return (await response.text()); } return (await response.json()); } 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, 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} * 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 = {}, isJson = true) { 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, responseType: isJson ? "json" : "text", 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; }