diff --git a/README.md b/README.md index e9361e2..6577371 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ It supports the following BitTorrent clients: - Deluge - Elementum - Transmission -- Porla (new!) +- Porla - Tixati - QNAP DownloadStation +- rqbit (new!) ## How do i get it running? diff --git a/src/models/clients.ts b/src/models/clients.ts index 2750f8f..f7616cd 100644 --- a/src/models/clients.ts +++ b/src/models/clients.ts @@ -10,6 +10,7 @@ import { PorlaWebUI } from "../webuis/porla-webui"; import { TixatiWebUI } from "../webuis/tixati-webui"; import { TTorrentWebUI } from "../webuis/ttorrent-webui"; import { QNAPDownloadStationWebUI } from "../webuis/qnapdownloadstation-webui"; +import { RqbitWebUI } from "../webuis/rqbit-webui"; /** * Stable, opaque identifiers for each supported client. These values are @@ -30,6 +31,7 @@ export enum Client { TTorrentWebUI = "ttorrent", PorlaWebUI = "porla", QNAPDownloadStationWebUI = "qnap-download-station", + RqbitWebUI = "rqbit", } /** @@ -48,6 +50,7 @@ export const ClientDisplayName: Record = { [Client.TTorrentWebUI]: "tTorrent", [Client.PorlaWebUI]: "Porla", [Client.QNAPDownloadStationWebUI]: "QNAP Download Station", + [Client.RqbitWebUI]: "rqbit", }; type ConcreteTorrentWebUIConstructor = new (settings: WebUISettings) => TorrentWebUI; @@ -64,6 +67,7 @@ export const ClientClassByClient: Record { + return new Promise((resolve, reject) => { + const url = this.createBaseUrl() + "/torrents" + this.createQueryString(config); + const fetchOpts = this.createTorrentFetchOptions(torrent); + this.sendRequest(url, fetchOpts, resolve, reject); + }); + } + + private createQueryString(config: TorrentUploadConfig): string { + const params = new URLSearchParams(); + + const dir = this.getDirectory(config); + if (dir) { + params.append("output_folder", dir); + } + + const addPaused = this.getAddPaused(config); + if (addPaused !== null) { + params.append("paused", addPaused.toString()); + } + + const query = params.toString(); + return query ? `?${query}` : ""; + } + + private createTorrentFetchOptions(torrent: Torrent): RequestInit { + const fetchOpts: RequestInit = { + method: "POST", + headers: this.createAuthHeaders(), + }; + + // rqbit accepts the magnet/URL as a plain-text body or the raw .torrent + // file bytes, and decides how to handle it by inspecting the payload. + fetchOpts.body = torrent.isMagnet ? (torrent.data as string) : (torrent.data as Blob); + + return fetchOpts; + } + + private createAuthHeaders(): Record { + const headers: Record = {}; + if (this._settings.username || this._settings.password) { + headers["Authorization"] = "Basic " + btoa(`${this._settings.username}:${this._settings.password}`); + } + return headers; + } + + private sendRequest(url: string, fetchOpts: RequestInit, resolve: (result: TorrentAddingResult) => void, reject: (error: TorrentAddingResult) => void): void { + this.fetch(url, fetchOpts).then(async (response) => { + const responseBody = await response.text(); + resolve({ success: true, httpResponseCode: response.status, httpResponseBody: responseBody }); + }).catch(error => { + reject({ success: false, httpResponseCode: 0, httpResponseBody: error.message || null }); + }); + } + + get isLabelSupported(): boolean { + return false; + } + + get isDirSupported(): boolean { + return true; + } + + get isAddPausedSupported(): boolean { + return true; + } +} diff --git a/test/webuis/rqbit-webui.test.ts b/test/webuis/rqbit-webui.test.ts new file mode 100644 index 0000000..429dfeb --- /dev/null +++ b/test/webuis/rqbit-webui.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { RqbitWebUI } from "../../src/webuis/rqbit-webui"; +import { makeWebUISettings, makeMagnetTorrent, makeFileTorrent } from "../helpers/fixtures"; +import { mockResponse, queueFetch } from "../helpers/fetch-mock"; + +const build = (over = {}) => new RqbitWebUI(makeWebUISettings({ host: "h", port: 1337, ...over })); +const addOk = () => mockResponse({ status: 200, json: { id: 0 } }); + +describe("RqbitWebUI", () => { + it("posts a magnet body to /torrents with output_folder/paused and Basic auth", async () => { + const fetch = queueFetch(addOk()); + const result = await build().sendTorrent(makeMagnetTorrent(), { dir: "/movies", addPaused: true }); + + expect(result.success).toBe(true); + const [url, opts] = fetch.mock.calls[0]; + expect(url).toBe("http://h:1337/torrents?output_folder=%2Fmovies&paused=true"); + expect(opts.method).toBe("POST"); + expect(opts.body).toBe("magnet:?xt=urn:btih:abc123&dn=Cool+Torrent"); + expect((opts.headers as any).Authorization).toBe("Basic " + btoa("user:pass")); + }); + + it("sends the raw .torrent blob as the body for file torrents", async () => { + const fetch = queueFetch(addOk()); + const torrent = makeFileTorrent(); + await build().sendTorrent(torrent, {}); + + const [, opts] = fetch.mock.calls[0]; + expect(opts.body).toBe(torrent.data); + }); + + it("omits output_folder when no directory is set but always sends paused", async () => { + const fetch = queueFetch(addOk()); + await build().sendTorrent(makeMagnetTorrent(), {}); + + expect(fetch.mock.calls[0][0]).toBe("http://h:1337/torrents?paused=false"); + }); + + it("omits the Authorization header when no credentials are configured", async () => { + const fetch = queueFetch(addOk()); + await build({ username: "", password: "" }).sendTorrent(makeMagnetTorrent(), {}); + + expect((fetch.mock.calls[0][1].headers as any).Authorization).toBeUndefined(); + }); + + it("rejects when the request fails", async () => { + queueFetch(mockResponse({ status: 401, body: "unauthorized" })); + await expect(build().sendTorrent(makeMagnetTorrent(), {})).rejects.toMatchObject({ success: false }); + }); + + it("supports dirs and add-paused only", () => { + const ui = build(); + expect(ui.isLabelSupported).toBe(false); + expect(ui.isDirSupported).toBe(true); + expect(ui.isAddPausedSupported).toBe(true); + }); +});