Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
4 changes: 4 additions & 0 deletions src/models/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +31,7 @@ export enum Client {
TTorrentWebUI = "ttorrent",
PorlaWebUI = "porla",
QNAPDownloadStationWebUI = "qnap-download-station",
RqbitWebUI = "rqbit",
}

/**
Expand All @@ -48,6 +50,7 @@ export const ClientDisplayName: Record<Client, string> = {
[Client.TTorrentWebUI]: "tTorrent",
[Client.PorlaWebUI]: "Porla",
[Client.QNAPDownloadStationWebUI]: "QNAP Download Station",
[Client.RqbitWebUI]: "rqbit",
};

type ConcreteTorrentWebUIConstructor = new (settings: WebUISettings) => TorrentWebUI;
Expand All @@ -64,6 +67,7 @@ export const ClientClassByClient: Record<Client, ConcreteTorrentWebUIConstructor
[Client.TTorrentWebUI]: TTorrentWebUI,
[Client.PorlaWebUI]: PorlaWebUI,
[Client.QNAPDownloadStationWebUI]: QNAPDownloadStationWebUI,
[Client.RqbitWebUI]: RqbitWebUI,
};

export class WebUIFactory {
Expand Down
71 changes: 71 additions & 0 deletions src/webuis/rqbit-webui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Torrent, TorrentUploadConfig } from "../models/torrent";
import { TorrentAddingResult, TorrentWebUI } from "../models/webui";

export class RqbitWebUI extends TorrentWebUI {
public override async sendTorrent(torrent: Torrent, config: TorrentUploadConfig): Promise<TorrentAddingResult> {
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<string, string> {
const headers: Record<string, string> = {};
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;
}
}
56 changes: 56 additions & 0 deletions test/webuis/rqbit-webui.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});