@ctrl/qbittorrent 9.1.0 → 9.2.0

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/README.md CHANGED
@@ -98,6 +98,15 @@ const result = await client.normalizedAddTorrent(fs.readFileSync(torrentFile), {
98
98
  console.log(result);
99
99
  ```
100
100
 
101
+ ##### export and create from state
102
+
103
+ If you're shutting down the server often (serverless?) you can export the state
104
+
105
+ ```ts
106
+ const state = client.exportState()
107
+ const client = QBittorrent.createFromState(config, state);
108
+ ```
109
+
101
110
  ### See Also
102
111
 
103
112
  All of the following npm modules provide the same normalized functions along with supporting the unique apis for each client.
@@ -1,16 +1,34 @@
1
- import type { AddTorrentOptions as NormalizedAddTorrentOptions, AllClientData, NormalizedTorrent, TorrentClient, TorrentSettings } from '@ctrl/shared-torrent';
1
+ import type { Jsonify } from 'type-fest';
2
+ import type { AddTorrentOptions as NormalizedAddTorrentOptions, AllClientData, NormalizedTorrent, TorrentClient, TorrentClientConfig, TorrentClientState } from '@ctrl/shared-torrent';
2
3
  import type { AddMagnetOptions, AddTorrentOptions, BuildInfo, Preferences, Torrent, TorrentCategories, TorrentFile, TorrentFilePriority, TorrentFilters, TorrentPeersResponse, TorrentPieceState, TorrentProperties, TorrentTrackers, WebSeed } from './types.js';
4
+ interface QBittorrentState extends TorrentClientState {
5
+ auth?: {
6
+ /**
7
+ * auth cookie
8
+ */
9
+ sid: string;
10
+ /**
11
+ * cookie expiration
12
+ */
13
+ expires: Date;
14
+ };
15
+ version?: {
16
+ version: string;
17
+ isVersion5OrHigher: boolean;
18
+ };
19
+ }
3
20
  export declare class QBittorrent implements TorrentClient {
4
- config: TorrentSettings;
5
21
  /**
6
- * auth cookie
22
+ * Create a new QBittorrent client from a state
7
23
  */
8
- private _sid?;
24
+ static createFromState(config: Readonly<TorrentClientConfig>, state: Readonly<Jsonify<QBittorrentState>>): QBittorrent;
25
+ config: TorrentClientConfig;
26
+ state: QBittorrentState;
27
+ constructor(options?: Partial<TorrentClientConfig>);
9
28
  /**
10
- * cookie expiration
29
+ * Export the state of the client as JSON
11
30
  */
12
- private _exp?;
13
- constructor(options?: Partial<TorrentSettings>);
31
+ exportState(): Jsonify<QBittorrentState>;
14
32
  /**
15
33
  * @deprecated
16
34
  */
@@ -212,4 +230,6 @@ export declare class QBittorrent implements TorrentClient {
212
230
  login(): Promise<boolean>;
213
231
  logout(): boolean;
214
232
  request<T>(path: string, method: 'GET' | 'POST', params?: Record<string, string | number>, body?: URLSearchParams | FormData, headers?: Record<string, string>, isJson?: boolean): Promise<T>;
233
+ private checkVersion;
215
234
  }
235
+ export {};
@@ -14,18 +14,28 @@ const defaults = {
14
14
  timeout: 5000,
15
15
  };
16
16
  export class QBittorrent {
17
- config;
18
- /**
19
- * auth cookie
20
- */
21
- _sid;
22
17
  /**
23
- * cookie expiration
18
+ * Create a new QBittorrent client from a state
24
19
  */
25
- _exp;
20
+ static createFromState(config, state) {
21
+ const client = new QBittorrent(config);
22
+ client.state = {
23
+ ...state,
24
+ auth: state.auth ? { ...state.auth, expires: new Date(state.auth.expires) } : undefined,
25
+ };
26
+ return client;
27
+ }
28
+ config;
29
+ state = {};
26
30
  constructor(options = {}) {
27
31
  this.config = { ...defaults, ...options };
28
32
  }
33
+ /**
34
+ * Export the state of the client as JSON
35
+ */
36
+ exportState() {
37
+ return JSON.parse(JSON.stringify(this.state));
38
+ }
29
39
  /**
30
40
  * @deprecated
31
41
  */
@@ -307,16 +317,20 @@ export class QBittorrent {
307
317
  * {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#pause-torrents}
308
318
  */
309
319
  async pauseTorrent(hashes) {
320
+ const endpoint = this.state.version?.isVersion5OrHigher ? '/torrents/stop' : '/torrents/pause';
310
321
  const data = { hashes: normalizeHashes(hashes) };
311
- await this.request('/torrents/pause', 'POST', undefined, objToUrlSearchParams(data));
322
+ await this.request(endpoint, 'POST', undefined, objToUrlSearchParams(data));
312
323
  return true;
313
324
  }
314
325
  /**
315
326
  * {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#resume-torrents}
316
327
  */
317
328
  async resumeTorrent(hashes) {
329
+ const endpoint = this.state.version?.isVersion5OrHigher
330
+ ? '/torrents/start'
331
+ : '/torrents/resume';
318
332
  const data = { hashes: normalizeHashes(hashes) };
319
- await this.request('/torrents/resume', 'POST', undefined, objToUrlSearchParams(data));
333
+ await this.request(endpoint, 'POST', undefined, objToUrlSearchParams(data));
320
334
  return true;
321
335
  }
322
336
  /**
@@ -362,6 +376,11 @@ export class QBittorrent {
362
376
  form.set('file', file);
363
377
  }
364
378
  if (options) {
379
+ // Handle version-specific paused/stopped parameter
380
+ if (this.state.version?.isVersion5OrHigher && 'paused' in options) {
381
+ form.append('stopped', options.paused);
382
+ delete options.paused;
383
+ }
365
384
  // disable savepath when autoTMM is defined
366
385
  if (options.useAutoTMM === 'true') {
367
386
  options.savepath = '';
@@ -436,6 +455,11 @@ export class QBittorrent {
436
455
  const form = new FormData();
437
456
  form.append('urls', urls);
438
457
  if (options) {
458
+ // Handle version-specific paused/stopped parameter
459
+ if (this.state.version?.isVersion5OrHigher && 'paused' in options) {
460
+ form.append('stopped', options.paused);
461
+ delete options.paused;
462
+ }
439
463
  // disable savepath when autoTMM is defined
440
464
  if (options.useAutoTMM === 'true') {
441
465
  options.savepath = '';
@@ -526,7 +550,7 @@ export class QBittorrent {
526
550
  * {@link https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#login}
527
551
  */
528
552
  async login() {
529
- const url = joinURL(this.config.baseUrl, this.config.path, '/auth/login');
553
+ const url = joinURL(this.config.baseUrl, this.config.path ?? '', '/auth/login');
530
554
  const res = await ofetch.raw(url, {
531
555
  method: 'POST',
532
556
  headers: {
@@ -544,41 +568,43 @@ export class QBittorrent {
544
568
  if (!res.headers.get('set-cookie')?.length) {
545
569
  throw new Error('Cookie not found. Auth Failed.');
546
570
  }
547
- const cookie = cookieParse(res.headers.get('set-cookie'));
571
+ const cookie = cookieParse(res.headers.get('set-cookie') ?? '');
548
572
  if (!cookie.SID) {
549
573
  throw new Error('Invalid cookie');
550
574
  }
551
- this._sid = cookie.SID;
552
- // Not sure if it might be lowercase
553
575
  const expires = cookie.Expires ?? cookie.expires;
554
- // Assumed to be in seconds
555
576
  const maxAge = cookie['Max-Age'] ?? cookie['max-age'];
556
- this._exp = expires
557
- ? new Date(expires)
558
- : maxAge
559
- ? new Date(Number(maxAge) * 1000)
560
- : // Default expiration 1 hour
561
- new Date(Date.now() + 3600000);
577
+ this.state.auth = {
578
+ sid: cookie.SID,
579
+ expires: expires
580
+ ? new Date(expires)
581
+ : maxAge
582
+ ? new Date(Number(maxAge) * 1000)
583
+ : new Date(Date.now() + 3600000),
584
+ };
585
+ // Check version after successful login
586
+ await this.checkVersion();
562
587
  return true;
563
588
  }
564
589
  logout() {
565
- this._sid = undefined;
566
- this._exp = undefined;
590
+ delete this.state.auth;
567
591
  return true;
568
592
  }
569
593
  // eslint-disable-next-line max-params
570
594
  async request(path, method, params, body, headers = {}, isJson = true) {
571
- if (!this._sid || !this._exp || this._exp.getTime() < new Date().getTime()) {
595
+ if (!this.state.auth?.sid ||
596
+ !this.state.auth.expires ||
597
+ this.state.auth.expires.getTime() < new Date().getTime()) {
572
598
  const authed = await this.login();
573
599
  if (!authed) {
574
600
  throw new Error('Auth Failed');
575
601
  }
576
602
  }
577
- const url = joinURL(this.config.baseUrl, this.config.path, path);
603
+ const url = joinURL(this.config.baseUrl, this.config.path ?? '', path);
578
604
  const res = await ofetch(url, {
579
605
  method,
580
606
  headers: {
581
- Cookie: `SID=${this._sid ?? ''}`,
607
+ Cookie: `SID=${this.state.auth.sid ?? ''}`,
582
608
  ...headers,
583
609
  },
584
610
  body,
@@ -592,6 +618,17 @@ export class QBittorrent {
592
618
  });
593
619
  return res;
594
620
  }
621
+ async checkVersion() {
622
+ if (!this.state.version?.version) {
623
+ const newVersion = await this.getAppVersion();
624
+ // Remove potential 'v' prefix and any extra info after version number
625
+ const cleanVersion = newVersion.replace(/^v/, '').split('-')[0];
626
+ this.state.version = {
627
+ version: newVersion,
628
+ isVersion5OrHigher: cleanVersion === '5.0.0' || isGreater(cleanVersion, '5.0.0'),
629
+ };
630
+ }
631
+ }
595
632
  }
596
633
  /**
597
634
  * Normalizes hashes
@@ -610,3 +647,6 @@ function objToUrlSearchParams(obj) {
610
647
  }
611
648
  return params;
612
649
  }
650
+ function isGreater(a, b) {
651
+ return a.localeCompare(b, undefined, { numeric: true }) === 1;
652
+ }
@@ -549,7 +549,8 @@ export interface AddTorrentOptions {
549
549
  /**
550
550
  * Add torrents in the paused state. Possible values are true, false (default)
551
551
  */
552
- paused: TrueFalseStr;
552
+ paused?: TrueFalseStr;
553
+ stopped?: TrueFalseStr;
553
554
  /**
554
555
  * Control filesystem structure for content (added in Web API v2.7)
555
556
  * Migrating from rootFolder example rootFolder ? 'Original' : 'NoSubfolder'
@@ -606,7 +607,8 @@ export interface AddMagnetOptions {
606
607
  /**
607
608
  * Add torrents in the paused state. Possible values are true, false (default)
608
609
  */
609
- paused: TrueFalseStr;
610
+ paused?: TrueFalseStr;
611
+ stopped?: TrueFalseStr;
610
612
  /**
611
613
  * Create the root folder. Possible values are true, false, unset (default)
612
614
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl/qbittorrent",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "description": "TypeScript api wrapper for qbittorrent using got",
5
5
  "author": "Scott Cooper <scttcper@gmail.com>",
6
6
  "license": "MIT",
@@ -37,26 +37,27 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@ctrl/magnet-link": "^4.0.2",
40
- "@ctrl/shared-torrent": "^6.1.0",
40
+ "@ctrl/shared-torrent": "^6.2.1",
41
41
  "@ctrl/torrent-file": "^4.1.0",
42
- "cookie": "^1.0.1",
42
+ "cookie": "^1.0.2",
43
43
  "node-fetch-native": "^1.6.4",
44
44
  "ofetch": "^1.4.1",
45
+ "type-fest": "^4.30.2",
45
46
  "ufo": "^1.5.4",
46
47
  "uint8array-extras": "^1.4.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@biomejs/biome": "1.9.4",
50
- "@ctrl/eslint-config-biome": "4.2.11",
51
- "@eslint/compat": "^1.2.2",
52
- "@sindresorhus/tsconfig": "6.0.0",
51
+ "@ctrl/eslint-config-biome": "4.3.1",
52
+ "@eslint/compat": "^1.2.4",
53
+ "@sindresorhus/tsconfig": "7.0.0",
53
54
  "@types/cookie": "1.0.0",
54
- "@types/node": "22.9.0",
55
- "@vitest/coverage-v8": "2.1.4",
55
+ "@types/node": "22.10.2",
56
+ "@vitest/coverage-v8": "2.1.8",
56
57
  "p-wait-for": "5.0.2",
57
- "typedoc": "0.26.11",
58
- "typescript": "5.6.3",
59
- "vitest": "2.1.4"
58
+ "typedoc": "0.27.5",
59
+ "typescript": "5.7.2",
60
+ "vitest": "2.1.8"
60
61
  },
61
62
  "release": {
62
63
  "branches": [