@ctrl/rqbit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Scott Cooper <scttcper@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # rqbit [![npm](https://img.shields.io/npm/v/@ctrl/rqbit.svg?maxAge=3600)](https://www.npmjs.com/package/@ctrl/rqbit)
2
+
3
+ > TypeScript api wrapper for [rqbit](https://github.com/ikatson/rqbit) using [ofetch](https://github.com/unjs/ofetch)
4
+
5
+ ### Install
6
+
7
+ ```sh
8
+ npm install @ctrl/rqbit
9
+ ```
10
+
11
+ ### Use
12
+
13
+ ```ts
14
+ import { Rqbit } from '@ctrl/rqbit';
15
+
16
+ const client = new Rqbit({
17
+ baseUrl: 'http://localhost:3030/',
18
+ // only needed when rqbit is started with RQBIT_HTTP_BASIC_AUTH_USERPASS
19
+ username: 'admin',
20
+ password: 'adminadmin',
21
+ });
22
+
23
+ async function main() {
24
+ const res = await client.getAllData();
25
+ console.log(res);
26
+ }
27
+ ```
28
+
29
+ ### API
30
+
31
+ Docs: https://rqbit.ep.workers.dev
32
+
33
+ rqbit has no published api reference, `GET /` on the server lists every endpoint (`client.getApiInfo()`). The types here are based on responses from rqbit 9.0.1 and its [http api source](https://github.com/ikatson/rqbit/tree/v9.0.1/crates/librqbit/src/http_api).
34
+
35
+ Things that work differently from the other clients:
36
+
37
+ - Adding a magnet does not respond until rqbit resolves the metadata from peers. Adds use `addTimeout` (default 60 seconds) instead of `timeout`.
38
+ - rqbit has no labels, queue, or added/completed dates. `label` is ignored, `queueUp`/`queueDown` throw, and `dateAdded` is an empty string.
39
+ - `createTorrent` needs rqbit started with `RQBIT_HTTP_API_ALLOW_CREATE=true`, and `getStreamUrl`/`getPlaylistUrl` urls need the same basic auth as the api.
40
+ - Speeds are reported in MiB/s and converted to bytes per second when normalized.
41
+ - Torrents are added with `overwrite: true` by default, like Radarr does. rqbit refuses to add a torrent whose files already exist on disk otherwise.
42
+ - Failed requests throw `RqbitApiError` with the http `status` and rqbit's error `kind`, ex - `torrent_not_found`. Timeouts and network errors are also `RqbitApiError` with no `status`.
43
+ - rqbit's `total_bytes` only counts the pieces of selected files. `getTorrent` uses the file list for `totalSize`, `getAllData` skips the extra request per torrent so its `totalSize` is the selected size.
44
+
45
+ ### Normalized API
46
+
47
+ These functions are normalized through [@ctrl/shared-torrent](https://github.com/scttcper/shared-torrent), which makes it easier to support multiple torrent clients. See [below](#see-also) for alternative supported torrent clients.
48
+
49
+ ##### getAllData
50
+
51
+ Returns all torrent data and an array of label objects. Data has been normalized and does not match the output of native `listTorrents()`. rqbit has no labels so labels is always empty.
52
+
53
+ ```ts
54
+ const data = await client.getAllData();
55
+ console.log(data.torrents);
56
+ ```
57
+
58
+ ##### getTorrent
59
+
60
+ Returns one torrent data from torrent hash
61
+
62
+ ```ts
63
+ const data = await client.getTorrent('torrent-hash');
64
+ console.log(data);
65
+ ```
66
+
67
+ ##### pauseTorrent and resumeTorrent
68
+
69
+ Pause or resume a torrent
70
+
71
+ ```ts
72
+ const paused = await client.pauseTorrent('torrent-hash');
73
+ console.log(paused);
74
+ const resumed = await client.resumeTorrent('torrent-hash');
75
+ console.log(resumed);
76
+ ```
77
+
78
+ ##### removeTorrent
79
+
80
+ Remove a torrent. Does not remove data on disk by default.
81
+
82
+ ```ts
83
+ // does not remove data on disk
84
+ const result = await client.removeTorrent('torrent-hash', false);
85
+ console.log(result);
86
+
87
+ // remove data on disk
88
+ const res = await client.removeTorrent('torrent-hash', true);
89
+ console.log(res);
90
+ ```
91
+
92
+ ##### addTorrent
93
+
94
+ Add a torrent from a magnet link or torrent file, has client specific options. Also see normalizedAddTorrent
95
+
96
+ ```ts
97
+ import { readFileSync } from 'node:fs';
98
+
99
+ const result = await client.addTorrent(new Uint8Array(readFileSync('./linux.torrent')), {
100
+ output_folder: '/downloads/linux',
101
+ });
102
+ console.log(result.details.info_hash);
103
+ ```
104
+
105
+ ##### normalizedAddTorrent
106
+
107
+ Add a torrent and return normalized torrent data. rqbit cannot add a torrent paused, `startPaused` pauses it right after adding.
108
+
109
+ ```ts
110
+ const result = await client.normalizedAddTorrent('magnet:?xt=urn:btih:...', {
111
+ startPaused: true,
112
+ });
113
+ console.log(result);
114
+ ```
115
+
116
+ ##### export and create from state
117
+
118
+ rqbit uses basic auth on every request so there is no session to save, this exists to match the other clients.
119
+
120
+ ```ts
121
+ const state = client.exportState();
122
+ const restored = Rqbit.createFromState(config, state);
123
+ ```
124
+
125
+ ### See Also
126
+
127
+ All of the following npm modules provide the same normalized functions along with supporting the unique apis for each client.
128
+
129
+ - shared types - [@ctrl/shared-torrent](https://github.com/scttcper/shared-torrent)
130
+ - deluge - [@ctrl/deluge](https://github.com/scttcper/deluge)
131
+ - transmission - [@ctrl/transmission](https://github.com/scttcper/transmission)
132
+ - qbittorrent - [@ctrl/qbittorrent](https://github.com/scttcper/qbittorrent)
133
+ - utorrent - [@ctrl/utorrent](https://github.com/scttcper/utorrent)
134
+ - rtorrent - [@ctrl/rtorrent](https://github.com/scttcper/rtorrent)
135
+
136
+ Usenet clients with the same normalized approach:
137
+
138
+ - usenet shared types - [@ctrl/shared-usenet](https://github.com/scttcper/shared-usenet)
139
+ - nzbget - [@ctrl/nzbget](https://github.com/scttcper/nzbget)
140
+ - sabnzbd - [@ctrl/sabnzbd](https://github.com/scttcper/sabnzbd)
141
+
142
+ ### Start a test docker container
143
+
144
+ ```
145
+ docker run -d \
146
+ --name=rqbit \
147
+ -e RQBIT_HTTP_BASIC_AUTH_USERPASS=admin:adminadmin \
148
+ -e RQBIT_HTTP_API_ALLOW_CREATE=true \
149
+ -p 3030:3030 \
150
+ -p 4240:4240 \
151
+ -v ~/Documents/rqbit/downloads:/home/rqbit/downloads \
152
+ --restart unless-stopped \
153
+ ikatson/rqbit:latest
154
+ ```
@@ -0,0 +1,3 @@
1
+ export * from './rqbit.js';
2
+ export * from './normalizeTorrentData.js';
3
+ export type * from './types.js';
@@ -0,0 +1,2 @@
1
+ export * from './rqbit.js';
2
+ export * from './normalizeTorrentData.js';
@@ -0,0 +1,11 @@
1
+ import { type NormalizedTorrent } from '@ctrl/shared-torrent';
2
+ import type { TorrentFile, TorrentWithStats } from './types.js';
3
+ /**
4
+ * Convert a torrent with stats into the shared normalized format.
5
+ * Speeds are MiB/s and the ETA is a `{ secs, nanos }` duration,
6
+ * see {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/torrent_state/stats.rs}
7
+ *
8
+ * @param files file list from the torrent details, used for `totalSize`.
9
+ * rqbit's `total_bytes` only counts the pieces of selected files, so without the file list `totalSize` is the selected size.
10
+ */
11
+ export declare function normalizeTorrentData(torrent: TorrentWithStats, files?: TorrentFile[]): NormalizedTorrent;
@@ -0,0 +1,65 @@
1
+ import { TorrentState } from '@ctrl/shared-torrent';
2
+ const MIB = 1024 * 1024;
3
+ /**
4
+ * Convert a torrent with stats into the shared normalized format.
5
+ * Speeds are MiB/s and the ETA is a `{ secs, nanos }` duration,
6
+ * see {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/torrent_state/stats.rs}
7
+ *
8
+ * @param files file list from the torrent details, used for `totalSize`.
9
+ * rqbit's `total_bytes` only counts the pieces of selected files, so without the file list `totalSize` is the selected size.
10
+ */
11
+ export function normalizeTorrentData(torrent, files) {
12
+ const { stats } = torrent;
13
+ const live = stats.live;
14
+ let state = TorrentState.unknown;
15
+ switch (stats.state) {
16
+ case 'initializing': {
17
+ // pausing during initialization keeps the torrent in initializing until it is started
18
+ state = stats.initializing_paused ? TorrentState.paused : TorrentState.checking;
19
+ break;
20
+ }
21
+ case 'live': {
22
+ state = stats.finished ? TorrentState.seeding : TorrentState.downloading;
23
+ break;
24
+ }
25
+ case 'paused': {
26
+ state = TorrentState.paused;
27
+ break;
28
+ }
29
+ case 'error': {
30
+ state = TorrentState.error;
31
+ break;
32
+ }
33
+ }
34
+ const progress = stats.total_bytes > 0 ? stats.progress_bytes / stats.total_bytes : 0;
35
+ const peerStats = live?.snapshot.peer_stats;
36
+ return {
37
+ id: torrent.info_hash,
38
+ name: torrent.name ?? torrent.info_hash,
39
+ state,
40
+ stateMessage: stats.error ?? '',
41
+ isCompleted: stats.finished,
42
+ progress,
43
+ ratio: stats.progress_bytes > 0 ? stats.uploaded_bytes / stats.progress_bytes : 0,
44
+ // rqbit does not track when a torrent was added or completed
45
+ dateAdded: '',
46
+ dateCompleted: undefined,
47
+ label: undefined,
48
+ savePath: torrent.output_folder,
49
+ uploadSpeed: live ? Math.round(live.upload_speed.mbps * MIB) : 0,
50
+ downloadSpeed: live ? Math.round(live.download_speed.mbps * MIB) : 0,
51
+ eta: live?.time_remaining?.duration.secs ?? 0,
52
+ // rqbit has no queue
53
+ queuePosition: 0,
54
+ // rqbit does not report seeds separately from peers
55
+ connectedPeers: peerStats?.live ?? 0,
56
+ connectedSeeds: 0,
57
+ totalPeers: peerStats?.seen ?? 0,
58
+ totalSeeds: 0,
59
+ totalSelected: stats.total_bytes,
60
+ totalSize: files ? files.reduce((sum, file) => sum + file.length, 0) : stats.total_bytes,
61
+ totalUploaded: stats.uploaded_bytes,
62
+ totalDownloaded: stats.progress_bytes,
63
+ raw: torrent,
64
+ };
65
+ }
@@ -0,0 +1,156 @@
1
+ import type { AddTorrentOptions as NormalizedAddTorrentOptions, AllClientData, NormalizedTorrent, TorrentClient, TorrentClientConfig, TorrentClientState } from '@ctrl/shared-torrent';
2
+ import { type FetchOptions, type MappedResponseType, type ResponseType } from 'ofetch';
3
+ import type { Jsonify } from 'type-fest';
4
+ import type { AddTorrentOptions, AddTorrentResponse, ApiRootResponse, CreateTorrentOptions, CreateTorrentResponse, DhtStats, EmptyResponse, ListTorrentsResponse, PeerStatsResponse, RateLimits, RqbitErrorResponse, SessionStats, TorrentDetails, TorrentIdOrHash, TorrentListItem, TorrentStats, TorrentWithStats } from './types.js';
5
+ /**
6
+ * rqbit uses basic auth on every request, there is no session to keep
7
+ */
8
+ export type RqbitState = TorrentClientState;
9
+ export interface RqbitConfig extends TorrentClientConfig {
10
+ /**
11
+ * How long to wait when adding a torrent, in milliseconds.
12
+ * rqbit does not respond to an add until magnet metadata is resolved from peers, so adds need much longer than `timeout`.
13
+ * default: 60_000
14
+ */
15
+ addTimeout?: number;
16
+ }
17
+ export type ResolvedConfig = RqbitConfig & {
18
+ path: string;
19
+ addTimeout: number;
20
+ };
21
+ /**
22
+ * Error thrown when a request to rqbit fails, either an error response or a timeout/network error
23
+ */
24
+ export declare class RqbitApiError extends Error {
25
+ name: string;
26
+ /**
27
+ * HTTP status code, undefined when no response was received (timeout or network error)
28
+ */
29
+ status?: number;
30
+ /**
31
+ * rqbit error kind, ex - `torrent_not_found`. Undefined when rqbit returned a plain text error.
32
+ */
33
+ kind?: string;
34
+ response?: RqbitErrorResponse;
35
+ constructor(status: number | undefined, message: string, response?: RqbitErrorResponse, cause?: unknown);
36
+ }
37
+ /**
38
+ * rqbit has no published api reference. `GET /` on the server lists every endpoint, see {@link Rqbit.getApiInfo}.
39
+ * Routes {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api/handlers/mod.rs}
40
+ * Response types {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/api.rs}
41
+ */
42
+ export declare class Rqbit implements TorrentClient {
43
+ static createFromState(config: Readonly<Partial<RqbitConfig>>, state: Readonly<Jsonify<RqbitState>>): Rqbit;
44
+ config: ResolvedConfig;
45
+ state: RqbitState;
46
+ constructor(options?: Partial<RqbitConfig>);
47
+ exportState(): Jsonify<RqbitState>;
48
+ /**
49
+ * Lists the available api endpoints and the rqbit version
50
+ */
51
+ getApiInfo(): Promise<ApiRootResponse>;
52
+ /**
53
+ * rqbit version, ex - `9.0.1`
54
+ */
55
+ getVersion(): Promise<string>;
56
+ /**
57
+ * Session wide download/upload speed, peer and connection stats
58
+ */
59
+ getSessionStats(): Promise<SessionStats>;
60
+ getDhtStats(): Promise<DhtStats>;
61
+ getRateLimits(): Promise<RateLimits>;
62
+ /**
63
+ * Set session wide rate limits in bytes per second, null removes the limit
64
+ */
65
+ setRateLimits(limits: RateLimits): Promise<EmptyResponse>;
66
+ listTorrents(withStats: true): Promise<ListTorrentsResponse<TorrentWithStats>>;
67
+ listTorrents(withStats?: false): Promise<ListTorrentsResponse<TorrentListItem>>;
68
+ /**
69
+ * Torrent details including the file list
70
+ */
71
+ getTorrentDetails(id: TorrentIdOrHash): Promise<TorrentDetails>;
72
+ getTorrentStats(id: TorrentIdOrHash): Promise<TorrentStats>;
73
+ /**
74
+ * Per peer stats, defaults to only live peers
75
+ */
76
+ getTorrentPeerStats(id: TorrentIdOrHash, state?: 'live' | 'all'): Promise<PeerStatsResponse>;
77
+ /**
78
+ * Download the .torrent file for a torrent
79
+ */
80
+ getTorrentMetadata(id: TorrentIdOrHash): Promise<Uint8Array<ArrayBuffer>>;
81
+ /**
82
+ * Url to stream a file from a torrent, supports range requests. rqbit requires basic auth on this url when it is enabled.
83
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api/handlers/streaming.rs}
84
+ * @param fileIndex index into the torrent's file list
85
+ */
86
+ getStreamUrl(id: TorrentIdOrHash, fileIndex: number): string;
87
+ /**
88
+ * Url to an m3u8 playlist of a torrent's playable files, or every torrent's when no id is passed.
89
+ * rqbit requires basic auth on this url when it is enabled.
90
+ */
91
+ getPlaylistUrl(id?: TorrentIdOrHash): string;
92
+ pauseTorrent(id: TorrentIdOrHash): Promise<EmptyResponse>;
93
+ resumeTorrent(id: TorrentIdOrHash): Promise<EmptyResponse>;
94
+ /**
95
+ * Remove a torrent
96
+ * @param removeData (default: false) If true, remove the downloaded files.
97
+ */
98
+ removeTorrent(id: TorrentIdOrHash, removeData?: boolean): Promise<EmptyResponse>;
99
+ /**
100
+ * Change which files are downloaded
101
+ * @param fileIds indexes into the torrent's file list
102
+ */
103
+ setTorrentFiles(id: TorrentIdOrHash, fileIds: number[]): Promise<EmptyResponse>;
104
+ /**
105
+ * Connect to additional peers
106
+ * @param peers ex - `['1.2.3.4:6881']`
107
+ */
108
+ addPeers(id: TorrentIdOrHash, peers: string[]): Promise<{
109
+ added: number;
110
+ }>;
111
+ /**
112
+ * rqbit does not support queueing
113
+ */
114
+ queueUp(_id: TorrentIdOrHash): Promise<never>;
115
+ /**
116
+ * rqbit does not support queueing
117
+ */
118
+ queueDown(_id: TorrentIdOrHash): Promise<never>;
119
+ /**
120
+ * Add a magnet link or a url to a .torrent file.
121
+ * rqbit resolves magnet metadata from peers before it responds, so this can take a while, see {@link RqbitConfig.addTimeout}
122
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api/handlers/torrents.rs}
123
+ */
124
+ addMagnet(url: string, options?: Partial<AddTorrentOptions>): Promise<AddTorrentResponse>;
125
+ /**
126
+ * Add a torrent
127
+ * @param torrent .torrent file contents, or the file contents as a base64 string. Magnet links and urls are passed to {@link Rqbit.addMagnet}
128
+ */
129
+ addTorrent(torrent: string | Uint8Array<ArrayBuffer>, options?: Partial<AddTorrentOptions>): Promise<AddTorrentResponse>;
130
+ /**
131
+ * Resolve a magnet link to .torrent file contents without adding it.
132
+ * Like adding, rqbit fetches the metadata from peers before it responds, see {@link RqbitConfig.addTimeout}
133
+ */
134
+ resolveMagnet(magnet: string, options?: Partial<Pick<AddTorrentOptions, 'timeout_ms'>>): Promise<Uint8Array<ArrayBuffer>>;
135
+ /**
136
+ * Create a torrent from a folder on the rqbit server and start seeding it.
137
+ * rqbit must be started with `--http-api-allow-create` or `RQBIT_HTTP_API_ALLOW_CREATE=true`.
138
+ * @param folder path to the folder on the rqbit server
139
+ */
140
+ createTorrent(folder: string, options?: Partial<CreateTorrentOptions>): Promise<CreateTorrentResponse>;
141
+ /**
142
+ * Add a torrent and return normalized torrent data.
143
+ * The add endpoint has no paused option, `startPaused` pauses the torrent after adding.
144
+ * rqbit does not support labels, `label` is ignored.
145
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api_types.rs}
146
+ */
147
+ normalizedAddTorrent(torrent: string | Uint8Array<ArrayBuffer>, options?: Partial<NormalizedAddTorrentOptions>): Promise<NormalizedTorrent>;
148
+ getTorrent(id: TorrentIdOrHash): Promise<NormalizedTorrent>;
149
+ /**
150
+ * rqbit does not support labels so labels is always empty
151
+ */
152
+ getAllData(): Promise<AllClientData>;
153
+ request<T, R extends ResponseType = 'json'>(path: string, options?: FetchOptions<R>): Promise<MappedResponseType<R, T>>;
154
+ private url;
155
+ private postTorrent;
156
+ }
@@ -0,0 +1,338 @@
1
+ import { FetchError, ofetch, } from 'ofetch';
2
+ import { joinURL } from 'ufo';
3
+ import { base64ToUint8Array, stringToBase64 } from 'uint8array-extras';
4
+ import { normalizeTorrentData } from './normalizeTorrentData.js';
5
+ const defaults = {
6
+ baseUrl: 'http://localhost:3030/',
7
+ path: '/',
8
+ username: '',
9
+ password: '',
10
+ timeout: 5000,
11
+ addTimeout: 60_000,
12
+ };
13
+ /**
14
+ * Error thrown when a request to rqbit fails, either an error response or a timeout/network error
15
+ */
16
+ export class RqbitApiError extends Error {
17
+ name = 'RqbitApiError';
18
+ /**
19
+ * HTTP status code, undefined when no response was received (timeout or network error)
20
+ */
21
+ status;
22
+ /**
23
+ * rqbit error kind, ex - `torrent_not_found`. Undefined when rqbit returned a plain text error.
24
+ */
25
+ kind;
26
+ response;
27
+ constructor(status, message, response, cause) {
28
+ super(message, { cause });
29
+ this.status = status;
30
+ this.kind = response?.error_kind;
31
+ this.response = response;
32
+ }
33
+ }
34
+ /**
35
+ * rqbit has no published api reference. `GET /` on the server lists every endpoint, see {@link Rqbit.getApiInfo}.
36
+ * Routes {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api/handlers/mod.rs}
37
+ * Response types {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/api.rs}
38
+ */
39
+ export class Rqbit {
40
+ static createFromState(config, state) {
41
+ const client = new Rqbit(config);
42
+ client.state = { ...state };
43
+ return client;
44
+ }
45
+ config;
46
+ state = {};
47
+ constructor(options = {}) {
48
+ this.config = { ...defaults, ...options };
49
+ }
50
+ exportState() {
51
+ return JSON.parse(JSON.stringify(this.state));
52
+ }
53
+ /**
54
+ * Lists the available api endpoints and the rqbit version
55
+ */
56
+ async getApiInfo() {
57
+ return this.request('/');
58
+ }
59
+ /**
60
+ * rqbit version, ex - `9.0.1`
61
+ */
62
+ async getVersion() {
63
+ const res = await this.getApiInfo();
64
+ return res.version;
65
+ }
66
+ /**
67
+ * Session wide download/upload speed, peer and connection stats
68
+ */
69
+ async getSessionStats() {
70
+ return this.request('/stats');
71
+ }
72
+ async getDhtStats() {
73
+ return this.request('/dht/stats');
74
+ }
75
+ async getRateLimits() {
76
+ return this.request('/torrents/limits');
77
+ }
78
+ /**
79
+ * Set session wide rate limits in bytes per second, null removes the limit
80
+ */
81
+ async setRateLimits(limits) {
82
+ return this.request('/torrents/limits', { method: 'POST', body: limits });
83
+ }
84
+ async listTorrents(withStats = false) {
85
+ return this.request('/torrents', {
86
+ query: withStats ? { with_stats: true } : undefined,
87
+ });
88
+ }
89
+ /**
90
+ * Torrent details including the file list
91
+ */
92
+ async getTorrentDetails(id) {
93
+ return this.request(`/torrents/${id}`);
94
+ }
95
+ async getTorrentStats(id) {
96
+ return this.request(`/torrents/${id}/stats/v1`);
97
+ }
98
+ /**
99
+ * Per peer stats, defaults to only live peers
100
+ */
101
+ async getTorrentPeerStats(id, state = 'live') {
102
+ return this.request(`/torrents/${id}/peer_stats`, { query: { state } });
103
+ }
104
+ /**
105
+ * Download the .torrent file for a torrent
106
+ */
107
+ async getTorrentMetadata(id) {
108
+ const res = await this.request(`/torrents/${id}/metadata`, {
109
+ responseType: 'arrayBuffer',
110
+ });
111
+ return new Uint8Array(res);
112
+ }
113
+ /**
114
+ * Url to stream a file from a torrent, supports range requests. rqbit requires basic auth on this url when it is enabled.
115
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api/handlers/streaming.rs}
116
+ * @param fileIndex index into the torrent's file list
117
+ */
118
+ getStreamUrl(id, fileIndex) {
119
+ return this.url(`/torrents/${id}/stream/${fileIndex}`);
120
+ }
121
+ /**
122
+ * Url to an m3u8 playlist of a torrent's playable files, or every torrent's when no id is passed.
123
+ * rqbit requires basic auth on this url when it is enabled.
124
+ */
125
+ getPlaylistUrl(id) {
126
+ return this.url(id === undefined ? '/torrents/playlist' : `/torrents/${id}/playlist`);
127
+ }
128
+ async pauseTorrent(id) {
129
+ return this.request(`/torrents/${id}/pause`, { method: 'POST' });
130
+ }
131
+ async resumeTorrent(id) {
132
+ return this.request(`/torrents/${id}/start`, { method: 'POST' });
133
+ }
134
+ /**
135
+ * Remove a torrent
136
+ * @param removeData (default: false) If true, remove the downloaded files.
137
+ */
138
+ async removeTorrent(id, removeData = false) {
139
+ const action = removeData ? 'delete' : 'forget';
140
+ return this.request(`/torrents/${id}/${action}`, { method: 'POST' });
141
+ }
142
+ /**
143
+ * Change which files are downloaded
144
+ * @param fileIds indexes into the torrent's file list
145
+ */
146
+ async setTorrentFiles(id, fileIds) {
147
+ return this.request(`/torrents/${id}/update_only_files`, {
148
+ method: 'POST',
149
+ body: { only_files: fileIds },
150
+ });
151
+ }
152
+ /**
153
+ * Connect to additional peers
154
+ * @param peers ex - `['1.2.3.4:6881']`
155
+ */
156
+ async addPeers(id, peers) {
157
+ return this.request(`/torrents/${id}/add_peers`, {
158
+ method: 'POST',
159
+ body: peers.join('\n'),
160
+ headers: { 'Content-Type': 'text/plain' },
161
+ });
162
+ }
163
+ /**
164
+ * rqbit does not support queueing
165
+ */
166
+ async queueUp(_id) {
167
+ throw new Error('rqbit does not support queueing');
168
+ }
169
+ /**
170
+ * rqbit does not support queueing
171
+ */
172
+ async queueDown(_id) {
173
+ throw new Error('rqbit does not support queueing');
174
+ }
175
+ /**
176
+ * Add a magnet link or a url to a .torrent file.
177
+ * rqbit resolves magnet metadata from peers before it responds, so this can take a while, see {@link RqbitConfig.addTimeout}
178
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api/handlers/torrents.rs}
179
+ */
180
+ async addMagnet(url, options = {}) {
181
+ return this.postTorrent(url, true, options);
182
+ }
183
+ /**
184
+ * Add a torrent
185
+ * @param torrent .torrent file contents, or the file contents as a base64 string. Magnet links and urls are passed to {@link Rqbit.addMagnet}
186
+ */
187
+ async addTorrent(torrent, options = {}) {
188
+ if (typeof torrent === 'string') {
189
+ if (isUrl(torrent)) {
190
+ return this.addMagnet(torrent, options);
191
+ }
192
+ return this.postTorrent(base64ToUint8Array(torrent), false, options);
193
+ }
194
+ return this.postTorrent(torrent, false, options);
195
+ }
196
+ /**
197
+ * Resolve a magnet link to .torrent file contents without adding it.
198
+ * Like adding, rqbit fetches the metadata from peers before it responds, see {@link RqbitConfig.addTimeout}
199
+ */
200
+ async resolveMagnet(magnet, options = {}) {
201
+ const timeoutMs = options.timeout_ms ?? this.config.addTimeout;
202
+ const res = await this.request('/torrents/resolve_magnet', {
203
+ method: 'POST',
204
+ query: { timeout_ms: timeoutMs },
205
+ body: magnet,
206
+ // rqbit returns the torrent decoded as json when asked for json
207
+ headers: { 'Content-Type': 'text/plain', Accept: 'application/x-bittorrent' },
208
+ responseType: 'arrayBuffer',
209
+ timeout: timeoutMs + 5000,
210
+ });
211
+ return new Uint8Array(res);
212
+ }
213
+ /**
214
+ * Create a torrent from a folder on the rqbit server and start seeding it.
215
+ * rqbit must be started with `--http-api-allow-create` or `RQBIT_HTTP_API_ALLOW_CREATE=true`.
216
+ * @param folder path to the folder on the rqbit server
217
+ */
218
+ async createTorrent(folder, options = {}) {
219
+ const magnet = await this.request('/torrents/create', {
220
+ method: 'POST',
221
+ // repeated trackers=a&trackers=b
222
+ query: { output: 'magnet', ...options },
223
+ body: folder,
224
+ headers: { 'Content-Type': 'text/plain' },
225
+ responseType: 'text',
226
+ });
227
+ // rqbit only sends the info hash in the magnet
228
+ const infoHash = new URL(magnet).searchParams.get('xt').replace('urn:btih:', '');
229
+ return { magnet, info_hash: infoHash };
230
+ }
231
+ /**
232
+ * Add a torrent and return normalized torrent data.
233
+ * The add endpoint has no paused option, `startPaused` pauses the torrent after adding.
234
+ * rqbit does not support labels, `label` is ignored.
235
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api_types.rs}
236
+ */
237
+ async normalizedAddTorrent(torrent, options = {}) {
238
+ const res = await this.addTorrent(torrent);
239
+ const hash = res.details.info_hash;
240
+ if (options.startPaused) {
241
+ await this.pauseTorrent(hash);
242
+ }
243
+ return this.getTorrent(hash);
244
+ }
245
+ async getTorrent(id) {
246
+ const [details, stats] = await Promise.all([
247
+ this.getTorrentDetails(id),
248
+ this.getTorrentStats(id),
249
+ ]);
250
+ return normalizeTorrentData({
251
+ id: details.id,
252
+ info_hash: details.info_hash,
253
+ name: details.name,
254
+ output_folder: details.output_folder,
255
+ total_pieces: details.total_pieces,
256
+ stats,
257
+ }, details.files);
258
+ }
259
+ /**
260
+ * rqbit does not support labels so labels is always empty
261
+ */
262
+ async getAllData() {
263
+ const res = await this.listTorrents(true);
264
+ return {
265
+ torrents: res.torrents.map(torrent => normalizeTorrentData(torrent)),
266
+ labels: [],
267
+ raw: res,
268
+ };
269
+ }
270
+ async request(path, options = {}) {
271
+ const url = this.url(path);
272
+ const headers = new Headers(options.headers);
273
+ if (this.config.username || this.config.password) {
274
+ const auth = stringToBase64(`${this.config.username}:${this.config.password}`);
275
+ headers.set('Authorization', `Basic ${auth}`);
276
+ }
277
+ try {
278
+ return await ofetch(url, {
279
+ timeout: this.config.timeout,
280
+ dispatcher: this.config.dispatcher,
281
+ retry: false,
282
+ ...options,
283
+ headers,
284
+ });
285
+ }
286
+ catch (error) {
287
+ if (!(error instanceof FetchError)) {
288
+ throw error;
289
+ }
290
+ if (!error.response) {
291
+ // ofetch aborted (timeout) or the request never reached rqbit
292
+ throw new RqbitApiError(undefined, error.message, undefined, error);
293
+ }
294
+ const data = error.data;
295
+ if (isRqbitError(data)) {
296
+ throw new RqbitApiError(error.response.status, data.human_readable, data, error);
297
+ }
298
+ // rqbit returns plain text for routing and query string errors
299
+ const message = typeof data === 'string' && data ? data : error.message;
300
+ throw new RqbitApiError(error.response.status, message, undefined, error);
301
+ }
302
+ }
303
+ url(path) {
304
+ return joinURL(this.config.baseUrl, this.config.path, path);
305
+ }
306
+ async postTorrent(body, isUrlBody, options) {
307
+ const { only_files, initial_peers, ...rest } = options;
308
+ const timeoutMs = options.timeout_ms ?? this.config.addTimeout;
309
+ const query = {
310
+ overwrite: true,
311
+ is_url: isUrlBody,
312
+ ...rest,
313
+ timeout_ms: timeoutMs,
314
+ };
315
+ if (only_files) {
316
+ query.only_files = only_files.join(',');
317
+ }
318
+ if (initial_peers) {
319
+ query.initial_peers = initial_peers.join(',');
320
+ }
321
+ return this.request('/torrents', {
322
+ method: 'POST',
323
+ query,
324
+ body,
325
+ headers: {
326
+ 'Content-Type': isUrlBody ? 'text/plain' : 'application/x-bittorrent',
327
+ },
328
+ // leave room for rqbit to respond with its own timeout error
329
+ timeout: timeoutMs + 5000,
330
+ });
331
+ }
332
+ }
333
+ function isUrl(str) {
334
+ return /^(magnet:|https?:\/\/)/i.test(str);
335
+ }
336
+ function isRqbitError(data) {
337
+ return typeof data === 'object' && data !== null && 'error_kind' in data;
338
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Types are based on responses from rqbit 9.0.1.
3
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/api.rs}
4
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/webui/src/api-types.ts}
5
+ *
6
+ * Response from `GET /`
7
+ */
8
+ export interface ApiRootResponse {
9
+ /**
10
+ * Map of `"METHOD /path"` to a description of the endpoint
11
+ */
12
+ apis: Record<string, string>;
13
+ server: 'rqbit';
14
+ /**
15
+ * rqbit version, ex - `9.0.1`
16
+ */
17
+ version: string;
18
+ }
19
+ /**
20
+ * Torrent id from rqbit, or the 40 character info hash
21
+ */
22
+ export type TorrentIdOrHash = number | string;
23
+ /**
24
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/torrent_state/stats.rs}
25
+ */
26
+ export interface Speed {
27
+ /**
28
+ * Speed in MiB/s (despite the name), multiply by 1024 * 1024 for bytes per second
29
+ */
30
+ mbps: number;
31
+ /**
32
+ * ex - `7.56 MiB/s`
33
+ */
34
+ human_readable: string;
35
+ }
36
+ export interface Duration {
37
+ secs: number;
38
+ nanos: number;
39
+ }
40
+ export interface DurationWithHumanReadable {
41
+ duration: Duration;
42
+ /**
43
+ * ex - `4m 1s`
44
+ */
45
+ human_readable: string;
46
+ }
47
+ export interface AggregatePeerStats {
48
+ queued: number;
49
+ connecting: number;
50
+ live: number;
51
+ live_tcp: number;
52
+ live_utp: number;
53
+ live_socks: number;
54
+ seen: number;
55
+ dead: number;
56
+ not_needed: number;
57
+ steals: number;
58
+ }
59
+ export interface StatsSnapshot {
60
+ downloaded_and_checked_bytes: number;
61
+ fetched_bytes: number;
62
+ uploaded_bytes: number;
63
+ downloaded_and_checked_pieces: number;
64
+ total_piece_download_ms: number;
65
+ peer_stats: AggregatePeerStats;
66
+ }
67
+ /**
68
+ * Only present while the torrent is live
69
+ */
70
+ export interface LiveStats {
71
+ snapshot: StatsSnapshot;
72
+ average_piece_download_time: Duration | null;
73
+ download_speed: Speed;
74
+ upload_speed: Speed;
75
+ time_remaining: DurationWithHumanReadable | null;
76
+ }
77
+ /**
78
+ * A torrent paused while initializing stays `initializing` with `initializing_paused: true` until it is started.
79
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/torrent_state/stats.rs}
80
+ */
81
+ export type TorrentStatsState = 'initializing' | 'live' | 'paused' | 'error';
82
+ /**
83
+ * Response from `GET /torrents/{id}/stats/v1`
84
+ */
85
+ export interface TorrentStats {
86
+ state: TorrentStatsState;
87
+ /**
88
+ * Only present when state is `initializing`
89
+ */
90
+ initializing_paused?: boolean;
91
+ /**
92
+ * Bytes downloaded per file, in file order
93
+ */
94
+ file_progress: number[];
95
+ error: string | null;
96
+ progress_bytes: number;
97
+ uploaded_bytes: number;
98
+ /**
99
+ * Total bytes of the selected files
100
+ */
101
+ total_bytes: number;
102
+ finished: boolean;
103
+ live: LiveStats | null;
104
+ }
105
+ export interface TorrentFileAttributes {
106
+ symlink: boolean;
107
+ hidden: boolean;
108
+ padding: boolean;
109
+ executable: boolean;
110
+ }
111
+ export interface TorrentFile {
112
+ name: string;
113
+ components: string[];
114
+ length: number;
115
+ /**
116
+ * Whether the file is selected for download
117
+ */
118
+ included: boolean;
119
+ attributes: TorrentFileAttributes;
120
+ }
121
+ /**
122
+ * Response from `GET /torrents/{id}`
123
+ */
124
+ export interface TorrentDetails {
125
+ id: number;
126
+ info_hash: string;
127
+ /**
128
+ * null until metadata is resolved
129
+ */
130
+ name: string | null;
131
+ output_folder: string;
132
+ total_pieces: number;
133
+ files: TorrentFile[];
134
+ }
135
+ /**
136
+ * Item from `GET /torrents`
137
+ */
138
+ export interface TorrentListItem {
139
+ id: number;
140
+ info_hash: string;
141
+ name: string | null;
142
+ output_folder: string;
143
+ total_pieces: number;
144
+ /**
145
+ * Only present when listing with stats
146
+ */
147
+ stats?: TorrentStats;
148
+ }
149
+ export interface TorrentWithStats extends TorrentListItem {
150
+ stats: TorrentStats;
151
+ }
152
+ /**
153
+ * Response from `GET /torrents`
154
+ */
155
+ export interface ListTorrentsResponse<T extends TorrentListItem = TorrentListItem> {
156
+ torrents: T[];
157
+ }
158
+ /**
159
+ * Response from `POST /torrents`
160
+ */
161
+ export interface AddTorrentResponse {
162
+ /**
163
+ * null when added with `list_only`
164
+ */
165
+ id: number | null;
166
+ details: TorrentDetails;
167
+ output_folder: string;
168
+ seen_peers: string[] | null;
169
+ }
170
+ /**
171
+ * Query options for `POST /torrents`
172
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api_types.rs}
173
+ */
174
+ export interface AddTorrentOptions {
175
+ /**
176
+ * Allow writing over existing files on disk, rqbit refuses to add a torrent whose files already exist without this.
177
+ * default: true
178
+ */
179
+ overwrite: boolean;
180
+ /**
181
+ * Absolute folder to download into, defaults to the folder rqbit was started with
182
+ */
183
+ output_folder: string;
184
+ /**
185
+ * Folder inside the output folder to download into
186
+ */
187
+ sub_folder: string;
188
+ /**
189
+ * Only download files matching this regex
190
+ */
191
+ only_files_regex: string;
192
+ /**
193
+ * Only download these file indexes
194
+ */
195
+ only_files: number[];
196
+ /**
197
+ * Resolve the torrent and return its details without adding it
198
+ */
199
+ list_only: boolean;
200
+ /**
201
+ * Extra peers to connect to, ex - `['1.2.3.4:6881']`
202
+ */
203
+ initial_peers: string[];
204
+ /**
205
+ * Seconds
206
+ */
207
+ peer_connect_timeout: number;
208
+ /**
209
+ * Seconds
210
+ */
211
+ peer_read_write_timeout: number;
212
+ /**
213
+ * How long rqbit will wait to resolve magnet metadata before giving up, also used as the request timeout.
214
+ * default: {@link RqbitConfig.addTimeout}
215
+ */
216
+ timeout_ms: number;
217
+ }
218
+ export interface PeerCounters {
219
+ incoming_connections: number;
220
+ fetched_bytes: number;
221
+ uploaded_bytes: number;
222
+ total_time_connecting_ms: number;
223
+ connection_attempts: number;
224
+ connections: number;
225
+ errors: number;
226
+ fetched_chunks: number;
227
+ downloaded_and_checked_pieces: number;
228
+ total_piece_download_ms: number;
229
+ times_stolen_from_me: number;
230
+ times_i_stole: number;
231
+ }
232
+ export type PeerState = 'queued' | 'connecting' | 'live' | 'dead' | 'not_needed';
233
+ export interface PeerStats {
234
+ counters: PeerCounters;
235
+ state: PeerState;
236
+ conn_kind: 'tcp' | 'utp' | 'socks' | null;
237
+ client_name: string | null;
238
+ }
239
+ /**
240
+ * Response from `GET /torrents/{id}/peer_stats`
241
+ */
242
+ export interface PeerStatsResponse {
243
+ /**
244
+ * Keyed by `ip:port`
245
+ */
246
+ peers: Record<string, PeerStats>;
247
+ }
248
+ export interface ConnectionStatSingle {
249
+ attempts: number;
250
+ successes: number;
251
+ errors: number;
252
+ }
253
+ export interface ConnectionStatsPerFamily {
254
+ v4: ConnectionStatSingle;
255
+ v6: ConnectionStatSingle;
256
+ }
257
+ /**
258
+ * Response from `GET /stats`
259
+ */
260
+ export interface SessionStats {
261
+ counters: {
262
+ fetched_bytes: number;
263
+ uploaded_bytes: number;
264
+ blocked_incoming: number;
265
+ blocked_outgoing: number;
266
+ };
267
+ download_speed: Speed;
268
+ upload_speed: Speed;
269
+ peers: AggregatePeerStats;
270
+ uptime_seconds: number;
271
+ connections: {
272
+ tcp: ConnectionStatsPerFamily;
273
+ utp: ConnectionStatsPerFamily;
274
+ socks: ConnectionStatsPerFamily;
275
+ };
276
+ }
277
+ /**
278
+ * Session wide rate limits, null is unlimited
279
+ */
280
+ export interface RateLimits {
281
+ /**
282
+ * bytes per second
283
+ */
284
+ upload_bps: number | null;
285
+ /**
286
+ * bytes per second
287
+ */
288
+ download_bps: number | null;
289
+ }
290
+ /**
291
+ * JSON error body returned by rqbit
292
+ */
293
+ export interface RqbitErrorResponse {
294
+ /**
295
+ * ex - `torrent_not_found`, `internal_error`
296
+ */
297
+ error_kind: string;
298
+ human_readable: string;
299
+ status: number;
300
+ status_text: string;
301
+ id?: string;
302
+ }
303
+ /**
304
+ * Empty response from actions like pause, start, forget and delete
305
+ */
306
+ export type EmptyResponse = Record<string, never>;
307
+ /**
308
+ * Response from `GET /dht/stats`
309
+ */
310
+ export interface DhtStats {
311
+ /**
312
+ * This node's DHT id
313
+ */
314
+ id: string;
315
+ outstanding_requests: number;
316
+ routing_table_size: number;
317
+ routing_table_size_v6: number;
318
+ }
319
+ /**
320
+ * Query options for `POST /torrents/create`
321
+ * {@link https://github.com/ikatson/rqbit/blob/v9.0.1/crates/librqbit/src/http_api/handlers/torrents.rs}
322
+ */
323
+ export interface CreateTorrentOptions {
324
+ /**
325
+ * Torrent name, defaults to the folder name
326
+ */
327
+ name: string;
328
+ /**
329
+ * Announce urls
330
+ */
331
+ trackers: string[];
332
+ }
333
+ export interface CreateTorrentResponse {
334
+ magnet: string;
335
+ info_hash: string;
336
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@ctrl/rqbit",
3
+ "version": "0.0.1",
4
+ "description": "TypeScript api wrapper for rqbit using ofetch",
5
+ "keywords": [
6
+ "rqbit",
7
+ "torrent",
8
+ "typescript"
9
+ ],
10
+ "homepage": "https://rqbit.ep.workers.dev",
11
+ "license": "MIT",
12
+ "author": "Scott Cooper <scttcper@gmail.com>",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/scttcper/rqbit.git"
16
+ },
17
+ "files": [
18
+ "dist/src"
19
+ ],
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "main": "./dist/src/index.js",
23
+ "types": "./dist/src/index.d.ts",
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
+ "scripts": {
29
+ "lint": "oxlint . && oxfmt --check",
30
+ "lint:fix": "oxlint . --fix && oxfmt",
31
+ "prepare": "pnpm run build",
32
+ "build": "tsc",
33
+ "build:docs": "node --import ./typedoc.mjs ./node_modules/typedoc/bin/typedoc",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest"
36
+ },
37
+ "dependencies": {
38
+ "@ctrl/shared-torrent": "^6.5.2",
39
+ "ofetch": "^1.5.1",
40
+ "type-fest": "^5.10.0",
41
+ "ufo": "^1.6.4",
42
+ "uint8array-extras": "^1.6.0"
43
+ },
44
+ "devDependencies": {
45
+ "@ctrl/oxlint-config": "1.5.0",
46
+ "@sindresorhus/tsconfig": "8.1.0",
47
+ "@types/node": "26.6.4",
48
+ "oxfmt": "0.71.0",
49
+ "oxlint": "1.86.0",
50
+ "p-wait-for": "6.0.0",
51
+ "typedoc": "0.28.20",
52
+ "typescript": "7.0.2",
53
+ "typescript6": "npm:typescript@6.0.3",
54
+ "vitest": "5.0.3"
55
+ },
56
+ "release": {
57
+ "branches": [
58
+ "master"
59
+ ]
60
+ },
61
+ "engines": {
62
+ "node": ">=22"
63
+ },
64
+ "packageManager": "pnpm@12.9.1"
65
+ }