├── .github
└── workflows
│ └── node.js.yml
├── .gitignore
├── .npmignore
├── DEVELOPMENT.md
├── README.md
├── __fixtures__
└── 100MB.bin.torrent
├── __mocks__
└── qbit.ts
├── __tests__
├── discord
│ ├── api.test.ts
│ └── messages.test.ts
├── helpers
│ ├── preparePromMetrics.test.ts
│ └── torrent.test.ts
├── qbittorrent
│ ├── api.test.ts
│ └── auth.test.ts
└── racing
│ ├── preRace.test.ts
│ └── tag.test.ts
├── bin
└── index.mjs
├── build
└── src
│ ├── discord
│ ├── api.js
│ └── messages.js
│ ├── helpers
│ ├── constants.js
│ ├── preparePromMetrics.js
│ ├── torrent.js
│ └── utilities.js
│ ├── interfaces.js
│ ├── qbittorrent
│ ├── api.js
│ └── auth.js
│ ├── racing
│ ├── add.js
│ ├── common.js
│ ├── completed.js
│ ├── preRace.js
│ └── tag.js
│ ├── server
│ ├── app.js
│ └── appFactory.js
│ └── utils
│ ├── config.js
│ ├── configV2.js
│ └── logger.js
├── examples
└── README.md
├── npm-shrinkwrap.json
├── package.json
├── src
├── discord
│ ├── api.ts
│ └── messages.ts
├── helpers
│ ├── constants.ts
│ ├── preparePromMetrics.ts
│ ├── torrent.ts
│ └── utilities.ts
├── interfaces.ts
├── qbittorrent
│ ├── api.ts
│ └── auth.ts
├── racing
│ ├── add.ts
│ ├── common.ts
│ ├── completed.ts
│ ├── preRace.ts
│ └── tag.ts
├── server
│ ├── app.ts
│ └── appFactory.ts
└── utils
│ ├── config.ts
│ ├── configV2.ts
│ └── logger.ts
└── tsconfig.json
/.github/workflows/node.js.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Node.js CI
5 |
6 | on: [push, pull_request]
7 |
8 | jobs:
9 | build:
10 |
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: [16.x, 18.x]
16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
17 |
18 | steps:
19 | - uses: actions/checkout@v2
20 | - name: Use Node.js ${{ matrix.node-version }}
21 | uses: actions/setup-node@v1
22 | with:
23 | node-version: ${{ matrix.node-version }}
24 | - run: npm install
25 | - run: npm run build
26 | - run: npm run test
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | node_modules/
3 | notes.txt
4 | logs/
5 | tests/*.torrent
6 | tests/examples.js
7 | settings.js
8 | /build/**/*.js.map
9 | /build/**/*.d.ts
10 | build/__mocks__/
11 | build/__tests__/
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | __mocks__/
2 | __tests__/
3 | .github/
4 | /src/
5 | tests/
6 | .gitignore
7 | .npmignore
8 | sample.env
9 | sample.settings.js
10 | settings.d.ts
11 | settings.js
12 | .env
13 | tsconfig.json
14 | logs/
15 | node_modules/
16 | __fixtures__/
17 | build/__mocks__/
18 | build/__tests__/
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # Development
2 |
3 | These are just some notes for my personal reference for when I'm doing local testing and stuff.
4 |
5 | API docs: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)
6 |
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # qBittorrent Racing
2 |
3 | `qbit-race` is a set of tools to help racing torrents on certain trackers. Some features are:
4 |
5 | * Reannounce to tracker
6 | * Automatically tag torrents with trackers
7 | * Discord notifications on addition (w/ size, trackers)
8 | * Discord notifications on completion (w/ ratio)
9 | * Configuring the number of simultaneous races
10 | * Pause seeding torrents when a new race is about to begin
11 | * Skip torrents with certain tags / category from being paused prior to a race
12 |
13 | This repository is still in beta. There might be bugs. Feel free to open an issue if you encounter one.
14 | You may also open an issue to request a feature, but please mark it with the prefix `[FEATURE REQUEST]`
15 |
16 | ## Thanks
17 |
18 |
19 |
20 | [
](https://clients.walkerservers.com/)
21 |
22 | Massive Thanks to WalkerServers for sponsoring this project. Check them out for affordable, high performance dedicated servers!
23 |
24 |
25 | ## Index
26 |
27 | - [qBittorrent Racing](#qbittorrent-racing)
28 | - [Thanks](#thanks)
29 | - [Index](#index)
30 | - [Node requirement](#node-requirement)
31 | - [Important!](#important)
32 | - [Repo Setup](#repo-setup)
33 | - [Updating](#updating)
34 | - [Additional Settings](#additional-settings)
35 | - [Backup Settings](#backup-settings)
36 | - [AutoDL setup (Basic)](#autodl-setup-basic)
37 | - [AutoDL setup (Advanced)](#autodl-setup-advanced)
38 | - [Torrent Category](#torrent-category)
39 | - [Change Category on torrent completion](#change-category-on-torrent-completion)
40 | - [Autobrr Setup](#autobrr-setup-beta)
41 | - [qBittorrent post race setup](#qbittorrent-post-race-setup)
42 | - [Other Scripts](#other-scripts)
43 | - [Tag Errored Torrents](#tag-errored-torrents)
44 | - [Prometheus Exporter](#prometheus-exporter)
45 | - [Metrics server setup](#metrics-server-setup)
46 | - [Example PromQL queries](#example-promql-queries)
47 |
48 | ## Node requirement
49 |
50 | Please use Node.JS v18.6.0 or later. LTS v16 should work in most cases, but if you face bugs related to "Out of memory: wasm memory" ([more info](https://github.com/nodejs/node/pull/43612)) , please try Node v18.6.0 before creating an issue.
51 |
52 | I would recommend using n for installing Node.JS (https://github.com/tj/n) .
53 | ### Important!
54 |
55 | The node binary needs to be accessible to `autodl-irssi` (or `autobrr`) and `qbittorrent`. On some seedboxes, the `PATH` for these is fixed, and usually includes a directory such as `~/bin` or `~/.local/bin`. In such cases you can symlink node to these, more details are below.
56 |
57 |
58 |
59 | Symlinking Node on shared seedboxes
60 |
61 | You can determine the path to node by running:
62 |
63 | ```
64 | $ which node
65 | ```
66 |
67 | For instance, if you install using `n`, it may be `/home/username/n/bin/node`. Then, symlink that into your bin folder:
68 | ```
69 | ln -s PATH_TO_NODE ${HOME}/bin/node
70 | ```
71 |
72 | For details on specific seedbox vendors, see (TODO).
73 |
74 |
75 |
76 |
77 |
78 | Symlinking Node on dedicated seedboxes
79 |
80 | Usually on dedicates seedboxes, the path you install node to, even via `n`, should be added to the user's path, and restarting applications (e.g. autodl, qbittorrent) should make this available in their path.
81 |
82 | However, if it isn't, then you usually need to symlink it into `/usr/bin`. First determine the path to node:
83 |
84 | ```
85 | $ which node
86 | ```
87 |
88 | For instance, if you install using `n`, it may be `/home/username/n/bin/node`. Then, symlink that into `/usr/bin` as (this may need sudo):
89 | ```
90 | ln -s PATH_TO_NODE /usr/bin/node
91 | ```
92 |
93 | For details on specific seedbox vendors, see (TODO).
94 |
95 |
96 |
97 | ## Setup
98 |
99 | **PLEASE NOTE : YOU ARE ON THE MASTER BRANCH, FOR V2 (ALPHA) OF QBIT-RACE! THIS RELEASE IS NOT YET STABLE.**
100 |
101 | If you want to install the latest stable release, please checkout the README of v1.1.0: https://github.com/ckcr4lyf/qbit-race/tree/v1.1.0
102 |
103 | If you want to try out V2 (alpha), then read ahead...
104 |
105 | ### Install qbit-race
106 |
107 | Installing is as simple as `npm i -g qbit-race`
108 |
109 | ### Post install setup
110 |
111 | Run `qbit-race` once to generate a dummy config file.
112 |
113 | Then you can edit the file in `~/.config/qbit-race/config.json` with the values you prefer.
114 |
115 | ## Configuration
116 |
117 | Once you've initialized the config, you can tweak settings in `~/.config/qbit-race/config.json`. These settings can be changed anytime by modifying this file, and doing so does not need a restart of qBittorrent or AutoDL. Here is a short explanation of the options:
118 |
119 | |Parameter|Default|Description|
120 | |---------|-------|-----------|
121 | |`REANNOUNCE_INTERVAL`|`5000`|milliseconds to wait before re-sending reannounce requests|
122 | |`REANNOUNCE_LIMIT`|`30`|Number of times to reannounce to the tracker before "giving up" (sometimes a torrent may be uploaded but then deleted by moderation)|
123 | |`PAUSE_RATIO`|`1`|When a new torrent is added, all seeding torrents above this ratio are paused. `-1` will not pause any torrents. (This may lead to worse racing performance)|
124 | |`PAUSE_SKIP_TAGS`|`["tracker.linux.org", "some_other_tag"]`|Prevent pausing of torrents before a race, if any of the tags match. This parameter is useful for skipping certain trackers where you may want to maintain seedtime|
125 | |`PAUSE_SKIP_CATEGORIES`|`["permaseeding", "some_other_category"]`|Prevent pausing of torrents before a race, if the category matches any in this list. Useful if you setup autoDL with some filters with the category option, and want to skip them from being paused|
126 | |`CONCURRENT_RACES`|`1`|How many torrents can be "raced" at once. If autodl grabs a torrent, but these many races are already in progress, the torrent will be silently skipped. While less parallel races give better performance, if you want to download everything autoDL grabs, set this to `-1`|
127 | |`COUNT_STALLED_DOWNLOADS`|`false`|If a seeder abandons the torrent midway, the download may be stalled. This option controls whether such torrents should be counted when checking for `CONCURRENT_RACES`. It is advised to keep this as false|
128 | |`QBITTORRENT_SETTINGS`|`object`|The connection options for qBittorrent. More details below|
129 | |`DISCORD_NOTIFICATIONS`|`object`|Configuration for discord notifications. Check [this section](#discord-notifications) for more details|
130 | |`CATEGORY_FINISH_CHANGE`|`object`|Check [this section](#change-category-on-torrent-completion) for details|
131 |
132 | ### Discord Notifications
133 |
134 | This option allows you to configure a Discord Webhook URL for notifications on a torrent being added, as well as torrent completion.
135 |
136 | |Parameter|Default|Description|
137 | |---------|-------|-----------|
138 | |`enabled`|`false`|Controls whether notifications are sent or not. Set to `true` to enable discord notifications|
139 | |`webhook`|`""`|The URL for your discord webhook|
140 | |`botUsername`|`qBittorrent`|The username of the Discord "account" that sends the notification|
141 | |`botAvatar`|(qBittorrent logo)|The picture of the Discord "account" that sends notification, and thumbnail of the embed|
142 |
143 | Once you enable webhooks, you can run `qbit-race validate` to check if it is able to send you a message in the channel.
144 |
145 | ### Change Category on torrent completion
146 |
147 | Often it may be desirable to change the category of the torrent on completion, example when using with Sonarr / Radarr etc. It is also useful to move torrents from SSD to HDD, with qBittorrents category-based download path rules. You can add as many rules as you would like (of course, a single torrent is limited to a single category still, by qbittorrent itself).
148 |
149 | To do so, there are two requirements:
150 | 1. The torrent must be added with a category set ([see this section](#torrent-category))
151 | 2. The category *to be changed to* must already exist in qBittorrent
152 |
153 | Then, in the configuration file, you can add a line to the `CATEGORY_FINISH_CHANGE` object, of the form:
154 |
155 | ```
156 | 'THE_CATEGORY_FROM_AUTODL': 'THE_CATEGORY_AFTER_COMPLETION'
157 | ```
158 |
159 | For instance, if you add it with the category "DOWNLOADING_LINUX", and want to change to "SEEDING_LINUX" on completeion, you can set it up as:
160 | ```
161 | CATEGORY_FINISH_CHANGE: {
162 | 'DOWNLOADING_LINUX': 'SEEDING_LINUX',
163 | 'ANOTHER_ONE': 'YET_ANOTHER_ONE'
164 | }
165 | ```
166 |
167 | ## AutoDL setup (Basic)
168 |
169 | Make sure you followed the steps to symlink node (if required). Detailed vendor-specific seedbox guides can be found here: (TODO)
170 |
171 | To determine the path of `qbit-race`, run `which qbit-race`. You will see the complete path, for example `/home/username/n/bin/qbit-race`. This will be the command in the AutoDL config.
172 |
173 | Now in AutoDL, change the Action for your filter (or Global action) to:
174 | 1. Choose .torrent action - `Run Program`
175 | 2. Comamnd - `/home/username/n/bin/qbit-race`
176 | 3. Arguments - `add -p "$(TorrentPathName)"`
177 |
178 | TODO: Screencap
179 |
180 | Click OK, and that should be it!
181 |
182 | Now, when AutoDL gets a torrent, it will pass it to the script which will feed it to qBittorrent!
183 |
184 | You can view the logs under `~/.config/qbit-race/logs.txt` to try and debug.
185 |
186 | ## AutoDL setup (Advanced)
187 |
188 | **TODO: UPDATE FOR V2**
189 |
190 | These are additional parameters you can specify in autoDL for additional functionality
191 |
192 | ### No Tracker Tags
193 |
194 | By default, when adding the torrent, `qbit-race` also "tags" the torrent with the hostname of all trackers. This is mostly useful in older version of qBittorrent to be able to sort torrents by trackers.
195 |
196 | To disable this, you can pass an extra flag `--no-tracker-tags` to the `qbit-race add` command.
197 |
198 | ```
199 | qbit-race add -p "$(TorrentPathName) --no-tracker-tags"
200 | ```
201 |
202 | ### Extra tags
203 |
204 | If you want to add extra tags to the torrent, you can supply them via a comma-separated list using the `--extra-tags` param to the `qbit-race add` command.
205 |
206 | ```
207 | qbit-race add -p "$(TorrentPathName) --extra-tags "linux,foss"
208 | ```
209 |
210 | ### Torrent Category
211 |
212 | In the autoDL arguments field, you may specify a category (per filter, or global) by adding to the end of arguments `--category "the category name"`
213 |
214 | For instance, a filter for Arch Linux ISOs could have the arguments:
215 | ```
216 | `"$(InfoHash)" "$(InfoName)" "$(Tracker)" "$(TorrentPathName)" --category "never open"`
217 | ```
218 |
219 | Which would set the category of all torrents that match said filter to "never open". If the category doesn't exist it will be created automatically.
220 |
221 | Protip: qBittorrent has a feature that allows you to configure download paths by category. This might be useful to consolidate your downloads.
222 |
223 | ## autobrr setup (BETA)
224 |
225 | **TODO: UPDATE FOR V2**
226 |
227 | It should work with [autobrr](https://github.com/autobrr/autobrr) as well, for the arguments in autobrr, just put `{{ .TorrentPathName }}` , and the command to execute same as that for AutoDL (path to `qbit-race`). Advanced instructions for category etc. are similar.
228 |
229 | ## qBittorrent post race setup
230 |
231 | **TODO: UPDATE FOR V2**
232 |
233 | After the race, the post race script will resume the torrents (if nothing else is downloading), and also send you a discord notification with the ratio (if you have enabled it).
234 |
235 | To get the path to the post race script, run the following commands:
236 | ```sh
237 | cd ~/scripts/qbit-race/bin
238 | echo "$(pwd)/post_race.mjs"
239 | ```
240 |
241 | You will see a line like
242 | ```
243 | /home/username/scripts/qbit-race/bin/post_race.mjs
244 | ```
245 |
246 | This is the path to the script. Now open qBittorrent, and in that open settings. At the bottom of "Downloads", check "Run external program on torrent completion".
247 | In the textbox, enter that path to the script followed by `"%I" "%N" "%T"`.
248 |
249 | So the final entry would look like
250 | ```
251 | /home/username/scripts/qbit-race/bin/post_race.js "%I" "%N" "%T"
252 | ```
253 |
254 |
255 | ## Other Scripts
256 |
257 | ### Tag Errored Torrents
258 |
259 | **TODO: UPDATE FOR V2**
260 |
261 | Sometimes torrents may be deleted from the tracker, or just an error in general. qBittorrent provides no easy way of sorting by these errors (Usually tracker responds with an error message).
262 |
263 | To tag all such torrents as `error`, from the root folder (`~/qbit-race`), run:
264 | ```
265 | npm run tag_unreg
266 | ```
267 |
268 | This will tag all torrents which do not have ANY working tracker.
269 |
270 |
271 | ## Prometheus Exporter
272 |
273 | `qbit-race` now also features a prometheus exporter, which is a lightweight http server (based on fastify) which exports a few stats in a prometheus friendly format.
274 |
275 | The stats exposed are:
276 |
277 | * Download this session (bytes)
278 | * Download rate (instant) (bytes/s)
279 | * Upload this session (bytes)
280 | * Upload rate (instant) (bytes/s)
281 |
282 | ### Metrics server setup
283 |
284 | In the config file, there are two keys under `PROMETHEUS_SETTINGS` to configure for where the server binds to, `ip` and `port`. Defaults are `127.0.0.1` and `9999` respectively.
285 |
286 | This address needs to be accessible by yur prometheus instance!
287 |
288 | To run the server, it is recommended to use a node process manager, pm2. Install it with:
289 |
290 | ```sh
291 | npm i -g pm2
292 | ```
293 |
294 | Then you can start the server (from the project root) with:
295 |
296 | ```sh
297 | pm2 start "qbit-race metrics" --name=qbit-race-prom-exporter
298 | ```
299 |
300 | (Replace name with something else if you wish)
301 |
302 | You can monitor the status of th emetrics server with
303 |
304 | ```sh
305 | pm2 monit
306 | ```
307 |
308 | Then add it to your Prometheus config, and should be good to go!
309 |
310 | ### Example PromQL queries
311 |
312 | TODO
--------------------------------------------------------------------------------
/__fixtures__/100MB.bin.torrent:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ckcr4lyf/qbit-race/8d1b9f04bb7cb98f101d992d7f0c035bf7f5c240/__fixtures__/100MB.bin.torrent
--------------------------------------------------------------------------------
/__mocks__/qbit.ts:
--------------------------------------------------------------------------------
1 | import { QbittorrentApi } from '../src/qbittorrent/api.js'
2 |
3 | export const newMockQbitApi = (): QbittorrentApi => {
4 | return {
5 | getTorrents: (): undefined => undefined,
6 | getTrackers: (): undefined => undefined,
7 | addTags: (): undefined => undefined,
8 | } as unknown as QbittorrentApi; // dont tell me what to do
9 | }
10 |
11 | export const getMockWorkingTrackers: any = () => {
12 | return [
13 | {
14 | status: 2,
15 | },
16 | {
17 | status: 2,
18 | },
19 | {
20 | status: 2,
21 | },
22 | {
23 | status: 2, // First 3 are DHT , PEX etc.
24 | },
25 | ]
26 | }
27 |
28 | export const getMockNotWorkingTrackers: any = () => {
29 | return [
30 | {
31 | status: 2,
32 | },
33 | {
34 | status: 2,
35 | },
36 | {
37 | status: 2,
38 | },
39 | {
40 | status: 0, // First 3 are DHT , PEX etc.
41 | },
42 | ]
43 | }
--------------------------------------------------------------------------------
/__tests__/discord/api.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import nock from 'nock';
3 |
4 | import { sendMessageV2 } from '../../src/discord/api.js'
5 |
6 | test('sendMessageV2', async t => {
7 | const fakeWebhook = `https://domain.com:4444/`;
8 |
9 | const scope = nock(fakeWebhook);
10 | scope.post('/webhook', { data: 'xd' }).reply(200, 'OK');
11 |
12 | const res = await sendMessageV2(`https://domain.com:4444/webhook`, { data: 'xd' });
13 |
14 | t.deepEqual(res.status, 200);
15 | t.deepEqual(res.data, 'OK');
16 | })
--------------------------------------------------------------------------------
/__tests__/discord/messages.test.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'node:crypto';
2 | import test from 'ava';
3 |
4 | import { buildMessageBody, buildTorrentAddedBody } from '../../src/discord/messages.js';
5 |
6 | const fakeDiscSettings = {
7 | enabled: true,
8 | botUsername: 'bot',
9 | botAvatar: 'image.png',
10 | webhook: 'xd',
11 | }
12 |
13 | test('buildMessageBody', t => {
14 | const aVal = crypto.randomBytes(10).toString('hex');
15 | const bVal = crypto.randomBytes(10).toString('hex');
16 | const partialData: any = { a: aVal, b: bVal };
17 |
18 | const want = {
19 | a: aVal,
20 | b: bVal,
21 | username: 'bot',
22 | avatar_url: 'image.png'
23 | };
24 |
25 | const got = buildMessageBody(fakeDiscSettings, partialData);
26 |
27 | t.deepEqual(got, want);
28 | })
29 |
30 | test('buildTorrentAddedBody', t => {
31 | const torrentInfo: any = {
32 | size: 2.5 * 1024 * 1024, // 2.5MiB
33 | name: 'arch',
34 | trackers: ['archlinux.org'],
35 | reannounceCount: 3,
36 | };
37 |
38 | const want = {
39 | username: 'bot',
40 | avatar_url: 'image.png',
41 | content: `Added arch (2.50 MiB)`,
42 | embeds: [
43 | {
44 | title: 'arch',
45 | description: 'Added to qBittorrent',
46 | thumbnail: {
47 | url: 'image.png',
48 | },
49 | fields: [
50 | {
51 | name: 'Tracker',
52 | value: 'archlinux.org',
53 | },
54 | {
55 | name: 'Size',
56 | value: '2.50 MiB'
57 | },
58 | {
59 | name: 'Reannounce Count',
60 | value: '3'
61 | }
62 | ]
63 | }
64 | ]
65 | }
66 |
67 | const got = buildTorrentAddedBody(fakeDiscSettings, torrentInfo);
68 |
69 | t.deepEqual(got, want);
70 | })
--------------------------------------------------------------------------------
/__tests__/helpers/preparePromMetrics.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { countTorrentStates } from '../../src/helpers/preparePromMetrics.js';
3 | import { torrentFromApi } from '../../src/interfaces.js';
4 |
5 | test('countTorrentStates', t => {
6 | let torrents = [
7 | {
8 | state: 'error'
9 | },
10 | {
11 | state: 'error'
12 | },
13 | {
14 | state: 'uploading'
15 | },
16 | {
17 | state: 'downloading'
18 | },
19 | {
20 | state: 'downloading'
21 | },
22 | {
23 | state: 'downloading'
24 | },
25 | {
26 | state: 'pausedUP'
27 | },
28 | ] as unknown as torrentFromApi[];
29 |
30 | // Skip all the possible states
31 | const counted = countTorrentStates(torrents);
32 | const countedPartial = {
33 | error: counted.error,
34 | uploading: counted.uploading,
35 | downloading: counted.downloading,
36 | pausedUP: counted.pausedUP,
37 | stalledDL: counted.stalledDL,
38 | }
39 |
40 | const expected = {
41 | 'error': 2,
42 | 'uploading': 1,
43 | 'downloading': 3,
44 | 'pausedUP': 1,
45 | 'stalledDL': 0,
46 | };
47 |
48 | t.deepEqual(countedPartial, expected);
49 | })
50 |
--------------------------------------------------------------------------------
/__tests__/helpers/torrent.test.ts:
--------------------------------------------------------------------------------
1 | import path, { dirname } from 'path';
2 | import { fileURLToPath } from 'url';
3 | import fs from 'fs';
4 | import test from 'ava';
5 | import { getTorrentMetainfo } from '../../src/helpers/torrent.js';
6 |
7 | const __dirname = dirname(fileURLToPath(import.meta.url));
8 |
9 | test('getTorrentMetaInfo', t => {
10 | const torrent = fs.readFileSync(path.join(__dirname, '../../../__fixtures__/100MB.bin.torrent'));
11 | const metainfo = getTorrentMetainfo(torrent);
12 |
13 | const expected = {
14 | hash: 'c04d04e869f7f53f212b28401b381274f2091d86',
15 | name: '100MB.bin',
16 | tracker: 'sub.faketracker.com'
17 | };
18 |
19 | t.deepEqual(expected, metainfo);
20 | })
--------------------------------------------------------------------------------
/__tests__/qbittorrent/api.test.ts:
--------------------------------------------------------------------------------
1 | import nock from 'nock';
2 | import test from 'ava';
3 |
4 | import { QbittorrentApi } from '../../src/qbittorrent/api.js';
5 | import { randomBytes } from 'crypto';
6 |
7 | test('getTorrents', async t => {
8 | const api = new QbittorrentApi('http://qbit:8080', 'cookie');
9 |
10 | const scope = nock('http://qbit:8080', {
11 | reqheaders: {
12 | 'Cookie': 'cookie'
13 | }
14 | });
15 |
16 | const data = randomBytes(10).toString('hex');
17 |
18 | scope.get('/api/v2/torrents/info').reply(200, data);
19 |
20 | const got = await api.getTorrents();
21 |
22 | t.deepEqual(data, got);
23 | })
24 |
25 | test('getTorrentsWithParam', async t => {
26 | const api = new QbittorrentApi('http://qbit:8080', 'cookie');
27 |
28 | const scope = nock('http://qbit:8080', {
29 | reqheaders: {
30 | 'Cookie': 'cookie'
31 | }
32 | });
33 |
34 | const data = randomBytes(10).toString('hex');
35 |
36 | scope.get('/api/v2/torrents/info?hashes=a|b').reply(200, data);
37 |
38 | const got = await api.getTorrents(['a', 'b']);
39 |
40 | t.deepEqual(data, got);
41 | })
42 |
43 | test('resumeTorrents', async t => {
44 | const api = new QbittorrentApi('http://qbit:8080', 'cookie');
45 |
46 | const scope = nock('http://qbit:8080', {
47 | reqheaders: {
48 | 'Cookie': 'cookie'
49 | }
50 | });
51 |
52 | scope.post('/api/v2/torrents/resume', 'hashes=a|b').reply(200);
53 |
54 | await api.resumeTorrents([
55 | {
56 | hash: 'a',
57 | },
58 | {
59 | hash: 'b',
60 | }
61 | ]);
62 |
63 | // Make sure the scope was successfully called
64 | t.notThrows(() => {
65 | scope.done();
66 | });
67 | })
--------------------------------------------------------------------------------
/__tests__/qbittorrent/auth.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import nock from 'nock';
3 |
4 | import { QBITTORRENT_SETTINGS } from '../../src/utils/config.js'
5 | import { loginV2 } from '../../src/qbittorrent/auth.js';
6 |
7 |
8 | test('loginV2', async t => {
9 | const fakeSettings: QBITTORRENT_SETTINGS = {
10 | url: 'http://qbit:8080',
11 | username: 'admin',
12 | password: 'adminadmin'
13 | }
14 |
15 | const scope = nock(fakeSettings.url);
16 |
17 | scope.post('/api/v2/auth/login', {
18 | username: 'admin',
19 | password: 'adminadmin'
20 | }).reply(200, {}, {
21 | 'set-cookie': 'SID=1234'
22 | });
23 |
24 | scope.get('/api/v2/app/version').reply(200, 'v4');
25 | const api = await loginV2(fakeSettings);
26 |
27 | //@ts-ignore - client is private but we want to access for testing
28 | t.deepEqual(api.client.defaults.headers.Cookie, 'SID=1234');
29 | //@ts-ignore - client is private but we want to access for testing
30 | t.deepEqual(api.client.defaults.baseURL, 'http://qbit:8080');
31 | t.deepEqual(scope.isDone(), true);
32 | })
--------------------------------------------------------------------------------
/__tests__/racing/preRace.test.ts:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon';
2 | import test from 'ava';
3 |
4 | import { concurrentRacesCheck } from '../../src/racing/preRace.js';
5 | import { defaultSettings } from '../../src/utils/config.js';
6 | import { QbittorrentTorrent } from '../../src/qbittorrent/api.js';
7 | import { TorrentState } from '../../src/interfaces.js';
8 |
9 | test('concurrentRacesNoTorrents', t => {
10 | const settings = { ...defaultSettings };
11 | const result = concurrentRacesCheck(settings, [])
12 | t.deepEqual(result, true);
13 | });
14 |
15 | test('concurrentRacesAndDownloading', t => {
16 | const settings = { ...defaultSettings };
17 | settings.CONCURRENT_RACES = 2;
18 | const torrents: any[] = [
19 | {
20 | state: TorrentState.downloading
21 | },
22 | {
23 | state: TorrentState.downloading
24 | },
25 | {
26 | state: TorrentState.uploading
27 | }
28 | ];
29 |
30 | const result = concurrentRacesCheck(settings, torrents);
31 | t.deepEqual(result, false);
32 | })
33 |
34 | test('countStalledVeryOld', t => {
35 | const settings = { ...defaultSettings };
36 | settings.CONCURRENT_RACES = 2;
37 | settings.COUNT_STALLED_DOWNLOADS = true;
38 | const torrents: any[] = [
39 | {
40 | state: TorrentState.downloading
41 | },
42 | {
43 | state: TorrentState.stalledDL,
44 | added_on: 0,
45 | },
46 | {
47 | state: TorrentState.uploading
48 | }
49 | ];
50 |
51 | const result = concurrentRacesCheck(settings, torrents);
52 | t.deepEqual(result, false);
53 | })
54 |
55 | test('dontCountStalledReannouncePhase', t => {
56 | const settings = { ...defaultSettings };
57 | settings.CONCURRENT_RACES = 2;
58 | settings.REANNOUNCE_INTERVAL = 100;
59 | settings.REANNOUNCE_LIMIT = 10;
60 | settings.COUNT_STALLED_DOWNLOADS = false;
61 | const torrents: any[] = [
62 | {
63 | state: TorrentState.downloading
64 | },
65 | {
66 | state: TorrentState.stalledDL,
67 | added_on: Date.now() / 1000,
68 | },
69 | {
70 | state: TorrentState.uploading
71 | }
72 | ];
73 |
74 | const result = concurrentRacesCheck(settings, torrents);
75 | t.deepEqual(result, false);
76 | })
77 |
78 | test('dontCountStalledVeryOld', t => {
79 | const settings = { ...defaultSettings };
80 | settings.CONCURRENT_RACES = 2;
81 | settings.REANNOUNCE_INTERVAL = 100;
82 | settings.REANNOUNCE_LIMIT = 10;
83 | settings.COUNT_STALLED_DOWNLOADS = false;
84 | const torrents: any[] = [
85 | {
86 | state: TorrentState.downloading
87 | },
88 | {
89 | state: TorrentState.stalledDL,
90 | added_on: 0, // This one is old so shouldn't be counted
91 | },
92 | {
93 | state: TorrentState.uploading
94 | }
95 | ];
96 |
97 | const result = concurrentRacesCheck(settings, torrents);
98 | t.deepEqual(result, true);
99 | })
100 |
--------------------------------------------------------------------------------
/__tests__/racing/tag.test.ts:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon';
2 | import test from 'ava';
3 |
4 | import { getMockNotWorkingTrackers, getMockWorkingTrackers, newMockQbitApi } from '../../__mocks__/qbit.js';
5 |
6 | import { tagErroredTorrents } from '../../src/racing/tag.js'
7 |
8 | test('tagWhenNoTorrents', async t => {
9 |
10 | const mockApi = newMockQbitApi();
11 | // Test with no torrents
12 | const first = sinon.stub(mockApi, 'getTorrents').resolves([]);
13 |
14 | const result = await tagErroredTorrents(mockApi, true);
15 | t.deepEqual(result, undefined);
16 | t.deepEqual(first.called, true);
17 | })
18 |
19 | test('tagWhenAllWorkingTorrents', async t => {
20 | const mockApi = newMockQbitApi();
21 | // Test with torrents
22 | const first = sinon.stub(mockApi, 'getTorrents').resolves([
23 | {
24 | hash: 'ABCD',
25 | },
26 | {
27 | hash: 'GGWP'
28 | }
29 | ] as any);
30 |
31 | const second = sinon.stub(mockApi, 'getTrackers').onCall(0).resolves(getMockWorkingTrackers()).onCall(1).resolves(getMockWorkingTrackers())
32 |
33 | const result = await tagErroredTorrents(mockApi, true);
34 | t.deepEqual(result, undefined);
35 | t.deepEqual(first.called, true);
36 | t.deepEqual(second.called, true);
37 | t.deepEqual(second.calledWith('ABCD'), true);
38 | t.deepEqual(second.calledWith('GGWP'), true);
39 | })
40 |
41 | test('tagWhenNotWorking', async t => {
42 | const mockApi = newMockQbitApi();
43 | // Test with torrents
44 | const first = sinon.stub(mockApi, 'getTorrents').resolves([
45 | {
46 | hash: 'ABCD',
47 | },
48 | {
49 | hash: 'GGWP'
50 | }
51 | ] as any);
52 |
53 | const second = sinon.stub(mockApi, 'getTrackers').onCall(0).resolves(getMockWorkingTrackers()).onCall(1).resolves(getMockNotWorkingTrackers())
54 |
55 | const third = sinon.stub(mockApi, 'addTags').onCall(0).resolves(undefined);
56 |
57 | // False since we want the actual tag
58 | const result = await tagErroredTorrents(mockApi, false);
59 | t.deepEqual(result, undefined);
60 | t.deepEqual(first.called, true);
61 | t.deepEqual(second.called, true);
62 | t.deepEqual(second.calledWith('ABCD'), true);
63 | t.deepEqual(second.calledWith('GGWP'), true);
64 |
65 | // Since second has a non working tracker, we expect a tag as well
66 | t.deepEqual(third.called, true);
67 | t.deepEqual(third.calledWith([{ hash: 'GGWP' }] as any, ['error']), true);
68 | })
69 |
70 | test('dontTagIfDryRun', async t => {
71 | const mockApi = newMockQbitApi();
72 | // Test with torrents
73 | const first = sinon.stub(mockApi, 'getTorrents').resolves([
74 | {
75 | hash: 'ABCD',
76 | },
77 | {
78 | hash: 'GGWP'
79 | }
80 | ] as any);
81 |
82 | const second = sinon.stub(mockApi, 'getTrackers').onCall(0).resolves(getMockWorkingTrackers()).onCall(1).resolves(getMockNotWorkingTrackers())
83 |
84 | const third = sinon.stub(mockApi, 'addTags').onCall(0).resolves(undefined);
85 |
86 | // Shouldnt tag in dry run
87 | const result = await tagErroredTorrents(mockApi, true);
88 | t.deepEqual(result, undefined);
89 | t.deepEqual(first.called, true);
90 | t.deepEqual(second.called, true);
91 | t.deepEqual(second.calledWith('ABCD'), true);
92 | t.deepEqual(second.calledWith('GGWP'), true);
93 |
94 | // Since second has a non working tracker, we expect a tag as well
95 | t.deepEqual(third.called, false);
96 | })
97 |
--------------------------------------------------------------------------------
/bin/index.mjs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { Command } from 'commander';
4 | import { loadConfig, makeConfigIfNotExist } from '../build/src/utils/configV2.js';
5 | import { loginV2 } from '../build/src/qbittorrent/auth.js';
6 | import { sendMessageV2 } from '../build/src/discord/api.js'
7 | import { buildTorrentAddedBody } from '../build/src/discord/messages.js'
8 | import { getLoggerV3 } from '../build/src/utils/logger.js'
9 | import { tagErroredTorrents } from '../build/src/racing/tag.js'
10 | import { postRaceResumeV2 } from '../build/src/racing/completed.js'
11 | import { raceExisting } from '../build/src/racing/common.js'
12 | import { startMetricsServer } from '../build/src/server/appFactory.js';
13 | import { addTorrentToRace } from '../build/src/racing/add.js';
14 |
15 | // This should take care of having a base config
16 | makeConfigIfNotExist();
17 | const config = loadConfig();
18 | const program = new Command();
19 |
20 | const logger = getLoggerV3();
21 | logger.info(`Starting...`);
22 |
23 | program.command('validate').description(`Validate that you've configured qbit-race correctly`).action(async () => {
24 | logger.info(`Going to login`);
25 |
26 | try {
27 | await loginV2(config.QBITTORRENT_SETTINGS);
28 | // Check discord if applicable
29 | if (config.DISCORD_NOTIFICATIONS.enabled === true){
30 | await sendMessageV2(config.DISCORD_NOTIFICATIONS.webhook, buildTorrentAddedBody(config.DISCORD_NOTIFICATIONS, {
31 | name: '[qbit-race test] Arch Linux',
32 | trackers: ['archlinux.org', 'linux.org'],
33 | size: 1024 * 1024 * 1024 * 3.412,
34 | reannounceCount: 1,
35 | }))
36 | } else {
37 | logger.info(`Skipping discord validation as it is not enabled`);
38 | }
39 |
40 | logger.info(`Succesfully validated!`);
41 | } catch (e){
42 | logger.error(`Validation failed! ${e}`);
43 | process.exit(1);
44 | }
45 | })
46 |
47 | program.command('tag-error').description(`Tag torrents for which the tracker is errored`).option('--dry-run', 'Just list torrents without actually tagging them').action(async (options) => {
48 | const api = await loginV2(config.QBITTORRENT_SETTINGS);
49 | await tagErroredTorrents(api, options.dryRun);
50 | })
51 |
52 | program.command('completed').description('Run post race procedure on complete of torrent').requiredOption('-i, --infohash ', 'The infohash of the torrent').action(async(options) => {
53 | if (options.infohash.length !== 40){
54 | logger.error(`Wrong length of infohash. Expected 40, received ${options.infohash.length}. (Provided infohash: ${options.infohash})`);
55 | process.exit(1);
56 |
57 | }
58 | const api = await loginV2(config.QBITTORRENT_SETTINGS);
59 | await postRaceResumeV2(api, config, options.infohash);
60 | })
61 |
62 | program.command('add').description('Add a new torrent').requiredOption('-p, --path ', 'The path to the .torrent file. Must be a single file, not a directory!').option('-c, --category ', 'Category to set in qBittorrent').option('--no-tracker-tags', 'Disable auto adding the trackers as tags on the torrent in qBittorrent').option('--extra-tags ', 'Comma-separated list off extra tags to add. E.g. --extra-tags "linux,foss"').action(async(options) => {
63 | logger.debug(`Going to add torrent from ${options.path}, and set category ${options.category}`);
64 | const api = await loginV2(config.QBITTORRENT_SETTINGS);
65 |
66 | await addTorrentToRace(api, config, options.path, {
67 | trackerTags: options.trackerTags, // commander is extra smart, if we define with --no , it will default boolean to true and remove `no` from the name...
68 | extraTags: options.extraTags, // The raw csv string. We will split inside the function
69 | }, options.category);
70 | })
71 |
72 | program.command('race').description('Race an existing torrent').requiredOption('-i, --infohash ', 'The infohash of the torrent already in qBittorrent. Not case sensitive').option('--no-tracker-tags', 'Disable auto adding the trackers as tags on the torrent in qBittorrent').option('--extra-tags ', 'Comma-separated list off extra tags to add. E.g. --extra-tags "linux,foss"').action(async(options) => {
73 | logger.debug(`Going to race ${options.infohash}`);
74 | logger.info(`Going to login`);
75 | const api = await loginV2(config.QBITTORRENT_SETTINGS);
76 |
77 | try {
78 | await raceExisting(api, config, options.infohash, {
79 | trackerTags: options.trackerTags, // commander is extra smart, if we define with --no , it will default boolean to true and remove `no` from the name...
80 | extraTags: options.extraTags, // The raw csv string. We will split inside the function
81 | });
82 | } catch (e){
83 | logger.error(`Failed to race ${options.infohash}. Error: ${e}. (${e.stack})`);
84 | }
85 | })
86 |
87 | program.command('metrics').description('Start a prometheus metrics server').action(async () => {
88 | const api = await loginV2(config.QBITTORRENT_SETTINGS);
89 | startMetricsServer(config, api);
90 | })
91 |
92 | program.parse();
93 |
--------------------------------------------------------------------------------
/build/src/discord/api.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | export const sendMessageV2 = (webnook, body) => {
3 | return axios.post(webnook, body);
4 | };
5 | //# sourceMappingURL=api.js.map
--------------------------------------------------------------------------------
/build/src/discord/messages.js:
--------------------------------------------------------------------------------
1 | //Prepare message JSONs for different requirements
2 | import { humanFileSize } from '../helpers/utilities.js';
3 | export const buildMessageBody = (discordSettings, partialBody) => {
4 | return {
5 | ...partialBody,
6 | username: discordSettings.botUsername,
7 | avatar_url: discordSettings.botAvatar,
8 | };
9 | };
10 | export const buildTorrentAddedBody = (discordSettings, torrentAddedInfo) => {
11 | const humanSize = humanFileSize(torrentAddedInfo.size, false, 2);
12 | let partialBody = {
13 | content: `Added ${torrentAddedInfo.name} (${humanSize})`,
14 | embeds: [
15 | {
16 | title: torrentAddedInfo.name,
17 | description: 'Added to qBittorrent',
18 | thumbnail: {
19 | url: discordSettings.botAvatar,
20 | },
21 | fields: [
22 | {
23 | name: torrentAddedInfo.trackers.length === 1 ? 'Tracker' : 'Trackers',
24 | value: torrentAddedInfo.trackers.join('\n')
25 | },
26 | {
27 | name: 'Size',
28 | value: humanSize
29 | },
30 | {
31 | name: 'Reannounce Count',
32 | value: torrentAddedInfo.reannounceCount.toString()
33 | }
34 | ]
35 | }
36 | ]
37 | };
38 | return buildMessageBody(discordSettings, partialBody);
39 | };
40 | // TODO: generalize with above
41 | export const buildRacingBody = (discordSettings, torrentAddedInfo) => {
42 | const humanSize = humanFileSize(torrentAddedInfo.size, false, 2);
43 | let partialBody = {
44 | content: `Added ${torrentAddedInfo.name} (${humanSize})`,
45 | embeds: [
46 | {
47 | title: torrentAddedInfo.name,
48 | description: 'Racing in qBittorrent',
49 | thumbnail: {
50 | url: discordSettings.botAvatar,
51 | },
52 | fields: [
53 | {
54 | name: torrentAddedInfo.trackers.length === 1 ? 'Tracker' : 'Trackers',
55 | value: torrentAddedInfo.trackers.join('\n')
56 | },
57 | {
58 | name: 'Size',
59 | value: humanSize
60 | },
61 | {
62 | name: 'Reannounce Count',
63 | value: torrentAddedInfo.reannounceCount.toString()
64 | }
65 | ]
66 | }
67 | ]
68 | };
69 | return buildMessageBody(discordSettings, partialBody);
70 | };
71 | export const buildTorrentCompletedBody = (discordSettings, torrent) => {
72 | const humanSize = humanFileSize(torrent.size, false, 2);
73 | let partialBody = {
74 | content: `Completed ${torrent.name}! (Ratio: ${torrent.ratio.toFixed(2)})`,
75 | embeds: [
76 | {
77 | title: torrent.name,
78 | description: 'Completed download',
79 | thumbnail: {
80 | url: discordSettings.botAvatar
81 | },
82 | fields: [
83 | {
84 | name: 'Ratio',
85 | value: torrent.ratio.toFixed(2).toString()
86 | },
87 | {
88 | name: torrent.tags.split(',').length === 1 ? 'Tracker' : 'Trackers',
89 | value: torrent.tags.split(',').join('\n') || 'No trackers set as tags',
90 | },
91 | {
92 | name: 'Size',
93 | value: humanSize
94 | }
95 | ]
96 | }
97 | ]
98 | };
99 | return buildMessageBody(discordSettings, partialBody);
100 | };
101 | //# sourceMappingURL=messages.js.map
--------------------------------------------------------------------------------
/build/src/helpers/constants.js:
--------------------------------------------------------------------------------
1 | export const SEEDING_STATES = ['uploading', 'stalledUP', 'forcedUP'];
2 | //# sourceMappingURL=constants.js.map
--------------------------------------------------------------------------------
/build/src/helpers/preparePromMetrics.js:
--------------------------------------------------------------------------------
1 | import { TorrentState } from "../interfaces.js";
2 | export const makeMetrics = (transferInfo) => {
3 | let result = '';
4 | result += `qbit_dl_bytes ${transferInfo.dl_info_data}\n`;
5 | result += `qbit_dl_rate_bytes ${transferInfo.dl_info_speed}\n`;
6 | result += `qbit_ul_bytes ${transferInfo.up_info_data}\n`;
7 | result += `qbit_ul_rate_bytes ${transferInfo.up_info_speed}\n`;
8 | result += `up 1\n\n`;
9 | return result;
10 | };
11 | export const countTorrentStates = (torrents) => {
12 | // Initialize counter to 0
13 | let stateCounter = {};
14 | for (let state in TorrentState) {
15 | stateCounter[state] = 0;
16 | }
17 | // Count all the states
18 | for (let torrent of torrents) {
19 | stateCounter[torrent.state]++;
20 | }
21 | return stateCounter;
22 | };
23 | export const stateMetrics = (torrents) => {
24 | let metrics = '';
25 | let stateCount = countTorrentStates(torrents);
26 | for (let state in stateCount) {
27 | metrics += `qbit_torrents_state{state="${state}"} ${stateCount[state]}\n`;
28 | }
29 | metrics += '\n';
30 | return metrics;
31 | };
32 | //# sourceMappingURL=preparePromMetrics.js.map
--------------------------------------------------------------------------------
/build/src/helpers/torrent.js:
--------------------------------------------------------------------------------
1 | import bencode from '@ckcr4lyf/bencode-esm';
2 | import * as crypto from 'crypto';
3 | /**
4 | * getTorrentMetaInfo
5 | * ---
6 | *
7 | * Takes in the raw .torrent to get:
8 | * - The torrent name
9 | * - The tracker
10 | * - The infohash (calculated by decoded .torrent, encoding info, and calculating the SHA-1 hash)
11 | *
12 | * @param torrentData The raw .torrent contents
13 | * @returns Metainfo about the torrent
14 | */
15 | export const getTorrentMetainfo = (torrentData) => {
16 | const decodedData = bencode.decode(torrentData);
17 | if (typeof decodedData.info !== 'object') {
18 | throw new Error("NO_INFO");
19 | }
20 | if (Buffer.isBuffer(decodedData.info.name) !== true) {
21 | throw new Error("NO_NAME");
22 | }
23 | if (Buffer.isBuffer(decodedData.announce) !== true) {
24 | throw new Error("NO_ANNOUNCE");
25 | }
26 | const info = decodedData.info;
27 | const reEncodedInfo = bencode.encode(info);
28 | const torrentHash = crypto.createHash('sha1').update(reEncodedInfo).digest('hex');
29 | const torrentName = info.name.toString();
30 | const announce = new URL(decodedData.announce.toString());
31 | const tracker = announce.hostname;
32 | return {
33 | hash: torrentHash,
34 | name: torrentName,
35 | tracker: tracker
36 | };
37 | };
38 | //# sourceMappingURL=torrent.js.map
--------------------------------------------------------------------------------
/build/src/helpers/utilities.js:
--------------------------------------------------------------------------------
1 | export const sleep = (ms) => {
2 | if (process.env.CI === 'true') {
3 | return;
4 | }
5 | return new Promise((resolve, reject) => {
6 | setTimeout(resolve, ms);
7 | });
8 | };
9 | //Thanks mpen @ StackOverflow! - https://stackoverflow.com/a/14919494/3857675
10 | export const humanFileSize = (bytes, si = false, dp = 1) => {
11 | const thresh = si ? 1000 : 1024;
12 | if (Math.abs(bytes) < thresh) {
13 | return bytes + ' B';
14 | }
15 | const units = si
16 | ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
17 | : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
18 | let u = -1;
19 | const r = 10 ** dp;
20 | do {
21 | bytes /= thresh;
22 | ++u;
23 | } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
24 | return bytes.toFixed(dp) + ' ' + units[u];
25 | };
26 | //# sourceMappingURL=utilities.js.map
--------------------------------------------------------------------------------
/build/src/interfaces.js:
--------------------------------------------------------------------------------
1 | export var TorrentState;
2 | (function (TorrentState) {
3 | TorrentState["error"] = "error";
4 | TorrentState["missingFiles"] = "missingFiles";
5 | TorrentState["uploading"] = "uploading";
6 | TorrentState["pausedUP"] = "pausedUP";
7 | TorrentState["queuedUP"] = "queuedUP";
8 | TorrentState["stalledUP"] = "stalledUP";
9 | TorrentState["checkingUP"] = "checkingUP";
10 | TorrentState["forcedUP"] = "forcedUP";
11 | TorrentState["allocating"] = "allocating";
12 | TorrentState["downloading"] = "downloading";
13 | TorrentState["metaDL"] = "metaDL";
14 | TorrentState["pausedDL"] = "pausedDL";
15 | TorrentState["queuedDL"] = "queuedDL";
16 | TorrentState["stalledDL"] = "stalledDL";
17 | TorrentState["checkingDL"] = "checkingDL";
18 | TorrentState["forcedDL"] = "forcedDL";
19 | TorrentState["checkingResumeData"] = "checkingResumeData";
20 | TorrentState["moving"] = "moving";
21 | TorrentState["unknown"] = "unknown";
22 | })(TorrentState || (TorrentState = {}));
23 | export const SEEDING_STATES = [TorrentState.uploading, TorrentState.stalledUP, TorrentState.forcedUP];
24 | export var TrackerStatus;
25 | (function (TrackerStatus) {
26 | TrackerStatus[TrackerStatus["WORKING"] = 2] = "WORKING";
27 | })(TrackerStatus || (TrackerStatus = {}));
28 | //# sourceMappingURL=interfaces.js.map
--------------------------------------------------------------------------------
/build/src/qbittorrent/api.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import FormData from 'form-data';
3 | export class QbittorrentApi {
4 | constructor(basePath, cookie) {
5 | this.basePath = basePath;
6 | this.cookie = cookie;
7 | this.client = axios.create({
8 | baseURL: basePath,
9 | headers: {
10 | 'Cookie': cookie,
11 | }
12 | });
13 | this.version = 'v4'; // default to v4
14 | }
15 | async getAndSetVersion() {
16 | try {
17 | const response = await this.client.get(ApiEndpoints.version);
18 | console.log(response.data);
19 | this.version = response.data;
20 | return response.data;
21 | }
22 | catch (e) {
23 | throw new Error(`Failed to get qBittorrent version. Error: ${e}`);
24 | }
25 | }
26 | async getTorrents(hashes) {
27 | const params = {};
28 | if (Array.isArray(hashes)) {
29 | params.hashes = hashes.join('|');
30 | }
31 | try {
32 | const response = await this.client.get(ApiEndpoints.torrentsInfo, {
33 | params: params,
34 | });
35 | return response.data;
36 | }
37 | catch (e) {
38 | throw new Error(`Failed to get torrents from qBittorrent API. Error: ${e}`);
39 | }
40 | }
41 | // Just wraps getTorrents as a convenience method for single torrent
42 | async getTorrent(infohash) {
43 | const torrents = await this.getTorrents([infohash]);
44 | if (torrents.length === 0) {
45 | throw new Error(`Torrent not found! (Infohash = ${infohash})`);
46 | }
47 | return torrents[0];
48 | }
49 | async getTrackers(infohash) {
50 | const response = await this.client.get(ApiEndpoints.torrentTrackers, {
51 | params: {
52 | hash: infohash,
53 | }
54 | });
55 | return response.data;
56 | }
57 | async addTags(torrents, tags) {
58 | if (torrents.length === 0) {
59 | return;
60 | }
61 | if (tags.length === 0) {
62 | return;
63 | }
64 | const infohashes = torrents.map(torrent => torrent.hash);
65 | const payload = `hashes=${infohashes.join('|')}&tags=${tags.join(',')}`;
66 | try {
67 | await this.client.post(ApiEndpoints.addTags, payload, {
68 | headers: {
69 | 'Content-Type': 'application/x-www-form-urlencoded',
70 | }
71 | });
72 | }
73 | catch (e) {
74 | throw new Error(`Failed to add tags to torrent: ${e}`);
75 | }
76 | }
77 | async setCategory(infohash, category) {
78 | const payload = `hashes=${infohash}&category=${category}`;
79 | await this.client.post(ApiEndpoints.setCategory, payload, {
80 | headers: {
81 | 'Content-Type': 'application/x-www-form-urlencoded',
82 | }
83 | });
84 | }
85 | async resumeTorrents(torrents) {
86 | const infohashes = torrents.map(torrent => torrent.hash);
87 | const endpoint = this.version >= 'v5' ? ApiEndpoints.resumeTorrentsNew : ApiEndpoints.resumeTorrents;
88 | const payload = `hashes=${infohashes.join('|')}`;
89 | try {
90 | await this.client.post(endpoint, payload, {
91 | headers: {
92 | 'Content-Type': 'application/x-www-form-urlencoded',
93 | }
94 | });
95 | }
96 | catch (e) {
97 | throw new Error(`Failed to resume torrents. Error: ${e}`);
98 | }
99 | }
100 | async pauseTorrents(torrents) {
101 | if (torrents.length === 0) {
102 | return;
103 | }
104 | const endpoint = this.version >= 'v5' ? ApiEndpoints.pauseTorrentsNew : ApiEndpoints.pauseTorrents;
105 | const payload = `hashes=${torrents.map(torrent => torrent.hash).join('|')}`;
106 | await this.client.post(endpoint, payload, {
107 | headers: {
108 | 'Content-Type': 'application/x-www-form-urlencoded',
109 | }
110 | });
111 | }
112 | async deleteTorrentsWithFiles(torrents) {
113 | if (torrents.length === 0) {
114 | return;
115 | }
116 | await this.client.get(ApiEndpoints.deleteTorrents, {
117 | params: {
118 | hashes: torrents.map(torrent => torrent.hash).join('|'),
119 | deleteFiles: true,
120 | }
121 | });
122 | }
123 | async addTorrent(torrentData, category) {
124 | let formData = new FormData();
125 | formData.append("torrents", torrentData, 'dummy.torrent'); // The filename doesn't really matter
126 | if (category !== undefined) {
127 | formData.append('category', category);
128 | }
129 | await this.client.post(ApiEndpoints.addTorrent, formData, {
130 | headers: {
131 | ...formData.getHeaders(),
132 | //Because axios can't handle this. Wasted 2 hours trying to debug. Fuck.
133 | 'Content-Length': formData.getLengthSync(),
134 | }
135 | });
136 | }
137 | async reannounce(infohash) {
138 | const payload = `hashes=${infohash}`;
139 | try {
140 | await this.client.post(ApiEndpoints.reannounce, payload, {
141 | headers: {
142 | 'Content-Type': 'application/x-www-form-urlencoded',
143 | }
144 | });
145 | }
146 | catch (e) {
147 | throw new Error(`Failed to reannounce! Error: ${e}`);
148 | }
149 | }
150 | async getTransferInfo() {
151 | const response = await this.client.get(ApiEndpoints.transferInfo);
152 | return response.data;
153 | }
154 | }
155 | var ApiEndpoints;
156 | (function (ApiEndpoints) {
157 | ApiEndpoints["login"] = "/api/v2/auth/login";
158 | ApiEndpoints["torrentsInfo"] = "/api/v2/torrents/info";
159 | ApiEndpoints["torrentTrackers"] = "/api/v2/torrents/trackers";
160 | ApiEndpoints["resumeTorrents"] = "/api/v2/torrents/resume";
161 | ApiEndpoints["resumeTorrentsNew"] = "/api/v2/torrents/start";
162 | ApiEndpoints["addTags"] = "/api/v2/torrents/addTags";
163 | ApiEndpoints["setCategory"] = "/api/v2/torrents/setCategory";
164 | ApiEndpoints["pauseTorrents"] = "/api/v2/torrents/pause";
165 | ApiEndpoints["pauseTorrentsNew"] = "/api/v2/torrents/stop";
166 | ApiEndpoints["addTorrent"] = "/api/v2/torrents/add";
167 | ApiEndpoints["deleteTorrents"] = "/api/v2/torrents/delete";
168 | ApiEndpoints["reannounce"] = "/api/v2/torrents/reannounce";
169 | ApiEndpoints["transferInfo"] = "/api/v2/transfer/info";
170 | ApiEndpoints["version"] = "/api/v2/app/version";
171 | })(ApiEndpoints || (ApiEndpoints = {}));
172 | export const login = (qbittorrentSettings) => {
173 | return axios.post(`${qbittorrentSettings.url}${ApiEndpoints.login}`, {
174 | username: qbittorrentSettings.username,
175 | password: qbittorrentSettings.password
176 | }, {
177 | headers: {
178 | 'Content-Type': 'application/x-www-form-urlencoded',
179 | }
180 | });
181 | };
182 | //# sourceMappingURL=api.js.map
--------------------------------------------------------------------------------
/build/src/qbittorrent/auth.js:
--------------------------------------------------------------------------------
1 | import { getLoggerV3 } from '../utils/logger.js';
2 | import { login as apiLogin, QbittorrentApi } from './api.js';
3 | export const loginV2 = async (qbittorrentSettings) => {
4 | const logger = getLoggerV3();
5 | const response = await apiLogin(qbittorrentSettings);
6 | if (response.status != 200) {
7 | logger.error(`Unknown error logging in!\nStatus: ${response.status}\nHeaders: ${response.headers}\nBody: ${response.data}`);
8 | throw new Error("Failed to authenticate (UNKNOWN ERROR)");
9 | }
10 | if (response.data === 'Fails.') {
11 | logger.warn(`Incorrect credentials!`);
12 | throw new Error("Incorrect credentials");
13 | }
14 | if (response.headers['set-cookie'] === undefined || Array.isArray(response.headers['set-cookie']) === false || response.headers['set-cookie'].length === 0) {
15 | logger.error(`Missing set cookie header from response!\nStatus: ${response.status}\nHeaders: ${response.headers}\nBody: ${response.data}`);
16 | throw new Error(`Failed to authenticate (UNKNOWN ERROR)`);
17 | }
18 | const api = new QbittorrentApi(qbittorrentSettings.url, response.headers['set-cookie'][0]);
19 | // Need to get the version so we can choose which endpoints to use
20 | // See: https://github.com/ckcr4lyf/qbit-race/issues/52
21 | const version = await api.getAndSetVersion();
22 | logger.info(`Detected qBitorrent version as: ${version}`);
23 | return api;
24 | };
25 | //# sourceMappingURL=auth.js.map
--------------------------------------------------------------------------------
/build/src/racing/add.js:
--------------------------------------------------------------------------------
1 | /**
2 | * To add a new torrent into qbittorrent for racing
3 | */
4 | import { getTorrentMetainfo } from "../helpers/torrent.js";
5 | import { getLoggerV3 } from "../utils/logger.js";
6 | import * as fs from 'fs';
7 | import { concurrentRacesCheck, getTorrentsToPause } from "./preRace.js";
8 | import { sleep } from "../helpers/utilities.js";
9 | import { TrackerStatus } from "../interfaces.js";
10 | import { buildTorrentAddedBody } from "../discord/messages.js";
11 | import { sendMessageV2 } from "../discord/api.js";
12 | import axios from "axios";
13 | export const addTorrentToRace = async (api, settings, path, options, category) => {
14 | const logger = getLoggerV3();
15 | logger.debug(`Called with path: ${path}, category: ${category}`);
16 | // Read the torrent file and get info
17 | let torrentFile;
18 | try {
19 | torrentFile = fs.readFileSync(path);
20 | }
21 | catch (e) {
22 | logger.error(`Failed to read torrent from ${e}`);
23 | process.exit(1);
24 | }
25 | let torrentMetainfo;
26 | try {
27 | torrentMetainfo = getTorrentMetainfo(torrentFile);
28 | }
29 | catch (e) {
30 | logger.error(`Fail to parse torrent file`);
31 | process.exit(1);
32 | }
33 | // Do pre race check to determine if we should add this torrent
34 | let torrents;
35 | try {
36 | torrents = await api.getTorrents();
37 | }
38 | catch (e) {
39 | logger.error(`Failed to get torrents from qbittorrent: ${e}`);
40 | process.exit(1);
41 | }
42 | const goodToRace = concurrentRacesCheck(settings, torrents);
43 | if (goodToRace === false) {
44 | logger.info(`Pre race conditions not met. Skipping ${torrentMetainfo.name}`);
45 | process.exit(0);
46 | }
47 | // TODO: Move to common race part
48 | const torrentsToPause = getTorrentsToPause(settings, torrents);
49 | try {
50 | logger.debug(`Going to pause ${torrentsToPause.length} torrents for the race...`);
51 | await api.pauseTorrents(torrentsToPause);
52 | }
53 | catch (e) {
54 | logger.error(`Failed to pause torrents: ${e}`);
55 | process.exit(1);
56 | }
57 | try {
58 | await api.addTorrent(torrentFile, category);
59 | }
60 | catch (e) {
61 | logger.error(`Failed to add torrent to qbittorrent: ${e}`);
62 | process.exit(1);
63 | }
64 | // Wait for torrent to register in qbit, initial announce
65 | logger.debug(`Going to sleep for 5 seconds to allow torrent to register...`);
66 | await sleep(5000);
67 | logger.debug(`Finished sleeping, going to get trackers`);
68 | // Get the torrent's trackers, which we set as tags as well.
69 | const trackersAsTags = [];
70 | let registeredFlag = false;
71 | for (let i = 0; i < 25; i++) {
72 | logger.debug(`Attempt #${i + 1} to get trackers`);
73 | try {
74 | const trackers = await api.getTrackers(torrentMetainfo.hash);
75 | trackers.splice(0, 3); // Get rid of DHT, PEX etc.
76 | trackersAsTags.push(...trackers.map(tracker => new URL(tracker.url).hostname));
77 | logger.info(`Successfully got trackers!`);
78 | registeredFlag = true;
79 | break;
80 | }
81 | catch (e) {
82 | if (axios.isAxiosError(e) && e.response?.status === 404) {
83 | logger.warn(`Got 404 from qbittorrent, probably not registered yet... Will sleep for a second and try again. (Error: ${e})`);
84 | await sleep(1000);
85 | continue;
86 | }
87 | logger.error(`Failed to get tags for torrent: ${e}`);
88 | process.exit(1);
89 | }
90 | }
91 | if (registeredFlag === false) {
92 | logger.error(`Failed to get torrent from qbit, maybe not registered!`);
93 | process.exit(1);
94 | }
95 | const tagsToAdd = [];
96 | if (options.trackerTags === false) {
97 | logger.debug(`--no-tracker-tags specified, will skip adding them to the torrent!`);
98 | }
99 | else {
100 | tagsToAdd.push(...trackersAsTags);
101 | }
102 | if (options.extraTags !== undefined) {
103 | const extraTags = options.extraTags.split(',');
104 | logger.debug(`Going to add extra tags: ${extraTags}`);
105 | tagsToAdd.push(...extraTags);
106 | }
107 | if (tagsToAdd.length !== 0) {
108 | try {
109 | await api.addTags([torrentMetainfo], tagsToAdd);
110 | }
111 | catch (e) {
112 | logger.error(`Failed to add tags to torrent: ${e}`);
113 | process.exit(1);
114 | }
115 | }
116 | let torrent;
117 | try {
118 | torrent = await api.getTorrent(torrentMetainfo.hash);
119 | }
120 | catch (e) {
121 | logger.error(`Failed to get information of torrent from qbit: ${e}`);
122 | process.exit(1);
123 | }
124 | logger.debug(`Startng reannounce check`);
125 | let attempts = 0;
126 | let announceOk = false;
127 | while (attempts < settings.REANNOUNCE_LIMIT) {
128 | logger.debug(`Reannounce attempt #${attempts + 1}`);
129 | try {
130 | const trackers = await api.getTrackers(torrentMetainfo.hash);
131 | trackers.splice(0, 3); // Get rid of DHT, PEX etc.
132 | const working = trackers.some(tracker => tracker.status === TrackerStatus.WORKING);
133 | // Need to reannounce
134 | if (working === false) {
135 | logger.debug(`No working tracker. Will reannounce and sleep...`);
136 | await api.reannounce(torrent.hash);
137 | await sleep(settings.REANNOUNCE_INTERVAL);
138 | attempts++;
139 | }
140 | else {
141 | announceOk = true;
142 | logger.debug(`Tracker is OK!`);
143 | break;
144 | }
145 | }
146 | catch (e) {
147 | logger.error(`Failed to reannounce: ${e}`);
148 | process.exit(1);
149 | }
150 | }
151 | if (announceOk === false) {
152 | logger.warn(`Did not get an OK from tracker even after ${settings.REANNOUNCE_LIMIT} re-announces. Deleting torrent...`);
153 | try {
154 | await api.deleteTorrentsWithFiles([torrentMetainfo]);
155 | }
156 | catch (e) {
157 | logger.error(`Failed to delete torrent: ${e}`);
158 | process.exit(1);
159 | }
160 | // Resume any torrents that were paused for the race
161 | logger.debug(`Going to resume paused torrents since no race`);
162 | try {
163 | await api.resumeTorrents(torrentsToPause);
164 | }
165 | catch (e) {
166 | logger.error(`Failed to resume torrents: ${e}`);
167 | process.exit(1);
168 | }
169 | // Clean exit
170 | process.exit(0);
171 | }
172 | // Announce was good!
173 | logger.info(`Successfully added ${torrentMetainfo.name}!`);
174 | if (settings.DISCORD_NOTIFICATIONS.enabled === true) {
175 | const torrentAddedMessage = buildTorrentAddedBody(settings.DISCORD_NOTIFICATIONS, {
176 | name: torrent.name,
177 | size: torrent.size,
178 | trackers: trackersAsTags,
179 | reannounceCount: attempts
180 | });
181 | try {
182 | await sendMessageV2(settings.DISCORD_NOTIFICATIONS.webhook, torrentAddedMessage);
183 | }
184 | catch (e) {
185 | logger.error(`Failed to send message to discord: ${e}`);
186 | // Do not throw for this failure.
187 | }
188 | }
189 | // Clean exit
190 | // TODO: This guy can return to upper caller instead?
191 | process.exit(0);
192 | };
193 | //# sourceMappingURL=add.js.map
--------------------------------------------------------------------------------
/build/src/racing/common.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Racing a torrent consists of a few steps:
3 | *
4 | * # Case 1: qbit-race used to add a new torrent
5 | *
6 | * [Pre addition]
7 | *
8 | * 1. Get list of existing torrents
9 | * 2. Check concurrent racing rules to see if it is ok to add
10 | * a. Skip race if existing torrents
11 | * 3. Pause torrents before adding
12 | * 4. Add torrent
13 | *
14 | * [Post addition]
15 | * 5. Try and reannounce if applicable
16 | *
17 | * [Successfully announce]
18 | * 6a. Discord message
19 | *
20 | * [Fail to reannounce]
21 | * 6b. Delete torrent, resume existing torrents
22 | *
23 | * # Case 2: qbit-race used to apply "race mode" to existing torrent
24 | *
25 | * 1. Try and pause torrents
26 | * 2. Try and reannounce if applicable
27 | *
28 | * [Successfully announce]
29 | * 3a. Discord message
30 | *
31 | * [Fail to reannounce]
32 | * 3b. noop
33 | */
34 | import { sendMessageV2 } from "../discord/api.js";
35 | import { buildRacingBody } from "../discord/messages.js";
36 | import { sleep } from "../helpers/utilities.js";
37 | import { TrackerStatus } from "../interfaces.js";
38 | import { getLoggerV3 } from "../utils/logger.js";
39 | import { getTorrentsToPause } from "./preRace.js";
40 | /**
41 | * raceExisting will try and "race" and existing torrent, by:
42 | *
43 | * * Pausing existing torrents
44 | * * Try and reannounce if applicable
45 | * @param api The qbittorrent API
46 | * @param settings Settings for checking pause ratio etc.
47 | * @param infohash Infohash of newly added torrent
48 | */
49 | export const raceExisting = async (api, settings, infohash, options) => {
50 | const logger = getLoggerV3();
51 | logger.debug(`raceExisting called with infohash: ${infohash}`);
52 | const torrent = await api.getTorrent(infohash);
53 | // Try and pause existing torrents
54 | const torrents = await (async () => {
55 | try {
56 | const xd = await api.getTorrents();
57 | return xd;
58 | }
59 | catch (e) {
60 | logger.error(`Failed to get torrents from qBittorrent API: ${e}`);
61 | process.exit(-1);
62 | }
63 | })();
64 | const torrentsToPause = getTorrentsToPause(settings, torrents);
65 | logger.debug(`Going to pause ${torrentsToPause.length} torrents`);
66 | try {
67 | await api.pauseTorrents(torrentsToPause);
68 | }
69 | catch (e) {
70 | logger.error(`Failed to pause torrents: ${e}`);
71 | process.exit(-1);
72 | }
73 | let trackersAsTags = [];
74 | if (options.trackerTags === false) {
75 | logger.debug(`--no-tracker-tags specified, will skip adding them to the torrent!`);
76 | }
77 | else {
78 | trackersAsTags = await addTrackersAsTags(api, settings, infohash);
79 | }
80 | if (options.extraTags !== undefined) {
81 | const extraTags = options.extraTags.split(',');
82 | logger.debug(`Adding extra tags: ${extraTags}`);
83 | await api.addTags([{ hash: infohash }], extraTags);
84 | }
85 | const announceSummary = await reannounce(api, settings, torrent);
86 | if (announceSummary.ok === false) {
87 | logger.debug(`Going to resume torrents since failed to race`);
88 | await api.resumeTorrents(torrentsToPause);
89 | process.exit(0);
90 | }
91 | logger.info(`Successfully racing ${torrent.name}`);
92 | if (settings.DISCORD_NOTIFICATIONS.enabled === true) {
93 | const torrentAddedMessage = buildRacingBody(settings.DISCORD_NOTIFICATIONS, {
94 | name: torrent.name,
95 | size: torrent.size,
96 | trackers: trackersAsTags,
97 | reannounceCount: announceSummary.count
98 | });
99 | try {
100 | await sendMessageV2(settings.DISCORD_NOTIFICATIONS.webhook, torrentAddedMessage);
101 | }
102 | catch (e) {
103 | logger.error(`Failed to send message to discord: ${e}`);
104 | // Do not throw for this failure.
105 | }
106 | }
107 | process.exit(0);
108 | };
109 | export const reannounce = async (api, settings, torrent) => {
110 | const logger = getLoggerV3();
111 | logger.debug(`Starting reannounce check`);
112 | let attempts = 0;
113 | while (attempts < settings.REANNOUNCE_LIMIT) {
114 | logger.debug(`Reannounce attempt #${attempts + 1}`);
115 | const trackers = await api.getTrackers(torrent.hash);
116 | trackers.splice(0, 3); // Get rid of DHT, PEX etc.
117 | const working = trackers.some(tracker => tracker.status === TrackerStatus.WORKING);
118 | // Need to reannounce
119 | if (working === false) {
120 | logger.debug(`No working tracker. Will reannounce and sleep...`);
121 | await api.reannounce(torrent.hash);
122 | await sleep(settings.REANNOUNCE_INTERVAL);
123 | attempts++;
124 | }
125 | else {
126 | logger.debug(`Tracker is OK!`);
127 | return {
128 | ok: true,
129 | count: attempts
130 | };
131 | }
132 | }
133 | logger.debug(`Did not get OK from tracker even after ${settings.REANNOUNCE_LIMIT} re-announces!`);
134 | return {
135 | ok: false,
136 | count: attempts
137 | };
138 | };
139 | export const addTrackersAsTags = async (api, settings, infohash) => {
140 | const trackerNames = [];
141 | // Get the tags, map out hostname
142 | const trackers = await api.getTrackers(infohash);
143 | trackers.splice(0, 3); // Get rid of DHT, PEX etc.
144 | trackerNames.push(...trackers.map(tracker => new URL(tracker.url).hostname));
145 | // Add the tags
146 | await api.addTags([{ hash: infohash }], trackerNames);
147 | return trackerNames;
148 | };
149 | //# sourceMappingURL=common.js.map
--------------------------------------------------------------------------------
/build/src/racing/completed.js:
--------------------------------------------------------------------------------
1 | import { sendMessageV2 } from "../discord/api.js";
2 | import { buildTorrentCompletedBody } from "../discord/messages.js";
3 | import { TorrentState } from "../interfaces.js";
4 | import { getLoggerV3 } from "../utils/logger.js";
5 | export const postRaceResumeV2 = async (api, settings, infohash) => {
6 | const logger = getLoggerV3();
7 | let torrentInfo;
8 | try {
9 | torrentInfo = await api.getTorrent(infohash);
10 | }
11 | catch (e) {
12 | logger.error(`Failed to get torrent from API ${e}`);
13 | throw e;
14 | }
15 | logger.info(`${torrentInfo.name} completed... running post race script`);
16 | // Handle category change stuff if applicable
17 | if (torrentInfo.category in settings.CATEGORY_FINISH_CHANGE) {
18 | const newCategory = settings.CATEGORY_FINISH_CHANGE[torrentInfo.category];
19 | logger.debug(`Found entry in category change map. Changing from ${torrentInfo.category} to ${newCategory}`);
20 | try {
21 | await api.setCategory(torrentInfo.hash, newCategory);
22 | logger.debug(`Successfully set category to ${newCategory}`);
23 | }
24 | catch (e) {
25 | logger.error(`Failed to set category. ${e}`);
26 | }
27 | }
28 | let torrents;
29 | try {
30 | torrents = await api.getTorrents();
31 | }
32 | catch (e) {
33 | logger.error(`Failed to get torrents list from API! ${e}`);
34 | process.exit(1);
35 | }
36 | // Handle discord part if enabled
37 | if (settings.DISCORD_NOTIFICATIONS.enabled === true) {
38 | const messageBody = buildTorrentCompletedBody(settings.DISCORD_NOTIFICATIONS, torrentInfo);
39 | try {
40 | await sendMessageV2(settings.DISCORD_NOTIFICATIONS.webhook, messageBody);
41 | logger.debug(`Sent message to discord`);
42 | }
43 | catch (e) {
44 | logger.error(`Failed to send message to discord ${e}`);
45 | }
46 | }
47 | else {
48 | logger.debug(`Discord notifications disabled.`);
49 | }
50 | // The earliest time, from which there may still be a torrent in the reannounce phase
51 | // e.g. if interval is 10s, and limit is 6, then from now-60s, any torrents in stalledDL status
52 | // are still in the re-announce phase (for racing) , so we do not run the resume job
53 | const oldestRaceLimit = Date.now() - (settings.REANNOUNCE_INTERVAL * settings.REANNOUNCE_LIMIT);
54 | for (let x = 0; x < torrents.length; x++) {
55 | if (torrents[x].state === TorrentState.downloading) {
56 | logger.debug(`${torrents[x].name} is still downloading, won't resume`);
57 | return;
58 | }
59 | if (torrents[x].state === TorrentState.stalledDL && torrents[x].added_on * 1000 > oldestRaceLimit) {
60 | logger.debug(`${torrents[x].name} is in re-announce phase, won't resume`);
61 | return;
62 | }
63 | }
64 | // Check if anything is paused
65 | const pausedTorrents = torrents.filter(torrent => torrent.state === TorrentState.pausedUP);
66 | if (pausedTorrents.length === 0) {
67 | logger.debug(`Nothing to resume (no paused torrents)`);
68 | return;
69 | }
70 | if (settings.SKIP_RESUME === true) {
71 | logger.debug(`Skip resume is true, not resuming anything...`);
72 | return;
73 | }
74 | // Try and resume
75 | try {
76 | await api.resumeTorrents(pausedTorrents);
77 | logger.debug(`Successfully resumed torrents`);
78 | }
79 | catch (e) {
80 | logger.error(`Failed to resume torrents! ${e}`);
81 | process.exit(1);
82 | }
83 | };
84 | //# sourceMappingURL=completed.js.map
--------------------------------------------------------------------------------
/build/src/racing/preRace.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Functions to do some pre-race checks
3 | */
4 | import { SEEDING_STATES, TorrentState } from "../interfaces.js";
5 | import { getLoggerV3 } from "../utils/logger.js";
6 | export const concurrentRacesCheck = (settings, torrents) => {
7 | const logger = getLoggerV3();
8 | if (settings.CONCURRENT_RACES === -1) {
9 | logger.debug(`CONCURRENT_RACES set to -1, not checking existing torrents...`);
10 | return true;
11 | }
12 | const downloading = torrents.filter(torrent => torrent.state === TorrentState.downloading);
13 | if (downloading.length >= settings.CONCURRENT_RACES) {
14 | logger.debug(`Downloading ${downloading.length} and concurrent limit is ${settings.CONCURRENT_RACES}. Wont add`);
15 | return false;
16 | }
17 | // Next we see if any potential new races are in the stalled stage
18 | // The earliest time, from which there may still be a torrent in the reannounce phase
19 | // e.g. if interval is 10s, and limit is 6, then from now-60s, any torrents in stalledDL status
20 | // are still in the re-announce phase (for racing) , so they should be counted as "downloading"
21 | const oldestRaceLimit = Date.now() - (settings.REANNOUNCE_INTERVAL * settings.REANNOUNCE_LIMIT);
22 | const stalledDownloading = torrents.filter(torrent => {
23 | if (torrent.state !== TorrentState.stalledDL) {
24 | return false;
25 | }
26 | // Count irrespective of age
27 | if (settings.COUNT_STALLED_DOWNLOADS === true) {
28 | return true;
29 | }
30 | // If its still in the reannounce phase
31 | if (torrent.added_on * 1000 > oldestRaceLimit) {
32 | return true;
33 | }
34 | return false;
35 | });
36 | logger.debug(`Currently ${stalledDownloading.length} stalled downloading torrents`);
37 | if (downloading.length + stalledDownloading.length >= settings.CONCURRENT_RACES) {
38 | logger.debug(`Sum of downloading and stalled downloading is ${downloading.length + stalledDownloading.length} and concurrent limit is ${settings.CONCURRENT_RACES}. Wont add`);
39 | return false;
40 | }
41 | return true;
42 | };
43 | export const getTorrentsToPause = (settings, torrents) => {
44 | const logger = getLoggerV3();
45 | if (settings.PAUSE_RATIO === -1) {
46 | logger.debug(`Pause ratio is -1, wont pause any torrents`);
47 | return [];
48 | }
49 | const torrentsToPause = torrents.filter(torrent => {
50 | // Not seeding - no need to pause
51 | if (SEEDING_STATES.some(state => state === torrent.state) === false) {
52 | return false;
53 | }
54 | // If ratio below pause ratio then dont
55 | if (torrent.ratio < settings.PAUSE_RATIO) {
56 | logger.debug(`Ratio for ${torrent.name} is ${torrent.ratio} - below pause ratio. Wont pause`);
57 | return false;
58 | }
59 | // If the cateogry is set to skip then dont
60 | if (settings.PAUSE_SKIP_CATEGORIES.includes(torrent.category)) {
61 | logger.debug(`Category for ${torrent.name} is ${torrent.category} - included in PAUSE_SKIP_CATEGORIES. Wont pause`);
62 | return false;
63 | }
64 | // Lastly - if any tags are to not skip then dont
65 | const torrentTags = torrent.tags.split(',');
66 | const skipTag = settings.PAUSE_SKIP_TAGS.find(tag => torrentTags.includes(tag));
67 | if (skipTag !== undefined) {
68 | logger.debug(`Tags for ${torrent.name} contains ${skipTag} - included in PAUSE_SKIP_TAGS. Wont pause`);
69 | return false;
70 | }
71 | // Otherwise we should pause this guy for the upcoming race
72 | return true;
73 | });
74 | return torrentsToPause;
75 | };
76 | //# sourceMappingURL=preRace.js.map
--------------------------------------------------------------------------------
/build/src/racing/tag.js:
--------------------------------------------------------------------------------
1 | import { getLoggerV3 } from "../utils/logger.js";
2 | export const tagErroredTorrents = async (api, dryRun) => {
3 | const logger = getLoggerV3();
4 | logger.info(`Starting...`);
5 | if (dryRun === true) {
6 | logger.info(`--dry-run specified, will not tag any torrents`);
7 | }
8 | let torrents;
9 | try {
10 | torrents = await api.getTorrents();
11 | }
12 | catch (e) {
13 | logger.error(`Failed to get torrents from api`);
14 | throw new Error("api failure");
15 | }
16 | let torrentsToTag = [];
17 | for (const torrent of torrents) {
18 | const trackers = await api.getTrackers(torrent.hash);
19 | trackers.splice(0, 3); // Get rid of DHT PEX etc.
20 | // See if at least one has status=2 , i.e. working
21 | const working = trackers.some(tracker => tracker.status === 2);
22 | if (working === false) {
23 | logger.warn(`[${torrent.hash}] tracker error for ${torrent.name}`);
24 | torrentsToTag.push(torrent);
25 | }
26 | }
27 | if (torrentsToTag.length === 0) {
28 | logger.info(`No torrents to tag`);
29 | return;
30 | }
31 | if (dryRun === true) {
32 | logger.info(`Reached end of dry run. Exiting...`);
33 | return;
34 | }
35 | logger.info(`Going to tag ${torrentsToTag.length} torrents...`);
36 | try {
37 | await api.addTags(torrentsToTag, ['error']);
38 | }
39 | catch (e) {
40 | logger.error(`Failed to tag torrents`);
41 | }
42 | logger.info(`Sucessfully tagged ${torrentsToTag.length} torrents!`);
43 | };
44 | //# sourceMappingURL=tag.js.map
--------------------------------------------------------------------------------
/build/src/server/app.js:
--------------------------------------------------------------------------------
1 | // The server for serving prometheus metrics
2 | import fastify from 'fastify';
3 | import { makeMetrics, stateMetrics } from '../helpers/preparePromMetrics.js';
4 | import { loginV2 } from '../qbittorrent/auth.js';
5 | import { loadConfig, makeConfigIfNotExist } from '../utils/configV2.js';
6 | import { getLoggerV3 } from '../utils/logger.js';
7 | const server = fastify();
8 | const logger = getLoggerV3({ logfile: 'prometheus-exporter-logs.txt' });
9 | makeConfigIfNotExist();
10 | const config = loadConfig();
11 | let api;
12 | try {
13 | api = await loginV2(config.QBITTORRENT_SETTINGS);
14 | }
15 | catch (e) {
16 | logger.error(`Failed to login: ${e}`);
17 | process.exit(1);
18 | }
19 | server.get('/metrics', async (request, reply) => {
20 | const transferInfo = await api.getTransferInfo();
21 | const torrents = await api.getTorrents();
22 | let finalMetrics = '';
23 | finalMetrics += makeMetrics(transferInfo);
24 | finalMetrics += stateMetrics(torrents);
25 | reply.status(200).headers({
26 | 'Content-Type': 'text/plain'
27 | }).send(finalMetrics);
28 | });
29 | const PROM_PORT = config.PROMETHEUS_SETTINGS.port;
30 | const PROM_IP = config.PROMETHEUS_SETTINGS.ip;
31 | server.listen(PROM_PORT, PROM_IP, (err, address) => {
32 | if (err) {
33 | logger.error(`Failed to bind to ${PROM_IP}:${PROM_PORT}. Exiting...`);
34 | process.exit(1);
35 | }
36 | logger.info(`Server started at ${PROM_IP}:${PROM_PORT}`);
37 | });
38 | //# sourceMappingURL=app.js.map
--------------------------------------------------------------------------------
/build/src/server/appFactory.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Expose a function that could be called from the binary
3 | */
4 | import fastify from "fastify";
5 | import { makeMetrics, stateMetrics } from "../helpers/preparePromMetrics.js";
6 | import { getLoggerV3 } from "../utils/logger.js";
7 | export const startMetricsServer = (config, api) => {
8 | const server = fastify();
9 | const logger = getLoggerV3({ logfile: 'prometheus-exporter-logs.txt' });
10 | server.get('/metrics', async (request, reply) => {
11 | const transferInfo = await api.getTransferInfo();
12 | const torrents = await api.getTorrents();
13 | let finalMetrics = '';
14 | finalMetrics += makeMetrics(transferInfo);
15 | finalMetrics += stateMetrics(torrents);
16 | reply.status(200).headers({
17 | 'Content-Type': 'text/plain'
18 | }).send(finalMetrics);
19 | });
20 | const PROM_PORT = config.PROMETHEUS_SETTINGS.port;
21 | const PROM_IP = config.PROMETHEUS_SETTINGS.ip;
22 | server.listen(PROM_PORT, PROM_IP, (err, address) => {
23 | if (err) {
24 | logger.error(`Failed to bind to ${PROM_IP}:${PROM_PORT}. Exiting...`);
25 | process.exit(1);
26 | }
27 | logger.info(`Server started at ${PROM_IP}:${PROM_PORT}`);
28 | });
29 | };
30 | //# sourceMappingURL=appFactory.js.map
--------------------------------------------------------------------------------
/build/src/utils/config.js:
--------------------------------------------------------------------------------
1 | export const defaultSettings = {
2 | REANNOUNCE_INTERVAL: 5000,
3 | REANNOUNCE_LIMIT: 30,
4 | PAUSE_RATIO: 1,
5 | PAUSE_SKIP_TAGS: ["tracker.linux.org", "some_other_tag"],
6 | PAUSE_SKIP_CATEGORIES: ["permaseeding", "some_other_category"],
7 | SKIP_RESUME: false,
8 | CONCURRENT_RACES: 1,
9 | COUNT_STALLED_DOWNLOADS: false,
10 | QBITTORRENT_SETTINGS: {
11 | url: 'http://localhost:8080',
12 | username: 'admin',
13 | password: 'adminadmin',
14 | },
15 | PROMETHEUS_SETTINGS: {
16 | ip: '127.0.0.1',
17 | port: 9999,
18 | },
19 | DISCORD_NOTIFICATIONS: {
20 | enabled: false,
21 | webhook: '',
22 | botUsername: 'qBittorrent',
23 | botAvatar: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/66/New_qBittorrent_Logo.svg/600px-New_qBittorrent_Logo.svg.png'
24 | },
25 | CATEGORY_FINISH_CHANGE: {
26 | 'OLD_CATEGORY': 'NEW_CATEORY'
27 | }
28 | };
29 | //# sourceMappingURL=config.js.map
--------------------------------------------------------------------------------
/build/src/utils/configV2.js:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import fs from 'node:fs';
3 | import os from 'node:os';
4 | import { defaultSettings } from './config.js';
5 | import { getLoggerV3 } from './logger.js';
6 | const getConfigDir = () => {
7 | const homeDir = os.homedir();
8 | const configDir = path.join(homeDir, '.config/qbit-race/');
9 | return configDir;
10 | };
11 | const getConfigPath = () => {
12 | return path.join(getConfigDir(), 'config.json');
13 | };
14 | export const getFilePathInConfigDir = (filename) => {
15 | return path.join(getConfigDir(), filename);
16 | };
17 | export const makeConfigDirIfNotExist = () => {
18 | const configDir = getConfigDir();
19 | try {
20 | const stats = fs.statSync(configDir);
21 | if (stats.isDirectory() === true) {
22 | return;
23 | }
24 | }
25 | catch (e) {
26 | // Probably didnt exist. Try to make
27 | fs.mkdirSync(configDir, { recursive: true });
28 | }
29 | };
30 | export const makeConfigIfNotExist = () => {
31 | makeConfigDirIfNotExist();
32 | const configDir = getConfigDir();
33 | const logger = getLoggerV3();
34 | // Now check config
35 | const configFilePath = getConfigPath();
36 | // Check if config exists
37 | try {
38 | const stats = fs.statSync(configFilePath);
39 | if (stats.isFile() === true) {
40 | logger.debug(`Config file exists. Will try reading it`);
41 | const configFile = fs.readFileSync(configFilePath);
42 | const config = JSON.parse(configFile.toString());
43 | return config;
44 | }
45 | }
46 | catch (e) {
47 | // Probably doesnt exist
48 | logger.info(`Config does not exist. Writing it now...`);
49 | const defaultConfigToWrite = JSON.stringify(defaultSettings, null, 2);
50 | fs.writeFileSync(configFilePath, defaultConfigToWrite);
51 | }
52 | };
53 | /**
54 | * loadConfig will attempt to read and then JSON.parse the file
55 | * TODO: Validate its legit config
56 | *
57 | * If the directory / path to config does not exist it will throw!
58 | *
59 | * It's assumed makeConfigIfNotExist() has already been called.
60 | */
61 | export const loadConfig = () => {
62 | const logger = getLoggerV3();
63 | const configPath = getConfigPath();
64 | const configData = fs.readFileSync(configPath);
65 | const parsedConfig = JSON.parse(configData.toString());
66 | // Check if any keys are missing
67 | const allKeysFromDefault = Object.keys(defaultSettings);
68 | let overwriteConfig = false;
69 | for (const key of allKeysFromDefault) {
70 | if ((key in parsedConfig) === false) {
71 | const defaultValue = defaultSettings[key];
72 | logger.warn(`Missing key ${key} from current config! Will update with default value (${defaultValue})`);
73 | parsedConfig[key] = defaultValue;
74 | overwriteConfig = true;
75 | }
76 | }
77 | if (overwriteConfig === true) {
78 | logger.info(`Overwriting config for missing keys...`);
79 | fs.writeFileSync(configPath, JSON.stringify(parsedConfig, null, 2));
80 | }
81 | return parsedConfig;
82 | };
83 | //# sourceMappingURL=configV2.js.map
--------------------------------------------------------------------------------
/build/src/utils/logger.js:
--------------------------------------------------------------------------------
1 | import { Logger, LOGLEVEL } from "@ckcr4lyf/logger";
2 | import { getFilePathInConfigDir, makeConfigDirIfNotExist } from "./configV2.js";
3 | export const getLoggerV3 = (options) => {
4 | // Hardcoded to DEBUG.
5 | // In future get from user's settings or env var
6 | const logFilename = getFilePathInConfigDir(options?.logfile || 'logs.txt');
7 | if (options !== undefined) {
8 | if (options.skipFile === true) {
9 | return new Logger({
10 | loglevel: LOGLEVEL.DEBUG,
11 | });
12 | }
13 | }
14 | makeConfigDirIfNotExist();
15 | return new Logger({
16 | loglevel: LOGLEVEL.DEBUG,
17 | filename: logFilename
18 | });
19 | };
20 | //# sourceMappingURL=logger.js.map
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | Contains examples on usage, setup and more
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "qbit-race",
3 | "version": "2.0.0-alpha.19",
4 | "description": "Qbit utilities for racing",
5 | "main": "./bin/index.js",
6 | "type": "module",
7 | "scripts": {
8 | "build": "rm -rf build/ && tsc -p .",
9 | "backup": "node bin/backup.js",
10 | "dev": "nodemon src/index.ts",
11 | "start": "node build/index.js",
12 | "validate": "node tests/validate.js",
13 | "tag_unreg": "node bin/tag_errored.js",
14 | "test-dev": "ts-node src/index.ts",
15 | "test": "ava"
16 | },
17 | "author": "Raghu Saxena",
18 | "license": "ISC",
19 | "dependencies": {
20 | "@ckcr4lyf/bencode-esm": "^0.0.2",
21 | "@ckcr4lyf/logger": "^0.0.5",
22 | "axios": "^1.4.0",
23 | "commander": "^9.4.0",
24 | "fastify": "^3.19.0",
25 | "form-data": "^3.0.1"
26 | },
27 | "devDependencies": {
28 | "@ava/typescript": "^3.0.1",
29 | "@types/node": "^18.6.3",
30 | "@types/sinon": "^10.0.13",
31 | "ava": "^4.3.1",
32 | "nock": "^13.2.9",
33 | "sinon": "^14.0.0",
34 | "typescript": "^4.7.4"
35 | },
36 | "ava": {
37 | "files": [
38 | "__tests__/**/*"
39 | ],
40 | "timeout": "1m",
41 | "typescript": {
42 | "rewritePaths": {
43 | "__tests__/": "build/__tests__/"
44 | },
45 | "compile": false
46 | }
47 | },
48 | "preferGlobal": true,
49 | "bin": {
50 | "qbit-race": "bin/index.mjs"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/discord/api.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosResponse } from 'axios';
2 |
3 | export const sendMessageV2 = (webnook: string, body: any): Promise => {
4 | return axios.post(webnook, body);
5 | }
--------------------------------------------------------------------------------
/src/discord/messages.ts:
--------------------------------------------------------------------------------
1 | //Prepare message JSONs for different requirements
2 | import { humanFileSize } from '../helpers/utilities.js';
3 | import { QbittorrentTorrent } from '../qbittorrent/api.js';
4 | import { DISCORD_SETTINGS } from '../utils/config.js';
5 |
6 | type EmbedField = {
7 | name: string;
8 | value: string;
9 | }
10 |
11 | type DiscordEmbed = {
12 | title: string;
13 | description: string;
14 | thumbnail: {
15 | url: string;
16 | },
17 | fields: EmbedField[];
18 | }
19 |
20 | type PartialMesssageBody = {
21 | content: string;
22 | embeds: DiscordEmbed[];
23 | }
24 |
25 | type MessageBody = PartialMesssageBody & {
26 | username: string;
27 | avatar_url: string;
28 | }
29 |
30 | type TorrentAddedInfo = {
31 | name: string;
32 | trackers: string[];
33 | size: number;
34 | reannounceCount: number;
35 | }
36 |
37 | export const buildMessageBody = (discordSettings: DISCORD_SETTINGS, partialBody: PartialMesssageBody): MessageBody => {
38 | return {
39 | ...partialBody,
40 | username: discordSettings.botUsername,
41 | avatar_url: discordSettings.botAvatar,
42 | }
43 | }
44 |
45 | export const buildTorrentAddedBody = (discordSettings: DISCORD_SETTINGS, torrentAddedInfo: TorrentAddedInfo): MessageBody => {
46 | const humanSize = humanFileSize(torrentAddedInfo.size, false, 2);
47 |
48 | let partialBody: PartialMesssageBody = {
49 | content: `Added ${torrentAddedInfo.name} (${humanSize})`,
50 | embeds: [
51 | {
52 | title: torrentAddedInfo.name,
53 | description: 'Added to qBittorrent',
54 | thumbnail: {
55 | url: discordSettings.botAvatar,
56 | },
57 | fields: [
58 | {
59 | name: torrentAddedInfo.trackers.length === 1 ? 'Tracker' : 'Trackers',
60 | value: torrentAddedInfo.trackers.join('\n')
61 | },
62 | {
63 | name: 'Size',
64 | value: humanSize
65 | },
66 | {
67 | name: 'Reannounce Count',
68 | value: torrentAddedInfo.reannounceCount.toString()
69 | }
70 | ]
71 | }
72 | ]
73 | }
74 |
75 | return buildMessageBody(discordSettings, partialBody);
76 | }
77 |
78 | // TODO: generalize with above
79 | export const buildRacingBody = (discordSettings: DISCORD_SETTINGS, torrentAddedInfo: TorrentAddedInfo): MessageBody => {
80 | const humanSize = humanFileSize(torrentAddedInfo.size, false, 2);
81 |
82 | let partialBody: PartialMesssageBody = {
83 | content: `Added ${torrentAddedInfo.name} (${humanSize})`,
84 | embeds: [
85 | {
86 | title: torrentAddedInfo.name,
87 | description: 'Racing in qBittorrent',
88 | thumbnail: {
89 | url: discordSettings.botAvatar,
90 | },
91 | fields: [
92 | {
93 | name: torrentAddedInfo.trackers.length === 1 ? 'Tracker' : 'Trackers',
94 | value: torrentAddedInfo.trackers.join('\n')
95 | },
96 | {
97 | name: 'Size',
98 | value: humanSize
99 | },
100 | {
101 | name: 'Reannounce Count',
102 | value: torrentAddedInfo.reannounceCount.toString()
103 | }
104 | ]
105 | }
106 | ]
107 | }
108 |
109 | return buildMessageBody(discordSettings, partialBody);
110 | }
111 |
112 | export const buildTorrentCompletedBody = (discordSettings: DISCORD_SETTINGS, torrent: QbittorrentTorrent): MessageBody => {
113 | const humanSize = humanFileSize(torrent.size, false, 2);
114 |
115 | let partialBody: PartialMesssageBody = {
116 | content: `Completed ${torrent.name}! (Ratio: ${torrent.ratio.toFixed(2)})`,
117 | embeds: [
118 | {
119 | title: torrent.name,
120 | description: 'Completed download',
121 | thumbnail: {
122 | url: discordSettings.botAvatar
123 | },
124 | fields: [
125 | {
126 | name: 'Ratio',
127 | value: torrent.ratio.toFixed(2).toString()
128 | },
129 | {
130 | name: torrent.tags.split(',').length === 1 ? 'Tracker' : 'Trackers',
131 | value: torrent.tags.split(',').join('\n') || 'No trackers set as tags',
132 | },
133 | {
134 | name: 'Size',
135 | value: humanSize
136 | }
137 | ]
138 | }
139 | ]
140 | }
141 |
142 | return buildMessageBody(discordSettings, partialBody);
143 | }
144 |
--------------------------------------------------------------------------------
/src/helpers/constants.ts:
--------------------------------------------------------------------------------
1 | export const SEEDING_STATES = ['uploading', 'stalledUP', 'forcedUP'];
--------------------------------------------------------------------------------
/src/helpers/preparePromMetrics.ts:
--------------------------------------------------------------------------------
1 | import { torrentFromApi, TransferInfo, TorrentState } from "../interfaces.js"
2 |
3 | export const makeMetrics = (transferInfo: TransferInfo) => {
4 | let result = '';
5 | result += `qbit_dl_bytes ${transferInfo.dl_info_data}\n`;
6 | result += `qbit_dl_rate_bytes ${transferInfo.dl_info_speed}\n`;
7 | result += `qbit_ul_bytes ${transferInfo.up_info_data}\n`;
8 | result += `qbit_ul_rate_bytes ${transferInfo.up_info_speed}\n`;
9 | result += `up 1\n\n`;
10 | return result;
11 | }
12 |
13 | export const countTorrentStates = (torrents: torrentFromApi[]): Record => {
14 |
15 | // Initialize counter to 0
16 | let stateCounter: Record = {};
17 |
18 | for (let state in TorrentState){
19 | stateCounter[state] = 0;
20 | }
21 |
22 | // Count all the states
23 | for (let torrent of torrents){
24 | stateCounter[torrent.state]++;
25 | }
26 |
27 | return stateCounter;
28 | }
29 |
30 | export const stateMetrics = (torrents: torrentFromApi[]): string => {
31 | let metrics = '';
32 | let stateCount = countTorrentStates(torrents);
33 |
34 | for (let state in stateCount){
35 | metrics += `qbit_torrents_state{state="${state}"} ${stateCount[state]}\n`;
36 | }
37 |
38 | metrics += '\n';
39 |
40 | return metrics;
41 | }
--------------------------------------------------------------------------------
/src/helpers/torrent.ts:
--------------------------------------------------------------------------------
1 | import bencode from '@ckcr4lyf/bencode-esm';
2 | import * as crypto from 'crypto';
3 |
4 | /**
5 | * getTorrentMetaInfo
6 | * ---
7 | *
8 | * Takes in the raw .torrent to get:
9 | * - The torrent name
10 | * - The tracker
11 | * - The infohash (calculated by decoded .torrent, encoding info, and calculating the SHA-1 hash)
12 | *
13 | * @param torrentData The raw .torrent contents
14 | * @returns Metainfo about the torrent
15 | */
16 | export const getTorrentMetainfo = (torrentData: Buffer): TorrentMetainfoV2 => {
17 | const decodedData = bencode.decode(torrentData);
18 |
19 | if (typeof decodedData.info !== 'object'){
20 | throw new Error("NO_INFO");
21 | }
22 |
23 | if (Buffer.isBuffer(decodedData.info.name) !== true){
24 | throw new Error("NO_NAME");
25 | }
26 |
27 | if (Buffer.isBuffer(decodedData.announce) !== true){
28 | throw new Error("NO_ANNOUNCE");
29 | }
30 |
31 | const info = decodedData.info;
32 | const reEncodedInfo = bencode.encode(info);
33 |
34 | const torrentHash = crypto.createHash('sha1').update(reEncodedInfo).digest('hex');
35 | const torrentName = info.name.toString();
36 | const announce = new URL(decodedData.announce.toString());
37 | const tracker = announce.hostname;
38 |
39 | return {
40 | hash: torrentHash,
41 | name: torrentName,
42 | tracker: tracker
43 | }
44 | }
45 |
46 | export type TorrentMetainfoV2 = {
47 | hash: string;
48 | name: string;
49 | tracker: string;
50 | }
51 |
52 | export type torrentMetainfo = {
53 | infohash: string;
54 | name: string;
55 | tracker: string;
56 | }
--------------------------------------------------------------------------------
/src/helpers/utilities.ts:
--------------------------------------------------------------------------------
1 | export const sleep = (ms: number) => {
2 | if (process.env.CI === 'true'){
3 | return;
4 | }
5 |
6 | return new Promise((resolve, reject) => {
7 | setTimeout(resolve, ms);
8 | });
9 | }
10 |
11 | //Thanks mpen @ StackOverflow! - https://stackoverflow.com/a/14919494/3857675
12 | export const humanFileSize = (bytes: number, si=false, dp=1) => {
13 | const thresh = si ? 1000 : 1024;
14 |
15 | if (Math.abs(bytes) < thresh) {
16 | return bytes + ' B';
17 | }
18 |
19 | const units = si
20 | ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
21 | : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
22 | let u = -1;
23 | const r = 10**dp;
24 |
25 | do {
26 | bytes /= thresh;
27 | ++u;
28 | } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
29 |
30 |
31 | return bytes.toFixed(dp) + ' ' + units[u];
32 | }
--------------------------------------------------------------------------------
/src/interfaces.ts:
--------------------------------------------------------------------------------
1 | export enum TorrentState {
2 | error = 'error',
3 | missingFiles = 'missingFiles',
4 | uploading = 'uploading',
5 | pausedUP = 'pausedUP',
6 | queuedUP = 'queuedUP',
7 | stalledUP = 'stalledUP',
8 | checkingUP = 'checkingUP',
9 | forcedUP = 'forcedUP',
10 | allocating = 'allocating',
11 | downloading = 'downloading',
12 | metaDL = 'metaDL',
13 | pausedDL = 'pausedDL',
14 | queuedDL = 'queuedDL',
15 | stalledDL = 'stalledDL',
16 | checkingDL = 'checkingDL',
17 | forcedDL = 'forcedDL',
18 | checkingResumeData = 'checkingResumeData',
19 | moving = 'moving',
20 | unknown = 'unknown',
21 | }
22 |
23 | export const SEEDING_STATES = [TorrentState.uploading, TorrentState.stalledUP, TorrentState.forcedUP];
24 |
25 | // Just type the important fields
26 | export interface torrentFromApi {
27 | name: string;
28 | hash: string;
29 | state: TorrentState;
30 | added_on: number; //Unix timestamp
31 | ratio: number;
32 | category: string; // "" or single category
33 | tags: string; // "" or CSV of multiple tags
34 | size: number;
35 | }
36 |
37 | export enum TrackerStatus {
38 | WORKING = 2,
39 | }
40 |
41 | export type TransferInfo = {
42 | connection_status: string;
43 | dht_nodes: number;
44 | dl_info_data: number;
45 | dl_info_speed: number;
46 | up_info_data: number;
47 | up_info_speed: number;
48 | }
--------------------------------------------------------------------------------
/src/qbittorrent/api.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance, AxiosResponse } from 'axios';
2 | import FormData from 'form-data';
3 |
4 | import { torrentFromApi, TorrentState, TransferInfo } from '../interfaces.js';
5 | import { QBITTORRENT_SETTINGS, Settings } from '../utils/config.js';
6 | import { getLoggerV3 } from '../utils/logger.js';
7 |
8 |
9 | export class QbittorrentApi {
10 |
11 | private client: AxiosInstance;
12 | private version: string;
13 |
14 | constructor(public basePath: string, public cookie: string) {
15 |
16 | this.client = axios.create({
17 | baseURL: basePath,
18 | headers: {
19 | 'Cookie': cookie,
20 | }
21 | });
22 |
23 | this.version = 'v4'; // default to v4
24 | }
25 |
26 | async getAndSetVersion(): Promise {
27 | try {
28 | const response = await this.client.get(ApiEndpoints.version);
29 |
30 | console.log(response.data);
31 | this.version = response.data;
32 | return response.data;
33 | } catch (e){
34 | throw new Error(`Failed to get qBittorrent version. Error: ${e}`);
35 | }
36 | }
37 |
38 | async getTorrents(hashes?: string[]): Promise {
39 | const params: Record = {};
40 |
41 | if (Array.isArray(hashes)){
42 | params.hashes = hashes.join('|')
43 | }
44 |
45 | try {
46 | const response = await this.client.get(ApiEndpoints.torrentsInfo, {
47 | params: params,
48 | });
49 |
50 | return response.data;
51 | } catch (e){
52 | throw new Error(`Failed to get torrents from qBittorrent API. Error: ${e}`);
53 | }
54 | }
55 |
56 | // Just wraps getTorrents as a convenience method for single torrent
57 | async getTorrent(infohash: string): Promise {
58 | const torrents = await this.getTorrents([infohash]);
59 |
60 | if (torrents.length === 0){
61 | throw new Error(`Torrent not found! (Infohash = ${infohash})`);
62 | }
63 |
64 | return torrents[0];
65 | }
66 |
67 | async getTrackers(infohash: string): Promise {
68 | const response = await this.client.get(ApiEndpoints.torrentTrackers, {
69 | params: {
70 | hash: infohash,
71 | }
72 | });
73 |
74 | return response.data;
75 | }
76 |
77 | async addTags(torrents: ApiCompatibleTorrent[], tags: string[]){
78 | if (torrents.length === 0){
79 | return;
80 | }
81 |
82 | if (tags.length === 0){
83 | return;
84 | }
85 |
86 | const infohashes = torrents.map(torrent => torrent.hash);
87 | const payload = `hashes=${infohashes.join('|')}&tags=${tags.join(',')}`;
88 |
89 | try {
90 | await this.client.post(ApiEndpoints.addTags, payload, {
91 | headers: {
92 | 'Content-Type': 'application/x-www-form-urlencoded',
93 | }
94 | });
95 | } catch (e){
96 | throw new Error(`Failed to add tags to torrent: ${e}`);
97 | }
98 |
99 | }
100 |
101 | async setCategory(infohash: string, category: string){
102 | const payload = `hashes=${infohash}&category=${category}`;
103 |
104 | await this.client.post(ApiEndpoints.setCategory, payload, {
105 | headers: {
106 | 'Content-Type': 'application/x-www-form-urlencoded',
107 | }
108 | })
109 | }
110 |
111 | async resumeTorrents(torrents: ApiCompatibleTorrent[]){
112 | const infohashes = torrents.map(torrent => torrent.hash);
113 | const endpoint = this.version >= 'v5' ? ApiEndpoints.resumeTorrentsNew : ApiEndpoints.resumeTorrents;
114 | const payload = `hashes=${infohashes.join('|')}`;
115 |
116 | try {
117 | await this.client.post(endpoint, payload, {
118 | headers: {
119 | 'Content-Type': 'application/x-www-form-urlencoded',
120 | }
121 | });
122 | } catch (e){
123 | throw new Error(`Failed to resume torrents. Error: ${e}`);
124 | }
125 | }
126 |
127 | async pauseTorrents(torrents: QbittorrentTorrent[]){
128 | if (torrents.length === 0){
129 | return;
130 | }
131 |
132 | const endpoint = this.version >= 'v5' ? ApiEndpoints.pauseTorrentsNew : ApiEndpoints.pauseTorrents;
133 | const payload = `hashes=${torrents.map(torrent => torrent.hash).join('|')}`
134 |
135 | await this.client.post(endpoint, payload, {
136 | headers: {
137 | 'Content-Type': 'application/x-www-form-urlencoded',
138 | }
139 | });
140 | }
141 | async deleteTorrentsWithFiles(torrents: ApiCompatibleTorrent[]){
142 | if (torrents.length === 0){
143 | return;
144 | }
145 |
146 | await this.client.get(ApiEndpoints.deleteTorrents, {
147 | params: {
148 | hashes: torrents.map(torrent => torrent.hash).join('|'),
149 | deleteFiles: true,
150 | }
151 | });
152 | }
153 |
154 | async addTorrent(torrentData: Buffer, category?: string){
155 |
156 | let formData = new FormData();
157 | formData.append("torrents", torrentData, 'dummy.torrent'); // The filename doesn't really matter
158 |
159 | if (category !== undefined){
160 | formData.append('category', category);
161 | }
162 |
163 | await this.client.post(ApiEndpoints.addTorrent, formData, {
164 | headers: {
165 | ...formData.getHeaders(),
166 | //Because axios can't handle this. Wasted 2 hours trying to debug. Fuck.
167 | 'Content-Length': formData.getLengthSync(),
168 | }
169 | });
170 | }
171 |
172 | async reannounce(infohash: string){
173 | const payload = `hashes=${infohash}`;
174 |
175 | try {
176 | await this.client.post(ApiEndpoints.reannounce, payload, {
177 | headers: {
178 | 'Content-Type': 'application/x-www-form-urlencoded',
179 | }
180 | });
181 | } catch (e){
182 | throw new Error(`Failed to reannounce! Error: ${e}`);
183 | }
184 | }
185 |
186 | async getTransferInfo(): Promise{
187 | const response = await this.client.get(ApiEndpoints.transferInfo);
188 | return response.data;
189 | }
190 | }
191 |
192 | enum ApiEndpoints {
193 | login = '/api/v2/auth/login',
194 | torrentsInfo = '/api/v2/torrents/info',
195 | torrentTrackers = '/api/v2/torrents/trackers',
196 | resumeTorrents = '/api/v2/torrents/resume',
197 | resumeTorrentsNew = '/api/v2/torrents/start',
198 | addTags = '/api/v2/torrents/addTags',
199 | setCategory = '/api/v2/torrents/setCategory',
200 | pauseTorrents = '/api/v2/torrents/pause',
201 | pauseTorrentsNew = '/api/v2/torrents/stop',
202 | addTorrent = '/api/v2/torrents/add',
203 | deleteTorrents = '/api/v2/torrents/delete',
204 | reannounce = '/api/v2/torrents/reannounce',
205 | transferInfo = '/api/v2/transfer/info',
206 | version = '/api/v2/app/version',
207 | }
208 |
209 | export const login = (qbittorrentSettings: QBITTORRENT_SETTINGS): Promise => {
210 | return axios.post(`${qbittorrentSettings.url}${ApiEndpoints.login}`, {
211 | username: qbittorrentSettings.username,
212 | password: qbittorrentSettings.password
213 | }, {
214 | headers: {
215 | 'Content-Type': 'application/x-www-form-urlencoded',
216 | }
217 | });
218 | }
219 |
220 | // We just need the hash for some of the API calls
221 | export type ApiCompatibleTorrent = {
222 | hash: string;
223 | }
224 |
225 | export type QbittorrentTorrent = {
226 | name: string;
227 | hash: string;
228 | state: TorrentState;
229 | added_on: number; //Unix timestamp
230 | ratio: number;
231 | category: string; // "" or single category
232 | tags: string; // "" or CSV of multiple tags
233 | size: number;
234 | }
235 |
236 | export type QbittorrentTracker = {
237 | status: number;
238 | url: string;
239 | }
--------------------------------------------------------------------------------
/src/qbittorrent/auth.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { QBITTORRENT_SETTINGS, Settings } from '../utils/config.js';
3 | import { getLoggerV3 } from '../utils/logger.js';
4 | import { login as apiLogin, QbittorrentApi } from './api.js';
5 |
6 |
7 | export const loginV2 = async (qbittorrentSettings: QBITTORRENT_SETTINGS): Promise => {
8 | const logger = getLoggerV3();
9 | const response = await apiLogin(qbittorrentSettings);
10 |
11 | if (response.status != 200){
12 | logger.error(`Unknown error logging in!\nStatus: ${response.status}\nHeaders: ${response.headers}\nBody: ${response.data}`)
13 | throw new Error("Failed to authenticate (UNKNOWN ERROR)");
14 | }
15 |
16 | if (response.data === 'Fails.'){
17 | logger.warn(`Incorrect credentials!`);
18 | throw new Error("Incorrect credentials");
19 | }
20 |
21 | if (response.headers['set-cookie'] === undefined || Array.isArray(response.headers['set-cookie']) === false || response.headers['set-cookie'].length === 0) {
22 | logger.error(`Missing set cookie header from response!\nStatus: ${response.status}\nHeaders: ${response.headers}\nBody: ${response.data}`);
23 | throw new Error(`Failed to authenticate (UNKNOWN ERROR)`);
24 | }
25 |
26 | const api = new QbittorrentApi(qbittorrentSettings.url, response.headers['set-cookie'][0]);
27 |
28 | // Need to get the version so we can choose which endpoints to use
29 | // See: https://github.com/ckcr4lyf/qbit-race/issues/52
30 | const version = await api.getAndSetVersion();
31 | logger.info(`Detected qBitorrent version as: ${version}`);
32 | return api;
33 | }
--------------------------------------------------------------------------------
/src/racing/add.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * To add a new torrent into qbittorrent for racing
3 | */
4 |
5 | import { getTorrentMetainfo, torrentMetainfo, TorrentMetainfoV2 } from "../helpers/torrent.js";
6 | import { getLoggerV3 } from "../utils/logger.js"
7 | import * as fs from 'fs';
8 | import { Options, Settings } from "../utils/config";
9 | import { QbittorrentApi, QbittorrentTorrent } from "../qbittorrent/api";
10 | import { concurrentRacesCheck, getTorrentsToPause } from "./preRace.js";
11 | import { sleep } from "../helpers/utilities.js";
12 | import { TrackerStatus } from "../interfaces.js";
13 | import { buildTorrentAddedBody } from "../discord/messages.js";
14 | import { sendMessageV2 } from "../discord/api.js";
15 | import axios from "axios";
16 |
17 | export const addTorrentToRace = async (api: QbittorrentApi, settings: Settings, path: string, options: Options, category?: string) => {
18 |
19 | const logger = getLoggerV3();
20 | logger.debug(`Called with path: ${path}, category: ${category}`);
21 |
22 | // Read the torrent file and get info
23 | let torrentFile: Buffer;
24 |
25 |
26 | try {
27 | torrentFile = fs.readFileSync(path);
28 | } catch (e){
29 | logger.error(`Failed to read torrent from ${e}`);
30 | process.exit(1);
31 | }
32 |
33 | let torrentMetainfo: TorrentMetainfoV2;
34 |
35 | try {
36 | torrentMetainfo = getTorrentMetainfo(torrentFile);
37 | } catch (e){
38 | logger.error(`Fail to parse torrent file`);
39 | process.exit(1);
40 | }
41 |
42 | // Do pre race check to determine if we should add this torrent
43 | let torrents: QbittorrentTorrent[];
44 | try {
45 | torrents = await api.getTorrents();
46 | } catch (e){
47 | logger.error(`Failed to get torrents from qbittorrent: ${e}`);
48 | process.exit(1);
49 | }
50 |
51 | const goodToRace = concurrentRacesCheck(settings, torrents);
52 |
53 | if (goodToRace === false){
54 | logger.info(`Pre race conditions not met. Skipping ${torrentMetainfo.name}`);
55 | process.exit(0);
56 | }
57 |
58 | // TODO: Move to common race part
59 | const torrentsToPause = getTorrentsToPause(settings, torrents);
60 |
61 | try {
62 | logger.debug(`Going to pause ${torrentsToPause.length} torrents for the race...`);
63 | await api.pauseTorrents(torrentsToPause);
64 | } catch (e){
65 | logger.error(`Failed to pause torrents: ${e}`)
66 | process.exit(1);
67 | }
68 |
69 | try {
70 | await api.addTorrent(torrentFile, category);
71 | } catch (e){
72 | logger.error(`Failed to add torrent to qbittorrent: ${e}`);
73 | process.exit(1);
74 | }
75 |
76 | // Wait for torrent to register in qbit, initial announce
77 | logger.debug(`Going to sleep for 5 seconds to allow torrent to register...`);
78 | await sleep(5000);
79 | logger.debug(`Finished sleeping, going to get trackers`);
80 |
81 | // Get the torrent's trackers, which we set as tags as well.
82 | const trackersAsTags: string[] = [];
83 | let registeredFlag = false;
84 |
85 | for (let i = 0; i < 25; i++){
86 | logger.debug(`Attempt #${i+1} to get trackers`)
87 |
88 | try {
89 | const trackers = await api.getTrackers(torrentMetainfo.hash);
90 | trackers.splice(0, 3); // Get rid of DHT, PEX etc.
91 | trackersAsTags.push(...trackers.map(tracker => new URL(tracker.url).hostname));
92 | logger.info(`Successfully got trackers!`);
93 | registeredFlag = true;
94 | break;
95 | } catch (e){
96 | if (axios.isAxiosError(e) && e.response?.status === 404){
97 | logger.warn(`Got 404 from qbittorrent, probably not registered yet... Will sleep for a second and try again. (Error: ${e})`);
98 | await sleep(1000);
99 | continue;
100 | }
101 |
102 | logger.error(`Failed to get tags for torrent: ${e}`);
103 | process.exit(1);
104 | }
105 | }
106 |
107 | if (registeredFlag === false){
108 | logger.error(`Failed to get torrent from qbit, maybe not registered!`);
109 | process.exit(1);
110 | }
111 |
112 | const tagsToAdd = [];
113 |
114 | if (options.trackerTags === false){
115 | logger.debug(`--no-tracker-tags specified, will skip adding them to the torrent!`);
116 | } else {
117 | tagsToAdd.push(...trackersAsTags);
118 | }
119 |
120 | if (options.extraTags !== undefined){
121 | const extraTags = options.extraTags.split(',');
122 | logger.debug(`Going to add extra tags: ${extraTags}`);
123 | tagsToAdd.push(...extraTags)
124 | }
125 |
126 | if (tagsToAdd.length !== 0){
127 | try {
128 | await api.addTags([torrentMetainfo], tagsToAdd);
129 | } catch (e){
130 | logger.error(`Failed to add tags to torrent: ${e}`);
131 | process.exit(1);
132 | }
133 | }
134 |
135 | let torrent: QbittorrentTorrent;
136 |
137 | try {
138 | torrent = await api.getTorrent(torrentMetainfo.hash);
139 | } catch (e){
140 | logger.error(`Failed to get information of torrent from qbit: ${e}`);
141 | process.exit(1);
142 | }
143 |
144 | logger.debug(`Startng reannounce check`);
145 |
146 | let attempts = 0;
147 | let announceOk = false;
148 |
149 | while (attempts < settings.REANNOUNCE_LIMIT){
150 | logger.debug(`Reannounce attempt #${attempts + 1}`);
151 |
152 | try {
153 | const trackers = await api.getTrackers(torrentMetainfo.hash);
154 | trackers.splice(0, 3); // Get rid of DHT, PEX etc.
155 | const working = trackers.some(tracker => tracker.status === TrackerStatus.WORKING);
156 |
157 | // Need to reannounce
158 | if (working === false){
159 | logger.debug(`No working tracker. Will reannounce and sleep...`)
160 | await api.reannounce(torrent.hash);
161 | await sleep(settings.REANNOUNCE_INTERVAL);
162 | attempts++;
163 | } else {
164 | announceOk = true;
165 | logger.debug(`Tracker is OK!`)
166 | break;
167 | }
168 | } catch (e){
169 | logger.error(`Failed to reannounce: ${e}`);
170 | process.exit(1);
171 | }
172 | }
173 |
174 | if (announceOk === false){
175 | logger.warn(`Did not get an OK from tracker even after ${settings.REANNOUNCE_LIMIT} re-announces. Deleting torrent...`);
176 | try {
177 | await api.deleteTorrentsWithFiles([torrentMetainfo]);
178 | } catch (e){
179 | logger.error(`Failed to delete torrent: ${e}`);
180 | process.exit(1);
181 | }
182 |
183 | // Resume any torrents that were paused for the race
184 | logger.debug(`Going to resume paused torrents since no race`);
185 |
186 | try {
187 | await api.resumeTorrents(torrentsToPause);
188 | } catch (e){
189 | logger.error(`Failed to resume torrents: ${e}`);
190 | process.exit(1);
191 | }
192 |
193 | // Clean exit
194 | process.exit(0);
195 | }
196 |
197 | // Announce was good!
198 | logger.info(`Successfully added ${torrentMetainfo.name}!`);
199 |
200 | if (settings.DISCORD_NOTIFICATIONS.enabled === true){
201 | const torrentAddedMessage = buildTorrentAddedBody(settings.DISCORD_NOTIFICATIONS, {
202 | name: torrent.name,
203 | size: torrent.size,
204 | trackers: trackersAsTags,
205 | reannounceCount: attempts
206 | });
207 |
208 | try {
209 | await sendMessageV2(settings.DISCORD_NOTIFICATIONS.webhook, torrentAddedMessage);
210 | } catch (e){
211 | logger.error(`Failed to send message to discord: ${e}`);
212 | // Do not throw for this failure.
213 | }
214 | }
215 |
216 | // Clean exit
217 | // TODO: This guy can return to upper caller instead?
218 | process.exit(0);
219 | }
--------------------------------------------------------------------------------
/src/racing/common.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Racing a torrent consists of a few steps:
3 | *
4 | * # Case 1: qbit-race used to add a new torrent
5 | *
6 | * [Pre addition]
7 | *
8 | * 1. Get list of existing torrents
9 | * 2. Check concurrent racing rules to see if it is ok to add
10 | * a. Skip race if existing torrents
11 | * 3. Pause torrents before adding
12 | * 4. Add torrent
13 | *
14 | * [Post addition]
15 | * 5. Try and reannounce if applicable
16 | *
17 | * [Successfully announce]
18 | * 6a. Discord message
19 | *
20 | * [Fail to reannounce]
21 | * 6b. Delete torrent, resume existing torrents
22 | *
23 | * # Case 2: qbit-race used to apply "race mode" to existing torrent
24 | *
25 | * 1. Try and pause torrents
26 | * 2. Try and reannounce if applicable
27 | *
28 | * [Successfully announce]
29 | * 3a. Discord message
30 | *
31 | * [Fail to reannounce]
32 | * 3b. noop
33 | */
34 |
35 | import { sendMessageV2 } from "../discord/api.js";
36 | import { buildRacingBody } from "../discord/messages.js";
37 | import { sleep } from "../helpers/utilities.js";
38 | import { TrackerStatus } from "../interfaces.js";
39 | import { QbittorrentApi, QbittorrentTorrent } from "../qbittorrent/api.js";
40 | import { Options, Settings } from "../utils/config.js";
41 | import { getLoggerV3 } from "../utils/logger.js"
42 | import { getTorrentsToPause } from "./preRace.js";
43 |
44 |
45 | /**
46 | * raceExisting will try and "race" and existing torrent, by:
47 | *
48 | * * Pausing existing torrents
49 | * * Try and reannounce if applicable
50 | * @param api The qbittorrent API
51 | * @param settings Settings for checking pause ratio etc.
52 | * @param infohash Infohash of newly added torrent
53 | */
54 | export const raceExisting = async (api: QbittorrentApi, settings: Settings, infohash: string, options: Options) => {
55 | const logger = getLoggerV3();
56 | logger.debug(`raceExisting called with infohash: ${infohash}`);
57 |
58 | const torrent = await api.getTorrent(infohash);
59 |
60 | // Try and pause existing torrents
61 | const torrents = await (async () => {
62 | try {
63 | const xd = await api.getTorrents();
64 | return xd;
65 | } catch (e){
66 | logger.error(`Failed to get torrents from qBittorrent API: ${e}`);
67 | process.exit(-1);
68 | }
69 | })();
70 |
71 | const torrentsToPause = getTorrentsToPause(settings, torrents);
72 | logger.debug(`Going to pause ${torrentsToPause.length} torrents`);
73 |
74 | try {
75 | await api.pauseTorrents(torrentsToPause);
76 | } catch (e){
77 | logger.error(`Failed to pause torrents: ${e}`);
78 | process.exit(-1);
79 | }
80 |
81 | let trackersAsTags: string[] = [];
82 | if (options.trackerTags === false){
83 | logger.debug(`--no-tracker-tags specified, will skip adding them to the torrent!`);
84 | } else {
85 | trackersAsTags = await addTrackersAsTags(api, settings, infohash);
86 | }
87 |
88 | if (options.extraTags !== undefined){
89 | const extraTags = options.extraTags.split(',');
90 | logger.debug(`Adding extra tags: ${extraTags}`);
91 | await api.addTags([{ hash: infohash }], extraTags);
92 | }
93 |
94 | const announceSummary = await reannounce(api, settings, torrent);
95 |
96 | if (announceSummary.ok === false){
97 | logger.debug(`Going to resume torrents since failed to race`);
98 | await api.resumeTorrents(torrentsToPause);
99 | process.exit(0);
100 | }
101 |
102 | logger.info(`Successfully racing ${torrent.name}`);
103 |
104 | if (settings.DISCORD_NOTIFICATIONS.enabled === true){
105 | const torrentAddedMessage = buildRacingBody(settings.DISCORD_NOTIFICATIONS, {
106 | name: torrent.name,
107 | size: torrent.size,
108 | trackers: trackersAsTags,
109 | reannounceCount: announceSummary.count
110 | });
111 |
112 | try {
113 | await sendMessageV2(settings.DISCORD_NOTIFICATIONS.webhook, torrentAddedMessage);
114 | } catch (e){
115 | logger.error(`Failed to send message to discord: ${e}`);
116 | // Do not throw for this failure.
117 | }
118 | }
119 |
120 | process.exit(0);
121 | }
122 |
123 | type ReannounceSummary = {
124 | ok: boolean;
125 | count: number;
126 | }
127 |
128 | export const reannounce = async (api: QbittorrentApi, settings: Settings, torrent: QbittorrentTorrent): Promise => {
129 | const logger = getLoggerV3();
130 | logger.debug(`Starting reannounce check`);
131 |
132 | let attempts = 0;
133 |
134 | while (attempts < settings.REANNOUNCE_LIMIT){
135 | logger.debug(`Reannounce attempt #${attempts+1}`);
136 |
137 | const trackers = await api.getTrackers(torrent.hash);
138 | trackers.splice(0, 3); // Get rid of DHT, PEX etc.
139 | const working = trackers.some(tracker => tracker.status === TrackerStatus.WORKING);
140 |
141 | // Need to reannounce
142 | if (working === false){
143 | logger.debug(`No working tracker. Will reannounce and sleep...`)
144 | await api.reannounce(torrent.hash);
145 | await sleep(settings.REANNOUNCE_INTERVAL);
146 | attempts++;
147 | } else {
148 | logger.debug(`Tracker is OK!`)
149 | return {
150 | ok: true,
151 | count: attempts
152 | }
153 | }
154 | }
155 |
156 | logger.debug(`Did not get OK from tracker even after ${settings.REANNOUNCE_LIMIT} re-announces!`);
157 | return {
158 | ok: false,
159 | count: attempts
160 | };
161 | }
162 |
163 | export const addTrackersAsTags = async (api: QbittorrentApi, settings: Settings, infohash: string): Promise => {
164 | const trackerNames: string[] = [];
165 |
166 | // Get the tags, map out hostname
167 | const trackers = await api.getTrackers(infohash);
168 | trackers.splice(0, 3); // Get rid of DHT, PEX etc.
169 | trackerNames.push(...trackers.map(tracker => new URL(tracker.url).hostname));
170 |
171 | // Add the tags
172 | await api.addTags([{ hash: infohash }], trackerNames);
173 |
174 | return trackerNames;
175 | }
176 |
--------------------------------------------------------------------------------
/src/racing/completed.ts:
--------------------------------------------------------------------------------
1 | import { sendMessageV2 } from "../discord/api.js";
2 | import { buildTorrentCompletedBody } from "../discord/messages.js";
3 | import { TorrentState } from "../interfaces.js";
4 | import { QbittorrentApi, QbittorrentTorrent } from "../qbittorrent/api.js";
5 | import { Settings } from "../utils/config";
6 | import { getLoggerV3 } from "../utils/logger.js";
7 |
8 | export const postRaceResumeV2 = async (api: QbittorrentApi, settings: Settings, infohash: string) => {
9 | const logger = getLoggerV3();
10 | let torrentInfo: QbittorrentTorrent;
11 |
12 | try {
13 | torrentInfo = await api.getTorrent(infohash);
14 | } catch (e){
15 | logger.error(`Failed to get torrent from API ${e}`)
16 | throw e;
17 | }
18 |
19 | logger.info(`${torrentInfo.name} completed... running post race script`);
20 |
21 | // Handle category change stuff if applicable
22 | if (torrentInfo.category in settings.CATEGORY_FINISH_CHANGE){
23 | const newCategory = settings.CATEGORY_FINISH_CHANGE[torrentInfo.category];
24 | logger.debug(`Found entry in category change map. Changing from ${torrentInfo.category} to ${newCategory}`);
25 |
26 | try {
27 | await api.setCategory(torrentInfo.hash, newCategory);
28 | logger.debug(`Successfully set category to ${newCategory}`);
29 | } catch (e){
30 | logger.error(`Failed to set category. ${e}`);
31 | }
32 | }
33 |
34 | let torrents: QbittorrentTorrent[];
35 |
36 | try {
37 | torrents = await api.getTorrents();
38 | } catch (e){
39 | logger.error(`Failed to get torrents list from API! ${e}`);
40 | process.exit(1);
41 | }
42 |
43 | // Handle discord part if enabled
44 | if (settings.DISCORD_NOTIFICATIONS.enabled === true){
45 | const messageBody = buildTorrentCompletedBody(settings.DISCORD_NOTIFICATIONS, torrentInfo);
46 | try {
47 | await sendMessageV2(settings.DISCORD_NOTIFICATIONS.webhook, messageBody);
48 | logger.debug(`Sent message to discord`);
49 | } catch (e){
50 | logger.error(`Failed to send message to discord ${e}`);
51 | }
52 | } else {
53 | logger.debug(`Discord notifications disabled.`)
54 | }
55 |
56 | // The earliest time, from which there may still be a torrent in the reannounce phase
57 | // e.g. if interval is 10s, and limit is 6, then from now-60s, any torrents in stalledDL status
58 | // are still in the re-announce phase (for racing) , so we do not run the resume job
59 | const oldestRaceLimit = Date.now() - (settings.REANNOUNCE_INTERVAL * settings.REANNOUNCE_LIMIT);
60 |
61 | for(let x = 0; x < torrents.length; x++){
62 | if (torrents[x].state === TorrentState.downloading){
63 | logger.debug(`${torrents[x].name} is still downloading, won't resume`);
64 | return;
65 | }
66 |
67 | if (torrents[x].state === TorrentState.stalledDL && torrents[x].added_on * 1000 > oldestRaceLimit){
68 | logger.debug(`${torrents[x].name} is in re-announce phase, won't resume`);
69 | return;
70 | }
71 | }
72 |
73 | // Check if anything is paused
74 | const pausedTorrents = torrents.filter(torrent => torrent.state === TorrentState.pausedUP);
75 |
76 | if (pausedTorrents.length === 0){
77 | logger.debug(`Nothing to resume (no paused torrents)`);
78 | return;
79 | }
80 |
81 | if (settings.SKIP_RESUME === true){
82 | logger.debug(`Skip resume is true, not resuming anything...`);
83 | return;
84 | }
85 |
86 | // Try and resume
87 | try {
88 | await api.resumeTorrents(pausedTorrents);
89 | logger.debug(`Successfully resumed torrents`);
90 | } catch (e){
91 | logger.error(`Failed to resume torrents! ${e}`);
92 | process.exit(1);
93 | }
94 | }
--------------------------------------------------------------------------------
/src/racing/preRace.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Functions to do some pre-race checks
3 | */
4 |
5 | import { SEEDING_STATES, TorrentState } from "../interfaces.js";
6 | import { QbittorrentTorrent } from "../qbittorrent/api.js";
7 | import { Settings } from "../utils/config.js";
8 | import { getLoggerV3 } from "../utils/logger.js";
9 |
10 | export const concurrentRacesCheck = (settings: Settings, torrents: QbittorrentTorrent[]): boolean => {
11 | const logger = getLoggerV3();
12 |
13 | if (settings.CONCURRENT_RACES === -1){
14 | logger.debug(`CONCURRENT_RACES set to -1, not checking existing torrents...`);
15 | return true;
16 | }
17 |
18 | const downloading = torrents.filter(torrent => torrent.state === TorrentState.downloading);
19 |
20 | if (downloading.length >= settings.CONCURRENT_RACES){
21 | logger.debug(`Downloading ${downloading.length} and concurrent limit is ${settings.CONCURRENT_RACES}. Wont add`);
22 | return false;
23 | }
24 |
25 | // Next we see if any potential new races are in the stalled stage
26 |
27 | // The earliest time, from which there may still be a torrent in the reannounce phase
28 | // e.g. if interval is 10s, and limit is 6, then from now-60s, any torrents in stalledDL status
29 | // are still in the re-announce phase (for racing) , so they should be counted as "downloading"
30 | const oldestRaceLimit = Date.now() - (settings.REANNOUNCE_INTERVAL * settings.REANNOUNCE_LIMIT);
31 |
32 | const stalledDownloading = torrents.filter(torrent => {
33 |
34 | if (torrent.state !== TorrentState.stalledDL){
35 | return false;
36 | }
37 |
38 | // Count irrespective of age
39 | if (settings.COUNT_STALLED_DOWNLOADS === true){
40 | return true;
41 | }
42 |
43 | // If its still in the reannounce phase
44 | if (torrent.added_on * 1000 > oldestRaceLimit){
45 | return true;
46 | }
47 |
48 | return false;
49 | });
50 |
51 | logger.debug(`Currently ${stalledDownloading.length} stalled downloading torrents`);
52 |
53 | if (downloading.length + stalledDownloading.length >= settings.CONCURRENT_RACES){
54 | logger.debug(`Sum of downloading and stalled downloading is ${downloading.length + stalledDownloading.length} and concurrent limit is ${settings.CONCURRENT_RACES}. Wont add`)
55 | return false;
56 | }
57 |
58 | return true;
59 | }
60 |
61 | export const getTorrentsToPause = (settings: Settings, torrents: QbittorrentTorrent[]): QbittorrentTorrent[] => {
62 | const logger = getLoggerV3();
63 |
64 | if (settings.PAUSE_RATIO === -1){
65 | logger.debug(`Pause ratio is -1, wont pause any torrents`);
66 | return [];
67 | }
68 |
69 | const torrentsToPause = torrents.filter(torrent => {
70 |
71 | // Not seeding - no need to pause
72 | if (SEEDING_STATES.some(state => state === torrent.state) === false){
73 | return false;
74 | }
75 |
76 | // If ratio below pause ratio then dont
77 | if (torrent.ratio < settings.PAUSE_RATIO){
78 | logger.debug(`Ratio for ${torrent.name} is ${torrent.ratio} - below pause ratio. Wont pause`);
79 | return false;
80 | }
81 |
82 | // If the cateogry is set to skip then dont
83 | if (settings.PAUSE_SKIP_CATEGORIES.includes(torrent.category)){
84 | logger.debug(`Category for ${torrent.name} is ${torrent.category} - included in PAUSE_SKIP_CATEGORIES. Wont pause`);
85 | return false;
86 | }
87 |
88 | // Lastly - if any tags are to not skip then dont
89 | const torrentTags = torrent.tags.split(',');
90 | const skipTag = settings.PAUSE_SKIP_TAGS.find(tag => torrentTags.includes(tag));
91 |
92 | if (skipTag !== undefined){
93 | logger.debug(`Tags for ${torrent.name} contains ${skipTag} - included in PAUSE_SKIP_TAGS. Wont pause`);
94 | return false;
95 | }
96 |
97 | // Otherwise we should pause this guy for the upcoming race
98 | return true;
99 | });
100 |
101 | return torrentsToPause;
102 | }
103 |
104 |
--------------------------------------------------------------------------------
/src/racing/tag.ts:
--------------------------------------------------------------------------------
1 | import { torrentFromApi } from "../interfaces.js";
2 | import { QbittorrentApi } from "../qbittorrent/api.js";
3 | import { getLoggerV3 } from "../utils/logger.js";
4 |
5 | export const tagErroredTorrents = async (api: QbittorrentApi, dryRun: boolean) => {
6 | const logger = getLoggerV3();
7 | logger.info(`Starting...`);
8 |
9 | if (dryRun === true) {
10 | logger.info(`--dry-run specified, will not tag any torrents`);
11 | }
12 |
13 | let torrents: torrentFromApi[];
14 |
15 | try {
16 | torrents = await api.getTorrents();
17 | } catch (e) {
18 | logger.error(`Failed to get torrents from api`);
19 | throw new Error("api failure");
20 | }
21 |
22 | let torrentsToTag: torrentFromApi[] = [];
23 |
24 | for (const torrent of torrents) {
25 | const trackers: any[] = await api.getTrackers(torrent.hash);
26 | trackers.splice(0, 3); // Get rid of DHT PEX etc.
27 |
28 | // See if at least one has status=2 , i.e. working
29 | const working = trackers.some(tracker => tracker.status === 2);
30 |
31 | if (working === false) {
32 | logger.warn(`[${torrent.hash}] tracker error for ${torrent.name}`);
33 | torrentsToTag.push(torrent);
34 | }
35 | }
36 |
37 |
38 | if (torrentsToTag.length === 0) {
39 | logger.info(`No torrents to tag`);
40 | return;
41 | }
42 |
43 | if (dryRun === true) {
44 | logger.info(`Reached end of dry run. Exiting...`);
45 | return;
46 | }
47 |
48 | logger.info(`Going to tag ${torrentsToTag.length} torrents...`);
49 |
50 | try {
51 | await api.addTags(torrentsToTag, ['error'])
52 | } catch (e) {
53 | logger.error(`Failed to tag torrents`);
54 | }
55 |
56 | logger.info(`Sucessfully tagged ${torrentsToTag.length} torrents!`);
57 | }
--------------------------------------------------------------------------------
/src/server/app.ts:
--------------------------------------------------------------------------------
1 | // The server for serving prometheus metrics
2 | import fastify from 'fastify';
3 | import { makeMetrics, stateMetrics } from '../helpers/preparePromMetrics.js';
4 | import { QbittorrentApi } from '../qbittorrent/api.js';
5 | import { loginV2 } from '../qbittorrent/auth.js';
6 | import { loadConfig, makeConfigIfNotExist } from '../utils/configV2.js';
7 | import { getLoggerV3 } from '../utils/logger.js';
8 |
9 | const server = fastify();
10 | const logger = getLoggerV3({ logfile: 'prometheus-exporter-logs.txt'});
11 |
12 | makeConfigIfNotExist();
13 | const config = loadConfig();
14 |
15 | let api: QbittorrentApi;
16 |
17 | try {
18 | api = await loginV2(config.QBITTORRENT_SETTINGS);
19 | } catch (e){
20 | logger.error(`Failed to login: ${e}`);
21 | process.exit(1);
22 | }
23 |
24 | server.get('/metrics', async (request, reply) => {
25 | const transferInfo = await api.getTransferInfo();
26 | const torrents = await api.getTorrents();
27 |
28 | let finalMetrics = '';
29 |
30 | finalMetrics += makeMetrics(transferInfo);
31 | finalMetrics += stateMetrics(torrents);
32 |
33 | reply.status(200).headers({
34 | 'Content-Type': 'text/plain'
35 | }).send(finalMetrics);
36 | });
37 |
38 | const PROM_PORT = config.PROMETHEUS_SETTINGS.port;
39 | const PROM_IP = config.PROMETHEUS_SETTINGS.ip;
40 |
41 | server.listen(PROM_PORT, PROM_IP, (err, address) => {
42 | if (err){
43 | logger.error(`Failed to bind to ${PROM_IP}:${PROM_PORT}. Exiting...`)
44 | process.exit(1);
45 | }
46 |
47 | logger.info(`Server started at ${PROM_IP}:${PROM_PORT}`);
48 | });
--------------------------------------------------------------------------------
/src/server/appFactory.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Expose a function that could be called from the binary
3 | */
4 |
5 | import fastify from "fastify";
6 | import { makeMetrics, stateMetrics } from "../helpers/preparePromMetrics.js";
7 | import { QbittorrentApi } from "../qbittorrent/api.js";
8 | import { Settings } from "../utils/config.js";
9 | import { getLoggerV3 } from "../utils/logger.js";
10 |
11 | export const startMetricsServer = (config: Settings, api: QbittorrentApi) => {
12 | const server = fastify();
13 | const logger = getLoggerV3({ logfile: 'prometheus-exporter-logs.txt'});
14 |
15 | server.get('/metrics', async (request, reply) => {
16 | const transferInfo = await api.getTransferInfo();
17 | const torrents = await api.getTorrents();
18 |
19 | let finalMetrics = '';
20 |
21 | finalMetrics += makeMetrics(transferInfo);
22 | finalMetrics += stateMetrics(torrents);
23 |
24 | reply.status(200).headers({
25 | 'Content-Type': 'text/plain'
26 | }).send(finalMetrics);
27 | });
28 |
29 | const PROM_PORT = config.PROMETHEUS_SETTINGS.port;
30 | const PROM_IP = config.PROMETHEUS_SETTINGS.ip;
31 |
32 | server.listen(PROM_PORT, PROM_IP, (err, address) => {
33 | if (err){
34 | logger.error(`Failed to bind to ${PROM_IP}:${PROM_PORT}. Exiting...`)
35 | process.exit(1);
36 | }
37 |
38 | logger.info(`Server started at ${PROM_IP}:${PROM_PORT}`);
39 | });
40 | }
--------------------------------------------------------------------------------
/src/utils/config.ts:
--------------------------------------------------------------------------------
1 | export type DISCORD_SETTINGS = {
2 | /**
3 | * Controls whether the webhook is enabled or not
4 | */
5 | enabled: boolean;
6 | /**
7 | * The URL of the discord webhook to send notifications to
8 | */
9 | webhook: string;
10 | /**
11 | * The username the webhook message will come from
12 | */
13 | botUsername: string;
14 | /**
15 | * The discord profile photo of the "webhook user"
16 | * Should be a URL path to an image
17 | */
18 | botAvatar: string;
19 | }
20 |
21 | export type QBITTORRENT_SETTINGS = {
22 | /**
23 | * The complete URL to the qBittorrent WEB UI. Should have the port
24 | * and url ending path if applicable.
25 | *
26 | * @example http://localhost:8080
27 | * @example https://domain.com:1337/qbit
28 | *
29 | */
30 | url: string;
31 | /**
32 | * Username for qBittorrent web ui
33 | * (default is `admin`)
34 | */
35 | username: string;
36 | /**
37 | * Password for qBittorrent web ui
38 | * (default is `adminadmin` - please change!)
39 | */
40 | password: string;
41 | }
42 |
43 | export type PROMETHEUS_SETTINGS = {
44 | /**
45 | * The ip to bind the metrics server to
46 | */
47 | ip: string,
48 | /**
49 | * The port to listen on
50 | */
51 | port: number,
52 | }
53 |
54 | export type Settings = {
55 | /**
56 | * Number of seconds to wait between reannounces, in milliseconds
57 | */
58 | REANNOUNCE_INTERVAL: number;
59 | /**
60 | * The number of times to attempt reannouncing
61 | */
62 | REANNOUNCE_LIMIT: number;
63 | /**
64 | * All torrents with ratio greater than or equal to this will
65 | * be paused when a new torrent is added for racing.
66 | * Set to -1 to disable pausing of torents
67 | */
68 | PAUSE_RATIO: number;
69 | /**
70 | * Torrents with these tags will be skipped from pause logic
71 | * when adding a new torrent for racing
72 | */
73 | PAUSE_SKIP_TAGS: string[];
74 | /**
75 | * Torrents matching any of these categories will be skipped
76 | * from pause logic when adding a new torrent for racing
77 | */
78 | PAUSE_SKIP_CATEGORIES: string[];
79 | /**
80 | * The maximum number of active "races". If these many races are
81 | * going on, then the download will be skipped
82 | * Set to -1 to ignore checking for ongoing races
83 | */
84 | CONCURRENT_RACES: number;
85 | /**
86 | * Whether stalled downloads should be counted as "downloading" (irrespective of age)
87 | * when deternining whether a torrent should be added for racing (by comparing against
88 | * CONCURRENT_RACES)
89 | */
90 | COUNT_STALLED_DOWNLOADS: boolean;
91 | DISCORD_NOTIFICATIONS: DISCORD_SETTINGS;
92 | QBITTORRENT_SETTINGS: QBITTORRENT_SETTINGS;
93 | PROMETHEUS_SETTINGS: PROMETHEUS_SETTINGS;
94 | /**
95 | * Set of category changes to perform on torrent completion
96 | */
97 | CATEGORY_FINISH_CHANGE: Record;
98 | /**
99 | * Whether we should skip resuming paused torrents at the end of a race
100 | */
101 | SKIP_RESUME: boolean;
102 | }
103 |
104 | /**
105 | * Extra options to be passed via CLI args
106 | */
107 | export type Options = {
108 | /**
109 | * Whether the trackers should be added as tags to the torrent
110 | */
111 | trackerTags: boolean;
112 | /**
113 | * Comma-separated extra tags to add to the torrent
114 | */
115 | extraTags?: string;
116 | }
117 |
118 | export const defaultSettings: Settings = {
119 | REANNOUNCE_INTERVAL: 5000,
120 | REANNOUNCE_LIMIT: 30,
121 | PAUSE_RATIO: 1,
122 | PAUSE_SKIP_TAGS: ["tracker.linux.org", "some_other_tag"],
123 | PAUSE_SKIP_CATEGORIES: ["permaseeding", "some_other_category"],
124 | SKIP_RESUME: false,
125 | CONCURRENT_RACES: 1,
126 | COUNT_STALLED_DOWNLOADS: false,
127 | QBITTORRENT_SETTINGS: {
128 | url: 'http://localhost:8080',
129 | username: 'admin',
130 | password: 'adminadmin',
131 | },
132 | PROMETHEUS_SETTINGS: {
133 | ip: '127.0.0.1',
134 | port: 9999,
135 | },
136 | DISCORD_NOTIFICATIONS: {
137 | enabled: false,
138 | webhook: '',
139 | botUsername: 'qBittorrent',
140 | botAvatar: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/66/New_qBittorrent_Logo.svg/600px-New_qBittorrent_Logo.svg.png'
141 | },
142 | CATEGORY_FINISH_CHANGE: {
143 | 'OLD_CATEGORY': 'NEW_CATEORY'
144 | }
145 | }
--------------------------------------------------------------------------------
/src/utils/configV2.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import fs from 'node:fs';
3 | import os from 'node:os';
4 | import { defaultSettings, Settings } from './config.js';
5 | import { getLoggerV3 } from './logger.js';
6 |
7 | const getConfigDir = (): string => {
8 | const homeDir = os.homedir();
9 | const configDir = path.join(homeDir, '.config/qbit-race/');
10 | return configDir;
11 | }
12 |
13 | const getConfigPath = (): string => {
14 | return path.join(getConfigDir(), 'config.json');
15 | }
16 |
17 | export const getFilePathInConfigDir = (filename: string) => {
18 | return path.join(getConfigDir(), filename);
19 | }
20 |
21 | export const makeConfigDirIfNotExist = () => {
22 | const configDir = getConfigDir();
23 |
24 | try {
25 | const stats = fs.statSync(configDir);
26 |
27 | if (stats.isDirectory() === true) {
28 | return;
29 | }
30 | } catch (e) {
31 | // Probably didnt exist. Try to make
32 | fs.mkdirSync(configDir, { recursive: true });
33 | }
34 | }
35 |
36 | export const makeConfigIfNotExist = () => {
37 | makeConfigDirIfNotExist();
38 | const configDir = getConfigDir();
39 | const logger = getLoggerV3();
40 |
41 | // Now check config
42 | const configFilePath = getConfigPath();
43 |
44 | // Check if config exists
45 | try {
46 | const stats = fs.statSync(configFilePath);
47 |
48 | if (stats.isFile() === true) {
49 | logger.debug(`Config file exists. Will try reading it`);
50 | const configFile = fs.readFileSync(configFilePath);
51 |
52 | const config = JSON.parse(configFile.toString());
53 | return config;
54 | }
55 | } catch (e) {
56 | // Probably doesnt exist
57 | logger.info(`Config does not exist. Writing it now...`);
58 | const defaultConfigToWrite = JSON.stringify(defaultSettings, null, 2);
59 | fs.writeFileSync(configFilePath, defaultConfigToWrite);
60 | }
61 | }
62 |
63 | /**
64 | * loadConfig will attempt to read and then JSON.parse the file
65 | * TODO: Validate its legit config
66 | *
67 | * If the directory / path to config does not exist it will throw!
68 | *
69 | * It's assumed makeConfigIfNotExist() has already been called.
70 | */
71 | export const loadConfig = (): Settings => {
72 | const logger = getLoggerV3();
73 | const configPath = getConfigPath();
74 | const configData = fs.readFileSync(configPath);
75 |
76 | const parsedConfig = JSON.parse(configData.toString());
77 |
78 | // Check if any keys are missing
79 | const allKeysFromDefault = Object.keys(defaultSettings);
80 |
81 | let overwriteConfig = false;
82 |
83 | for (const key of allKeysFromDefault){
84 | if ((key in parsedConfig) === false){
85 | const defaultValue = defaultSettings[key as keyof Settings];
86 | logger.warn(`Missing key ${key} from current config! Will update with default value (${defaultValue})`);
87 | parsedConfig[key] = defaultValue;
88 | overwriteConfig = true;
89 | }
90 | }
91 |
92 | if (overwriteConfig === true){
93 | logger.info(`Overwriting config for missing keys...`);
94 | fs.writeFileSync(configPath, JSON.stringify(parsedConfig, null, 2));
95 | }
96 |
97 | return parsedConfig
98 | }
99 |
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
1 | import { Logger, LOGLEVEL } from "@ckcr4lyf/logger";
2 | import { getFilePathInConfigDir, makeConfigDirIfNotExist } from "./configV2.js";
3 |
4 | type getLoggerOptions = {
5 | skipFile?: boolean,
6 | logfile?: string,
7 | }
8 |
9 | export const getLoggerV3 = (options?: getLoggerOptions): Logger => {
10 | // Hardcoded to DEBUG.
11 | // In future get from user's settings or env var
12 | const logFilename = getFilePathInConfigDir(options?.logfile || 'logs.txt');
13 |
14 | if (options !== undefined){
15 | if (options.skipFile === true){
16 | return new Logger({
17 | loglevel: LOGLEVEL.DEBUG,
18 | });
19 | }
20 | }
21 |
22 | makeConfigDirIfNotExist();
23 |
24 | return new Logger({
25 | loglevel: LOGLEVEL.DEBUG,
26 | filename: logFilename
27 | });
28 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "ES2022",
5 | "noImplicitAny": true,
6 | "removeComments": false,
7 | "strictNullChecks": true,
8 | "preserveConstEnums": true,
9 | "esModuleInterop": true,
10 | "outDir": "./build/",
11 | "sourceMap": true,
12 | "moduleResolution": "node",
13 | "declaration": true,
14 | },
15 | "include": [
16 | "src/**/*",
17 | "__tests__/"
18 | ],
19 | "exclude": [
20 | "node_modules",
21 | ]
22 | }
--------------------------------------------------------------------------------