├── .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 | } --------------------------------------------------------------------------------