diff --git a/.gitignore b/.gitignore
index 42d5292..a9fa3d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,6 @@ config.jsonc
# AI working docs (research, scratch design notes)
ai_docs
+
+# Harness / generated files committed by mistake
+.claude/
diff --git a/Dockerfile b/Dockerfile
index 087c4ce..420930e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -59,7 +59,7 @@ VOLUME ["/config"]
# Run as the image's non-root `bun` user (uid/gid 1000). This matches the
# PUID/PGID the *arr / linuxserver.io images default to, so files jack writes
-# (e.g. finished downloads in the blackhole completed folder) are owned by the
+# (e.g. finished downloads in the completed folder) are owned by the
# same user that imports them. Bind-mounted /config and download folders must
# therefore be readable/writable by uid 1000.
USER bun
diff --git a/README.md b/README.md
index a1fe24d..45dfaa3 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ flags (it can be either, or both):
| Role | What it does | You need it to… |
| --- | --- | --- |
| **`source`** | jack reads your Radarr/Sonarr library and serves it to peers: it searches your movies/episodes that have files and streams those files. | **Share** your library with friends. |
-| **`destination`** | jack registers itself in that Radarr/Sonarr as a Torznab indexer + Torrent Blackhole client and triggers imports of finished downloads. | **Consume** — drive everything from your existing *arr UI. |
+| **`destination`** | jack registers itself in that Radarr/Sonarr as a Torznab indexer + qBittorrent download client and triggers imports of finished downloads. | **Consume** — drive everything from your existing *arr UI. |
| **Peer** | Another **jack** instance — a friend. You list their URL + API key under `peers`; jack queries them when your *arr searches. | **Consume** media your friends have. |
So a typical "both" setup has your Radarr/Sonarr as `source: true` **and**
@@ -69,7 +69,7 @@ docker compose logs -f
```
You should see `Server listening` and, if you configured destinations,
-`Registered Jack as Torznab indexer` and `Registered Jack as Torrent Blackhole
+`Registered Jack as Torznab indexer` and `Registered Jack as qBittorrent
download client` lines.
The compose file mounts three host paths — adjust them for your setup:
@@ -78,21 +78,22 @@ The compose file mounts three host paths — adjust them for your setup:
| --- | --- | --- |
| `./config` → `/config` | App config | `APP_CONFIG_PATH` |
| `${MEDIA_PATH:-./data/media}` → `/data/media` | Your media, so jack can stream it to peers | must match the paths Radarr/Sonarr report |
-| `${TORRENTS_PATH:-./data/torrents}` → `/data/torrents` | Blackhole watch/completed dirs | `downloads.watchPath`, `downloads.completedPath` |
-
-> **Networking:** if Radarr/Sonarr run in their own Docker network,
-> uncomment the `networks:` block in the compose file so jack can reach them by
-> container name (and set `jack.baseUrl` to something they can resolve, e.g.
-> `http://jack:5225`). Otherwise use the host IP.
-
-> ⚠️ **Mount the blackhole folder into Radarr/Sonarr too.** jack registers the
-> Torrent Blackhole download client using the **literal** `downloads.watchPath`
-> and `downloads.completedPath`, and your Radarr/Sonarr resolve those paths in
-> *their own* filesystem. So the same blackhole watch/completed folder must be
-> mounted into your **Radarr and Sonarr** containers at the **exact same paths**
-> jack uses (e.g. `/data/torrents/watch` and `/data/torrents/completed`).
-> If they don't line up, *arr can't drop the stub `.torrent` or import the
-> finished file, and every grab fails.
+| `${TORRENTS_PATH:-./data/torrents}` → `/data/torrents` | Completed downloads dir | `downloads.completedPath` |
+
+> **Networking:** Radarr/Sonarr reach jack's qBittorrent API at `jack.baseUrl`,
+> so it must be resolvable from *their* side. If they run in their own Docker
+> network, uncomment the `networks:` block in the compose file so jack joins it,
+> and set `jack.baseUrl` to something they can resolve (e.g. `http://jack:5225`).
+> Otherwise use the host IP.
+
+> ⚠️ **Mount the completed folder into Radarr/Sonarr too.** jack writes finished
+> downloads to the **literal** `downloads.completedPath`, and your Radarr/Sonarr
+> import them by resolving that path in *their own* filesystem. So the same
+> completed folder must be mounted into your **Radarr and Sonarr** containers at
+> the **exact same path** jack uses (e.g. `/data/torrents/completed`). If they
+> don't line up, *arr can't import the finished file, and every grab fails.
+> (There's no watch folder anymore —*arr hands grabs to jack over the
+> qBittorrent API, not through a dropped `.torrent`.)
> ⚠️ **Mount your media at the same path Radarr/Sonarr report.** jack streams
> files straight from disk using the absolute path each *arr stores for the file
@@ -106,9 +107,9 @@ The compose file mounts three host paths — adjust them for your setup:
## How it works
There are two flows: **searching** for media (Torznab) and **downloading** it
-(the blackhole). The `.torrent` files involved are *not real torrents* — they're
-tiny stubs jack uses to ride on the *arr "torrent blackhole" workflow. Nothing
-ever touches BitTorrent.
+(the qBittorrent API). The `.torrent` files involved are *not real torrents* —
+they're tiny stubs jack hands to *arr, which sends them back to jack through the
+qBittorrent download-client API. Nothing ever touches BitTorrent.
### 1. Search flow (Torznab)
@@ -129,9 +130,9 @@ sequenceDiagram
1. On startup jack **registers itself as a Torznab indexer** in each `destination`
server (Radarr/Sonarr), using `jack.baseUrl` + `jack.apiKey`, and
- **registers a Torrent Blackhole download client** pointed at your
- `downloads` paths (only if `downloads` is configured). (Auto, unless you set
- that server's `autoregister.enable: false`.)
+ **registers a qBittorrent download client** pointing back at jack's own
+ qBittorrent API (`jack.baseUrl`, only if `downloads` is configured). (Auto,
+ unless you set that server's `autoregister.enable: false`.)
2. When you search or monitor something, Radarr/Sonarr query jack's `/torznab`
endpoint with that API key.
3. jack **fans the query out to every `peer`** you've configured, calling their
@@ -143,7 +144,7 @@ sequenceDiagram
6. Radarr/Sonarr show these as grabbable releases — indistinguishable from a
normal indexer's results.
-### 2. Download flow (blackhole)
+### 2. Download flow (qBittorrent API)
```mermaid
sequenceDiagram
@@ -152,27 +153,27 @@ sequenceDiagram
participant FJACK as jack (friend)
ARR->>JACK: grab release → fetch stub .torrent
- Note over ARR,JACK: *arr drops the stub into downloads.watchPath
- Note over JACK: watcher detects file, parses stub (peerId + itemId)
+ ARR->>JACK: POST /api/v2/torrents/add (stub)
+ Note over JACK: parse stub (peerId + itemId), queue download
JACK->>FJACK: GET /peer/items/:id/file
FJACK-->>JACK: streams real file from disk
→ downloads.completedPath
- Note over JACK: delete stub
- JACK->>ARR: import command (scan completed folder)
- Note over ARR: file lands in your library
+ ARR->>JACK: GET /api/v2/torrents/info (poll progress)
+ JACK-->>ARR: completed → content_path = finished file
+ Note over ARR: scans completed folder, imports into library
```
-1. You grab a release. Your *arr's download client is a **Torrent Blackhole**
- client pointed at jack's `downloads.watchPath` (jack registers this client
- for you on startup), so*arr fetches the `.torrent` from jack and drops it
- there.
+1. You grab a release. Your *arr's download client is a **qBittorrent** client
+ pointed at jack's own qBittorrent API (jack registers this client for you on
+ startup), so*arr fetches the stub `.torrent` from jack and immediately POSTs
+ it back to jack at `/api/v2/torrents/add`.
2. That `.torrent` is a **stub** — bencoded data that just encodes the
- `peerId` and `itemId`. No trackers, no pieces.
-3. jack's **blackhole watcher** notices the new file, parses the stub, and finds
- the matching peer.
+ `peerId` and `itemId`. No trackers, no pieces; it's never written to disk.
+3. jack parses the stub, finds the matching peer, and queues the download.
4. jack **downloads the real file over HTTP** from that peer's
`/peer/items/:id/file` endpoint into `downloads.completedPath`.
-5. jack deletes the stub and tells your Radarr/Sonarr to **scan the completed
- folder and import**, so the file lands in your library, renamed and tracked.
+5. *arr polls jack's qBittorrent API (`/api/v2/torrents/info`) for progress;
+ once jack reports the torrent complete,*arr scans the completed folder and
+ imports the file into your library, renamed and tracked.
### 3. Serving (being a peer to others)
@@ -257,10 +258,10 @@ you're doing.
"apiKey": "a-long-random-string" // openssl rand -hex 32 — see "The API key"
},
- // Blackhole watcher. Needed to *consume* (download) from peers.
- // Paths are inside the container; jack creates them if missing.
+ // Downloads. Needed to *consume* (download) from peers — jack registers
+ // itself as a qBittorrent download client and writes finished files here.
+ // Path is inside the container; jack creates it if missing.
"downloads": {
- "watchPath": "/data/torrents/watch", // *arr drops stub .torrents here
"completedPath": "/data/torrents/completed" // jack writes finished files here
},
@@ -319,10 +320,18 @@ Field notes:
Authelia, or a custom gateway. These are outbound connector headers only; jack
still adds the required *arr `X-Api-Key` or peer `X-Api-Key` auth header
separately.
-- **`downloads.watchPath` / `downloads.completedPath`** must also be mounted into
- your **Radarr and Sonarr** containers at the **same paths** — jack registers
- the Torrent Blackhole client with these literal paths and *arr resolves them in
- its own filesystem (see the callout in [Quick start](#quick-start-docker-compose)).
+- **`downloads.completedPath`** must also be mounted into your **Radarr and
+ Sonarr** containers at the **same path** — jack writes finished files there and
+ *arr resolves that path in its own filesystem to import them (see the callout in
+ [Quick start](#quick-start-docker-compose)). The other `downloads.*` keys are
+ optional tuning knobs (concurrency and retry backoff).
+- **Download client isolation.** jack registers its qBittorrent client at *arr's
+ **lowest** priority (50).*arr's general client pool only round-robins among the
+ best-priority group, so real torrents grabbed from your other indexers never get
+ routed to jack's client (they'd be rejected anyway). Grabs from the Jack indexer
+ still reach it because the indexer is bound to the client explicitly
+ (`downloadClientId`), which *arr resolves before applying priority. No tags or
+ extra config needed.
### Secrets from environment variables or files
@@ -441,28 +450,32 @@ mise run dev # bun --cwd apps/backend --hot src/index.ts
Registration runs on every startup and logs the *arr response body, so check
`docker compose logs jack` first. The common failures:
-### `Failed to register download client` — "Folder does not exist" (`TorrentFolder` / `WatchFolder`)
+### qBittorrent download client test fails / `Failed to register download client`
-```jsonc
-{
- "propertyName": "TorrentFolder", "errorMessage": "Folder does not exist"
- // …
-}
-```
+The qBittorrent client *arr registers (or the "Test" button in
+Settings → Download Clients) connects to jack's qBittorrent API at the host/port
+jack derives from `jack.baseUrl`. A failing test almost always means*arr can't
+reach that address.
+
+**Fix:** make sure `jack.baseUrl` is resolvable **from the Radarr/Sonarr side**.
+On a shared Docker network use the container name (`http://jack:5225`); otherwise
+the host IP/domain. If *arr and jack are on different networks, attach jack to
+*arr's network (the `networks:` block in the example compose).
+
+### Grabs download but never import
-jack registers the Torrent Blackhole client using the **literal**
-`downloads.watchPath` / `downloads.completedPath`, and Radarr/Sonarr resolve
-those paths in **their own** filesystem. This error means those paths don't
-exist *inside the Radarr/Sonarr containers* — almost always because the
-blackhole folders are only mounted into jack, not into *arr.
+The download completes in jack (you see it finish in the logs) but Radarr/Sonarr
+never pick the file up. jack writes finished files to the **literal**
+`downloads.completedPath`, and *arr imports them by resolving that path in **its
+own** filesystem — so the completed folder must exist at the **same path** inside
+the Radarr/Sonarr containers.
-**Fix:** mount the **same host folders** into Radarr **and** Sonarr at the
-**same container paths** jack uses. If jack has:
+**Fix:** mount the **same host folder** into Radarr **and** Sonarr at the **same
+container path** jack uses. If jack has:
```yaml
# jack
volumes:
- - /srv/media/jack-watch:/data/torrents/watch
- /srv/media/jack-completed:/data/torrents/completed
```
@@ -471,41 +484,36 @@ then Radarr and Sonarr each need:
```yaml
# radarr AND sonarr
volumes:
- - /srv/media/jack-watch:/data/torrents/watch
- /srv/media/jack-completed:/data/torrents/completed
```
Two gotchas:
- **Use a dedicated completed folder.** Don't point `completedPath` at a folder
- another download client (e.g. qBittorrent's `/downloads`) already writes to,
- or *arr's blackhole client will try to import unrelated files.
-- **Watch permissions.** jack runs as **uid/gid 1000**, matching the `PUID/PGID`
- the linuxserver.io *arr images default to, so files jack writes are owned by
- the same user that imports them. Make sure the watch/completed folders (and the
- `/config` mount) are readable/writable by uid 1000 — `chown -R 1000:1000` them
- if your*arr uses a different `PUID`, set it to match.
+ another download client (e.g. a real qBittorrent's `/downloads`) already writes
+ to, or *arr will try to import unrelated files.
+- **Permissions.** jack runs as **uid/gid 1000**, matching the `PUID/PGID` the
+ linuxserver.io *arr images default to, so files jack writes are owned by the
+ same user that imports them. Make sure the completed folder (and the `/config`
+ mount) are readable/writable by uid 1000 — `chown -R 1000:1000` them; if your
+*arr uses a different `PUID`, set it to match.
-### No indexer or download client registered
+### `No "downloads" config set` — no download client registered
```
-No peers configured; skipping indexer and download client registration (nothing to search or grab yet).
+No "downloads" config set; skipping download client auto-registration. Grabs will fail until a qBittorrent client is configured.
```
-jack only registers itself in Radarr/Sonarr when you have at least one `peer` —
-without peers there's nothing to search and nothing to grab, and Radarr/Sonarr
-reject an indexer whose test query returns no results anyway. So with **no
-`peers` configured** jack deliberately skips registration (look for `"peers":0`
-in the `Server listening` log line).
+jack only registers the qBittorrent download client when a `downloads` block is
+present in your config (it needs `completedPath` to know where to write files).
+Without it, the Torznab indexer is still registered so searches work, but grabs
+have nowhere to go.
-**Fix:** configure at least one entry under `peers`. The indexer and
-download client are registered on the next startup once there's a peer behind
-them.
+**Fix:** add a `downloads` block with `completedPath` and restart jack.
-If you *do* have peers but registration still fails with
-`Failed to register indexer` / *"no results in the configured categories"*, it
-means the test query returned nothing — check that the peer is reachable and its
-library actually contains matching items.
+If registration fails with `Failed to register indexer`, check that the
+destination server is reachable and its API key is correct — registration logs
+the raw *arr response body at `error` level.
### `ConnectionRefused` on startup
@@ -520,9 +528,9 @@ retried lazily the next time a search/download needs them, so they can recover
without a restart once the remote service is up.
Auto-registration still runs only during startup, so a destination that was down
-at boot may need a jack restart before the Torznab indexer or Torrent Blackhole
-client is created in Radarr/Sonarr. To make startup deterministic, wait for the
-dependencies to be healthy:
+at boot may need a jack restart before the Torznab indexer or qBittorrent
+download client is created in Radarr/Sonarr. To make startup deterministic, wait
+for the dependencies to be healthy:
```yaml
# jack
@@ -558,7 +566,7 @@ mise run clients # regenerate packages/schemas/src/generated
## Project layout
```
-apps/backend # the Hono server (Torznab, peer API, blackhole watcher)
+apps/backend # the Hono server (Torznab, peer API, qBittorrent API)
packages/schemas # generated Radarr/Sonarr API types
examples/ # docker-compose.yml + config.jsonc template
Dockerfile # multi-stage production image
diff --git a/apps/backend/drizzle/0002_romantic_stryfe.sql b/apps/backend/drizzle/0002_romantic_stryfe.sql
new file mode 100644
index 0000000..ef72827
--- /dev/null
+++ b/apps/backend/drizzle/0002_romantic_stryfe.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `downloads` ADD `qb_category` text;--> statement-breakpoint
+ALTER TABLE `downloads` ADD `qb_source_server` text;
\ No newline at end of file
diff --git a/apps/backend/drizzle/0003_high_makkari.sql b/apps/backend/drizzle/0003_high_makkari.sql
new file mode 100644
index 0000000..aa1c825
--- /dev/null
+++ b/apps/backend/drizzle/0003_high_makkari.sql
@@ -0,0 +1,34 @@
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_downloads` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `torrent_filename` text NOT NULL,
+ `peer_id` text NOT NULL,
+ `peer_name` text NOT NULL,
+ `item_id` text NOT NULL,
+ `filename` text NOT NULL,
+ `dest_path` text NOT NULL,
+ `part_path` text NOT NULL,
+ `release_size` integer NOT NULL,
+ `release_json` text NOT NULL,
+ `expected_bytes` integer,
+ `expected_bytes_source` text,
+ `expected_bytes_mismatch` integer DEFAULT false NOT NULL,
+ `downloaded_bytes` integer DEFAULT 0 NOT NULL,
+ `attempts` integer DEFAULT 0 NOT NULL,
+ `status` text NOT NULL,
+ `started_at` text NOT NULL,
+ `updated_at` text NOT NULL,
+ `completed_at` text,
+ `error` text,
+ `qb_category` text,
+ `qb_source_server` text,
+ CONSTRAINT "downloads_status_check" CHECK("__new_downloads"."status" in ('downloading', 'completed', 'failed', 'import_queued')),
+ CONSTRAINT "downloads_expected_bytes_source_check" CHECK("__new_downloads"."expected_bytes_source" is null or "__new_downloads"."expected_bytes_source" in ('content_length', 'release_size'))
+);
+--> statement-breakpoint
+INSERT INTO `__new_downloads`("id", "torrent_filename", "peer_id", "peer_name", "item_id", "filename", "dest_path", "part_path", "release_size", "release_json", "expected_bytes", "expected_bytes_source", "expected_bytes_mismatch", "downloaded_bytes", "attempts", "status", "started_at", "updated_at", "completed_at", "error", "qb_category", "qb_source_server") SELECT "id", "torrent_filename", "peer_id", "peer_name", "item_id", "filename", "dest_path", "part_path", "release_size", "release_json", "expected_bytes", "expected_bytes_source", "expected_bytes_mismatch", "downloaded_bytes", "attempts", "status", "started_at", "updated_at", "completed_at", "error", "qb_category", "qb_source_server" FROM `downloads`;--> statement-breakpoint
+DROP TABLE `downloads`;--> statement-breakpoint
+ALTER TABLE `__new_downloads` RENAME TO `downloads`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+CREATE INDEX `downloads_status_idx` ON `downloads` (`status`);--> statement-breakpoint
+CREATE INDEX `downloads_updated_at_idx` ON `downloads` (`updated_at`);
\ No newline at end of file
diff --git a/apps/backend/drizzle/0004_sleepy_retro_girl.sql b/apps/backend/drizzle/0004_sleepy_retro_girl.sql
new file mode 100644
index 0000000..753730c
--- /dev/null
+++ b/apps/backend/drizzle/0004_sleepy_retro_girl.sql
@@ -0,0 +1,34 @@
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_downloads` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `torrent_filename` text NOT NULL,
+ `peer_id` text NOT NULL,
+ `peer_name` text NOT NULL,
+ `item_id` text NOT NULL,
+ `filename` text NOT NULL,
+ `dest_path` text NOT NULL,
+ `part_path` text NOT NULL,
+ `release_size` integer NOT NULL,
+ `release_json` text NOT NULL,
+ `expected_bytes` integer,
+ `expected_bytes_source` text,
+ `expected_bytes_mismatch` integer DEFAULT false NOT NULL,
+ `downloaded_bytes` integer DEFAULT 0 NOT NULL,
+ `attempts` integer DEFAULT 0 NOT NULL,
+ `status` text NOT NULL,
+ `started_at` text NOT NULL,
+ `updated_at` text NOT NULL,
+ `completed_at` text,
+ `error` text,
+ `qb_category` text,
+ `qb_source_server` text,
+ CONSTRAINT "downloads_status_check" CHECK("__new_downloads"."status" in ('downloading', 'completed', 'failed', 'import_queued')),
+ CONSTRAINT "downloads_expected_bytes_source_check" CHECK("__new_downloads"."expected_bytes_source" is null or "__new_downloads"."expected_bytes_source" in ('content_length', 'content_range', 'release_size'))
+);
+--> statement-breakpoint
+INSERT INTO `__new_downloads`("id", "torrent_filename", "peer_id", "peer_name", "item_id", "filename", "dest_path", "part_path", "release_size", "release_json", "expected_bytes", "expected_bytes_source", "expected_bytes_mismatch", "downloaded_bytes", "attempts", "status", "started_at", "updated_at", "completed_at", "error", "qb_category", "qb_source_server") SELECT "id", "torrent_filename", "peer_id", "peer_name", "item_id", "filename", "dest_path", "part_path", "release_size", "release_json", "expected_bytes", "expected_bytes_source", "expected_bytes_mismatch", "downloaded_bytes", "attempts", "status", "started_at", "updated_at", "completed_at", "error", "qb_category", "qb_source_server" FROM `downloads`;--> statement-breakpoint
+DROP TABLE `downloads`;--> statement-breakpoint
+ALTER TABLE `__new_downloads` RENAME TO `downloads`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+CREATE INDEX `downloads_status_idx` ON `downloads` (`status`);--> statement-breakpoint
+CREATE INDEX `downloads_updated_at_idx` ON `downloads` (`updated_at`);
\ No newline at end of file
diff --git a/apps/backend/drizzle/meta/0002_snapshot.json b/apps/backend/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..1304f36
--- /dev/null
+++ b/apps/backend/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,209 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "5fc3b7b0-f29c-475e-937f-e87648e8bf40",
+ "prevId": "0e170273-951e-4b46-b8be-8d77d6188596",
+ "tables": {
+ "downloads": {
+ "name": "downloads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "torrent_filename": {
+ "name": "torrent_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "peer_name": {
+ "name": "peer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dest_path": {
+ "name": "dest_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "part_path": {
+ "name": "part_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_size": {
+ "name": "release_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_json": {
+ "name": "release_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expected_bytes": {
+ "name": "expected_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expected_bytes_source": {
+ "name": "expected_bytes_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expected_bytes_mismatch": {
+ "name": "expected_bytes_mismatch",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "downloaded_bytes": {
+ "name": "downloaded_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qb_category": {
+ "name": "qb_category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qb_source_server": {
+ "name": "qb_source_server",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "downloads_status_idx": {
+ "name": "downloads_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "downloads_updated_at_idx": {
+ "name": "downloads_updated_at_idx",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "downloads_status_check": {
+ "name": "downloads_status_check",
+ "value": "\"downloads\".\"status\" in ('downloading', 'completed', 'failed', 'import_queued')"
+ },
+ "downloads_expected_bytes_source_check": {
+ "name": "downloads_expected_bytes_source_check",
+ "value": "\"downloads\".\"expected_bytes_source\" is null or \"downloads\".\"expected_bytes_source\" = 'content_length'"
+ }
+ }
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/apps/backend/drizzle/meta/0003_snapshot.json b/apps/backend/drizzle/meta/0003_snapshot.json
new file mode 100644
index 0000000..976684c
--- /dev/null
+++ b/apps/backend/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,209 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "6c9640c3-d5d1-4bd2-82e3-bc067ea7f95f",
+ "prevId": "5fc3b7b0-f29c-475e-937f-e87648e8bf40",
+ "tables": {
+ "downloads": {
+ "name": "downloads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "torrent_filename": {
+ "name": "torrent_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "peer_name": {
+ "name": "peer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dest_path": {
+ "name": "dest_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "part_path": {
+ "name": "part_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_size": {
+ "name": "release_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_json": {
+ "name": "release_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expected_bytes": {
+ "name": "expected_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expected_bytes_source": {
+ "name": "expected_bytes_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expected_bytes_mismatch": {
+ "name": "expected_bytes_mismatch",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "downloaded_bytes": {
+ "name": "downloaded_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qb_category": {
+ "name": "qb_category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qb_source_server": {
+ "name": "qb_source_server",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "downloads_status_idx": {
+ "name": "downloads_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "downloads_updated_at_idx": {
+ "name": "downloads_updated_at_idx",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "downloads_status_check": {
+ "name": "downloads_status_check",
+ "value": "\"downloads\".\"status\" in ('downloading', 'completed', 'failed', 'import_queued')"
+ },
+ "downloads_expected_bytes_source_check": {
+ "name": "downloads_expected_bytes_source_check",
+ "value": "\"downloads\".\"expected_bytes_source\" is null or \"downloads\".\"expected_bytes_source\" in ('content_length', 'release_size')"
+ }
+ }
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/apps/backend/drizzle/meta/0004_snapshot.json b/apps/backend/drizzle/meta/0004_snapshot.json
new file mode 100644
index 0000000..ba893ba
--- /dev/null
+++ b/apps/backend/drizzle/meta/0004_snapshot.json
@@ -0,0 +1,209 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "3692fbf7-4334-4a78-a0a0-4ad366a37cec",
+ "prevId": "6c9640c3-d5d1-4bd2-82e3-bc067ea7f95f",
+ "tables": {
+ "downloads": {
+ "name": "downloads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "torrent_filename": {
+ "name": "torrent_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "peer_id": {
+ "name": "peer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "peer_name": {
+ "name": "peer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dest_path": {
+ "name": "dest_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "part_path": {
+ "name": "part_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_size": {
+ "name": "release_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_json": {
+ "name": "release_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expected_bytes": {
+ "name": "expected_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expected_bytes_source": {
+ "name": "expected_bytes_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expected_bytes_mismatch": {
+ "name": "expected_bytes_mismatch",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "downloaded_bytes": {
+ "name": "downloaded_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qb_category": {
+ "name": "qb_category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qb_source_server": {
+ "name": "qb_source_server",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "downloads_status_idx": {
+ "name": "downloads_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "downloads_updated_at_idx": {
+ "name": "downloads_updated_at_idx",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "downloads_status_check": {
+ "name": "downloads_status_check",
+ "value": "\"downloads\".\"status\" in ('downloading', 'completed', 'failed', 'import_queued')"
+ },
+ "downloads_expected_bytes_source_check": {
+ "name": "downloads_expected_bytes_source_check",
+ "value": "\"downloads\".\"expected_bytes_source\" is null or \"downloads\".\"expected_bytes_source\" in ('content_length', 'content_range', 'release_size')"
+ }
+ }
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json
index 643afd8..6c836c2 100644
--- a/apps/backend/drizzle/meta/_journal.json
+++ b/apps/backend/drizzle/meta/_journal.json
@@ -15,6 +15,27 @@
"when": 1780707145431,
"tag": "0001_tearful_the_fallen",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "6",
+ "when": 1780763364775,
+ "tag": "0002_romantic_stryfe",
+ "breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "6",
+ "when": 1780791754764,
+ "tag": "0003_high_makkari",
+ "breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "6",
+ "when": 1780819908039,
+ "tag": "0004_sleepy_retro_girl",
+ "breakpoints": true
}
]
}
diff --git a/apps/backend/src/__tests__/config.test.ts b/apps/backend/src/__tests__/config.test.ts
index a8faa35..99fc16e 100644
--- a/apps/backend/src/__tests__/config.test.ts
+++ b/apps/backend/src/__tests__/config.test.ts
@@ -280,19 +280,20 @@ describe('appConfig parsing', () => {
test('defaults the downloads hardening knobs', () => {
const parsed = AppConfig.parse({
- downloads: { watchPath: '/w', completedPath: '/c' },
+ downloads: { completedPath: '/c' },
})
expect(parsed.downloads).toMatchObject({
maxConcurrentDownloads: 3,
- maxDownloadAttempts: 5,
+ maxDownloadAttempts: 13,
retryBaseDelayMs: 1000,
- retryMaxDelayMs: 60_000,
+ retryMaxDelayMs: 1_800_000,
+ idleTimeoutMs: 60_000,
})
})
test('respects an explicit maxConcurrentDownloads and rejects non-positive values', () => {
- const parsed = AppConfig.parse({ downloads: { watchPath: '/w', completedPath: '/c', maxConcurrentDownloads: 8 } })
+ const parsed = AppConfig.parse({ downloads: { completedPath: '/c', maxConcurrentDownloads: 8 } })
expect(parsed.downloads?.maxConcurrentDownloads).toBe(8)
- expect(AppConfig.safeParse({ downloads: { watchPath: '/w', completedPath: '/c', maxConcurrentDownloads: 0 } }).success).toBe(false)
+ expect(AppConfig.safeParse({ downloads: { completedPath: '/c', maxConcurrentDownloads: 0 } }).success).toBe(false)
})
})
diff --git a/apps/backend/src/__tests__/database.test.ts b/apps/backend/src/__tests__/database.test.ts
index 31058e3..5c8c8b5 100644
--- a/apps/backend/src/__tests__/database.test.ts
+++ b/apps/backend/src/__tests__/database.test.ts
@@ -125,6 +125,29 @@ describe('DownloadsRepository', () => {
handle.close()
})
+ test('persists release_size as an expected-bytes source (CHECK accepts it)', async () => {
+ const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') })
+ const repository = new DownloadsRepository(handle.db)
+ const created = repository.create({
+ torrentFilename: 'movie.torrent',
+ peerId: 'peer-1',
+ peerName: 'Friend Jack',
+ itemId: 'movie:1',
+ filename: release.filename,
+ destPath: join(tempDir, release.filename),
+ partPath: join(tempDir, `${release.filename}.part`),
+ releaseSize: release.size,
+ release,
+ })
+
+ repository.setExpectedBytes(created.id, 123, 'release_size', false)
+
+ const row = repository.get(created.id)!
+ expect(row.expectedBytes).toBe(123)
+ expect(row.expectedBytesSource).toBe('release_size')
+ handle.close()
+ })
+
test('reconciles stale downloading rows using .part file size', async () => {
const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') })
const repository = new DownloadsRepository(handle.db)
@@ -180,6 +203,46 @@ describe('DownloadsRepository', () => {
handle.close()
})
+ test('persists qbCategory and qbSourceServer round-trip; defaults to null', async () => {
+ const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') })
+ const repository = new DownloadsRepository(handle.db)
+
+ const withQb = repository.create({
+ torrentFilename: 'movie.torrent',
+ peerId: 'peer-1',
+ peerName: 'Friend Jack',
+ itemId: 'movie:1',
+ filename: release.filename,
+ destPath: join(tempDir, release.filename),
+ partPath: join(tempDir, `${release.filename}.part`),
+ releaseSize: release.size,
+ release,
+ qbCategory: 'jack-abc12345',
+ qbSourceServer: 'My Radarr',
+ })
+
+ expect(withQb.qbCategory).toBe('jack-abc12345')
+ expect(withQb.qbSourceServer).toBe('My Radarr')
+ expect(repository.get(withQb.id)?.qbCategory).toBe('jack-abc12345')
+ expect(repository.get(withQb.id)?.qbSourceServer).toBe('My Radarr')
+
+ const withoutQb = repository.create({
+ torrentFilename: 'second.torrent',
+ peerId: 'peer-1',
+ peerName: 'Friend Jack',
+ itemId: 'movie:2',
+ filename: 'Second.mkv',
+ destPath: join(tempDir, 'Second.mkv'),
+ partPath: join(tempDir, 'Second.mkv.part'),
+ releaseSize: 200,
+ release: { ...release, id: 'remote:movie:2', filename: 'Second.mkv', size: 200 },
+ })
+
+ expect(withoutQb.qbCategory).toBeNull()
+ expect(withoutQb.qbSourceServer).toBeNull()
+ handle.close()
+ })
+
test('lists stale downloading rows without mutating them', async () => {
const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') })
const repository = new DownloadsRepository(handle.db)
diff --git a/apps/backend/src/__tests__/downloads-api.test.ts b/apps/backend/src/__tests__/downloads-api.test.ts
index 4df87ff..329617b 100644
--- a/apps/backend/src/__tests__/downloads-api.test.ts
+++ b/apps/backend/src/__tests__/downloads-api.test.ts
@@ -22,7 +22,7 @@ const envs: Envs = {
const config = AppConfig.parse({
jack: { baseUrl: 'http://localhost:3000', apiKey: 'test-api-key' },
- downloads: { watchPath: '/tmp/watch', completedPath: '/tmp/completed' },
+ downloads: { completedPath: '/tmp/completed' },
servers: [],
peers: [],
})
diff --git a/apps/backend/src/__tests__/downloads-service.test.ts b/apps/backend/src/__tests__/downloads-service.test.ts
index 9149bcc..23cd17f 100644
--- a/apps/backend/src/__tests__/downloads-service.test.ts
+++ b/apps/backend/src/__tests__/downloads-service.test.ts
@@ -1,5 +1,5 @@
import type { Release } from '../lib/release'
-import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
@@ -7,7 +7,6 @@ import { openDatabase } from '../database/connection'
import { FetchError } from '../lib/errors/FetchError'
import { DownloadsRepository } from '../modules/downloads/downloads.repository'
import { DownloadsService } from '../modules/downloads/downloads.service'
-import { createTorrentStub } from '../modules/torznab/torrent'
const release: Release = {
id: 'remote:movie:1',
@@ -18,14 +17,12 @@ const release: Release = {
}
let tempDir: string
-let watchPath: string
let completedPath: string
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'jack-downloads-service-'))
- watchPath = join(tempDir, 'watch')
completedPath = join(tempDir, 'completed')
- await Bun.$`mkdir -p ${watchPath} ${completedPath}`.quiet()
+ await Bun.$`mkdir -p ${completedPath}`.quiet()
})
afterEach(async () => {
@@ -34,12 +31,12 @@ afterEach(async () => {
function downloadsConfig(overrides: Partial> = {}) {
return {
- watchPath,
completedPath,
maxConcurrentDownloads: 2,
maxDownloadAttempts: 3,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0,
+ idleTimeoutMs: 60_000,
...overrides,
}
}
@@ -58,14 +55,10 @@ function fakePeer(overrides: Partial>
}
}
-function fakeDestination() {
- return { isInitialized: true, canDestination: true, name: 'Radarr', categories: [2000], triggerImport: async () => {} }
-}
-
-async function writeTorrent(filename = 'movie.torrent', itemId = 'movie:1') {
- const filePath = join(watchPath, filename)
- await writeFile(filePath, createTorrentStub({ name: release.title, size: release.size, peerId: 'peer-1', itemId }))
- return filePath
+// Poll until the (single) row reaches a terminal status, or the timeout elapses.
+async function waitForStatus(repository: DownloadsRepository, status: string) {
+ for (let i = 0; i < 50 && repository.list()[0]?.status !== status; i++)
+ await Bun.sleep(10)
}
describe('DownloadsService download progress persistence', () => {
@@ -81,16 +74,15 @@ describe('DownloadsService download progress persistence', () => {
await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 })
},
})
- const service = new DownloadsService(downloadsConfig(), [peer as any], [fakeDestination() as any], repository)
- const filePath = await writeTorrent()
+ const service = new DownloadsService(downloadsConfig(), [peer as any], repository)
- await service.processTorrentFile(filePath, 'movie.torrent')
+ await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' })
+ await waitForStatus(repository, 'import_queued')
expect(calls).toHaveLength(1)
expect(calls[0].destPath).toBe(join(completedPath, release.filename))
expect(calls[0].options.partPath).toBe(`${join(completedPath, release.filename)}.part`)
expect(calls[0].options.releaseSize).toBe(10)
- expect(calls[0].options.torrentFilename).toBe('movie.torrent')
const downloads = repository.list()
expect(downloads).toHaveLength(1)
@@ -106,31 +98,11 @@ describe('DownloadsService download progress persistence', () => {
const peer = fakePeer({ getRelease: async () => {
throw new Error('metadata failed')
} })
- const service = new DownloadsService(downloadsConfig(), [peer as any], [], repository)
- const filePath = await writeTorrent()
-
- await service.processTorrentFile(filePath, 'movie.torrent')
-
- expect(repository.list()).toHaveLength(0)
- handle.close()
- })
-
- test('rejects a peer release with a path-traversal filename', async () => {
- const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') })
- const repository = new DownloadsRepository(handle.db)
- const writtenPaths: string[] = []
- const peer = fakePeer({
- getRelease: async () => ({ ...release, filename: '../../evil.mkv' }),
- downloadFile: async (_itemId: string, destPath: string) => {
- writtenPaths.push(destPath)
- },
- })
- const service = new DownloadsService(downloadsConfig(), [peer as any], [fakeDestination() as any], repository)
- const filePath = await writeTorrent()
+ const service = new DownloadsService(downloadsConfig(), [peer as any], repository)
- await service.processTorrentFile(filePath, 'movie.torrent')
+ const result = await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' })
- expect(writtenPaths).toHaveLength(0)
+ expect(result).toBe('failed')
expect(repository.list()).toHaveLength(0)
handle.close()
})
@@ -143,10 +115,10 @@ describe('DownloadsService download progress persistence', () => {
calls++
throw new FetchError('not found', new Response(null, { status: 404 }))
} })
- const service = new DownloadsService(downloadsConfig(), [peer as any], [], repository)
- const filePath = await writeTorrent()
+ const service = new DownloadsService(downloadsConfig(), [peer as any], repository)
- await service.processTorrentFile(filePath, 'movie.torrent')
+ await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' })
+ await waitForStatus(repository, 'failed')
expect(calls).toBe(1) // 404 is permanent — no retry
const downloads = repository.list()
@@ -166,10 +138,10 @@ describe('DownloadsService download progress persistence', () => {
await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 })
},
})
- const service = new DownloadsService(downloadsConfig(), [peer as any], [fakeDestination() as any], repository)
- const filePath = await writeTorrent()
+ const service = new DownloadsService(downloadsConfig(), [peer as any], repository)
- await service.processTorrentFile(filePath, 'movie.torrent')
+ await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' })
+ await waitForStatus(repository, 'import_queued')
expect(calls).toBe(2)
const downloads = repository.list()
@@ -192,10 +164,10 @@ describe('DownloadsService download progress persistence', () => {
await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 })
},
})
- const service = new DownloadsService(downloadsConfig(), [peer as any], [fakeDestination() as any], repository)
- const filePath = await writeTorrent()
+ const service = new DownloadsService(downloadsConfig(), [peer as any], repository)
- await service.processTorrentFile(filePath, 'movie.torrent')
+ await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' })
+ await waitForStatus(repository, 'import_queued')
expect(resetSpy).toHaveBeenCalledTimes(1)
expect(repository.list()[0]?.status).toBe('import_queued')
@@ -222,14 +194,12 @@ describe('DownloadsService download progress persistence', () => {
await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 })
},
}
- const service = new DownloadsService(downloadsConfig({ maxConcurrentDownloads: 1 }), [peer as any], [fakeDestination() as any], repository)
- const a = await writeTorrent('a.torrent', 'movie:1')
- const b = await writeTorrent('b.torrent', 'movie:2')
+ const service = new DownloadsService(downloadsConfig({ maxConcurrentDownloads: 1 }), [peer as any], repository)
- await Promise.all([
- service.processTorrentFile(a, 'a.torrent'),
- service.processTorrentFile(b, 'b.torrent'),
- ])
+ await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' })
+ await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:2', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' })
+ for (let i = 0; i < 100 && repository.list().filter(d => d.status === 'import_queued').length < 2; i++)
+ await Bun.sleep(10)
expect(maxActive).toBe(1)
expect(repository.list().filter(d => d.status === 'import_queued')).toHaveLength(2)
@@ -258,12 +228,11 @@ describe('DownloadsService download progress persistence', () => {
releaseSize: release.size,
release,
})
- const service = new DownloadsService(downloadsConfig(), [peer as any], [fakeDestination() as any], repository)
+ const service = new DownloadsService(downloadsConfig(), [peer as any], repository)
const resumed = await service.resumeStaleDownloads()
// resumeStaleDownloads fires in the background; wait for the row to settle.
- for (let i = 0; i < 50 && repository.list()[0]?.status !== 'import_queued'; i++)
- await Bun.sleep(10)
+ await waitForStatus(repository, 'import_queued')
expect(resumed).toBe(1)
expect(calls).toEqual([join(completedPath, release.filename)])
@@ -294,7 +263,7 @@ describe('DownloadsService download progress persistence', () => {
}
repository.create({ ...base, torrentFilename: 'first.torrent' })
repository.create({ ...base, torrentFilename: 'second.torrent' })
- const service = new DownloadsService(downloadsConfig(), [peer as any], [fakeDestination() as any], repository)
+ const service = new DownloadsService(downloadsConfig(), [peer as any], repository)
const resumed = await service.resumeStaleDownloads()
for (let i = 0; i < 50 && !repository.list().some(d => d.status === 'import_queued'); i++)
@@ -308,69 +277,44 @@ describe('DownloadsService download progress persistence', () => {
handle.close()
})
- test('releases the re-enqueue claim after a successful resume so the filename can be processed again', async () => {
+ test('startQbDownload creates a row with qb fields and ends import_queued', async () => {
const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') })
const repository = new DownloadsRepository(handle.db)
- const calls: string[] = []
- const peer = fakePeer({
- downloadFile: async (itemId: string, _destPath: string, options: any) => {
- calls.push(itemId)
- await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 })
- },
- })
- repository.create({
- torrentFilename: 'movie.torrent',
+ const service = new DownloadsService(downloadsConfig(), [fakePeer() as any], repository)
+
+ const result = await service.startQbDownload({
peerId: 'peer-1',
- peerName: 'Friend Jack',
itemId: 'movie:1',
- filename: release.filename,
- destPath: join(completedPath, release.filename),
- partPath: `${join(completedPath, release.filename)}.part`,
- releaseSize: release.size,
- release,
+ qbCategory: 'jack-x',
+ qbSourceServer: 'My Radarr',
})
- const service = new DownloadsService(downloadsConfig(), [peer as any], [fakeDestination() as any], repository)
-
- await service.resumeStaleDownloads()
- for (let i = 0; i < 50 && repository.list()[0]?.status !== 'import_queued'; i++)
- await Bun.sleep(10)
- expect(calls).toHaveLength(1)
- // A later legitimate re-drop of the same torrent filename must NOT be skipped
- // by a stale re-enqueue claim once the resume has completed.
- const filePath = await writeTorrent('movie.torrent')
- await service.processTorrentFile(filePath, 'movie.torrent')
+ expect(result).toBe('started')
+ await waitForStatus(repository, 'import_queued')
- expect(calls).toHaveLength(2)
- expect(repository.list().filter(d => d.status === 'import_queued')).toHaveLength(2)
+ const rows = repository.list()
+ expect(rows).toHaveLength(1)
+ expect(rows[0]?.status).toBe('import_queued')
+ expect(rows[0]?.qbCategory).toBe('jack-x')
+ expect(rows[0]?.qbSourceServer).toBe('My Radarr')
handle.close()
})
- test('only triggers import on destinations whose categories match the release', async () => {
+ test('startQbDownload returns failed when the release filename is unsafe', async () => {
const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') })
const repository = new DownloadsRepository(handle.db)
- const triggered: string[] = []
- function dest(name: string, categories: number[]) {
- return {
- isInitialized: true,
- canDestination: true,
- name,
- categories,
- triggerImport: async () => {
- triggered.push(name)
- },
- }
- }
- // release is a movie (category 2000) — only Radarr should be scanned, not Sonarr.
- const radarr = dest('Radarr', [2000])
- const sonarr = dest('Sonarr', [5000])
- const service = new DownloadsService(downloadsConfig(), [fakePeer() as any], [radarr, sonarr] as any, repository)
- const filePath = await writeTorrent()
+ const peer = fakePeer({ getRelease: async () => ({ ...release, filename: '../../evil.mkv' }) })
+ const service = new DownloadsService(downloadsConfig(), [peer as any], repository)
- await service.processTorrentFile(filePath, 'movie.torrent')
+ const result = await service.startQbDownload({
+ peerId: 'peer-1',
+ itemId: 'movie:1',
+ qbCategory: 'jack-x',
+ qbSourceServer: 'My Radarr',
+ })
- expect(triggered).toEqual(['Radarr'])
- expect(repository.list()[0]?.status).toBe('import_queued')
+ expect(result).toBe('failed')
+ expect(repository.list()).toHaveLength(0)
handle.close()
})
})
diff --git a/apps/backend/src/__tests__/integration.test.ts b/apps/backend/src/__tests__/integration.test.ts
index 24b4ccd..8567ac8 100644
--- a/apps/backend/src/__tests__/integration.test.ts
+++ b/apps/backend/src/__tests__/integration.test.ts
@@ -10,10 +10,12 @@ import { runMigrations } from '../database/connection'
import * as schema from '../database/schema'
import { AppConfig } from '../lib/config'
import { RadarrServerConnector } from '../lib/servers/arr/radarr'
+import { SonarrServerConnector } from '../lib/servers/arr/sonarr'
import { PeerConnector } from '../lib/servers/peer'
import { DownloadsRepository } from '../modules/downloads/downloads.repository'
const RADARR_URL = 'http://radarr.test:7878'
+const SONARR_URL = 'http://sonarr.test:8989'
const PEER_JACK_URL = 'http://peer-jack.test:3000'
const HEX_KEY = 'a'.repeat(32)
@@ -73,6 +75,11 @@ const handlers = [
http.post(`${RADARR_URL}/api/v3/command`, () => HttpResponse.json({ id: 1 })),
http.get(`${RADARR_URL}/api/v3/health`, () => HttpResponse.json([])),
+ // ---- Local Sonarr (destination) — for the tvCategory download-client test ----
+ http.get(`${SONARR_URL}/api/v3/system/status`, () => HttpResponse.json({ appName: 'Sonarr', version: '4.0.0' })),
+ http.get(`${SONARR_URL}/api/v3/downloadclient`, () => HttpResponse.json([])),
+ http.post(`${SONARR_URL}/api/v3/downloadclient`, () => HttpResponse.json({ id: 1, name: 'Jack' })),
+
// ---- Peer jack ----
http.get(`${PEER_JACK_URL}/peer/search`, ({ request }) => {
const url = new URL(request.url)
@@ -103,7 +110,7 @@ afterAll(() => server.close())
const config = AppConfig.parse({
jack: { baseUrl: 'http://localhost:3000', apiKey: 'test-api-key' },
- downloads: { watchPath: '/tmp/jack-test-watch', completedPath: '/tmp/jack-test-completed' },
+ downloads: { completedPath: '/tmp/jack-test-completed' },
servers: [],
peers: [],
})
@@ -137,6 +144,17 @@ function makeRadarr(overrides?: { source?: boolean, destination?: boolean }) {
})
}
+function makeSonarr(overrides?: { source?: boolean, destination?: boolean }) {
+ return new SonarrServerConnector({
+ url: SONARR_URL,
+ apiKey: HEX_KEY,
+ name: 'My Sonarr',
+ source: overrides?.source ?? true,
+ destination: overrides?.destination ?? true,
+ autoregister: AUTOREGISTER,
+ })
+}
+
function createTestApp() {
const radarr = markInitialized(makeRadarr())
const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
@@ -389,59 +407,117 @@ describe('Auto-registration', () => {
})).rejects.toThrow()
})
- test('registerDownloadClient registers a Torrent Blackhole client', async () => {
+ test('registerDownloadClient registers a qBittorrent client (Radarr → movieCategory)', async () => {
let createdBody: any = null
server.use(
http.post(`${RADARR_URL}/api/v3/downloadclient`, async ({ request }) => {
createdBody = await request.json()
- return HttpResponse.json({ id: 1, name: 'Jack' })
+ return HttpResponse.json({ id: 7, name: 'Jack' })
}),
)
const radarr = markInitialized(makeRadarr())
const id = await radarr.registerDownloadClient({
name: 'Jack',
- watchPath: '/data/torrents/watch',
- completedPath: '/data/torrents/completed',
- priority: 1,
+ baseUrl: 'http://jack:5225',
+ username: 'My Radarr',
+ password: 'secret',
+ category: 'jack-abc',
})
- expect(id).toBe(1)
+ expect(id).toBe(7)
expect(createdBody).toMatchObject({
name: 'Jack',
enable: true,
protocol: 'torrent',
- implementation: 'TorrentBlackhole',
- configContract: 'TorrentBlackholeSettings',
+ implementation: 'QBittorrent',
+ configContract: 'QBittorrentSettings',
+ priority: 50, // lowest priority so the general client pool never picks Jack
+ tags: [],
})
- expect(createdBody.fields).toContainEqual({ name: 'torrentFolder', value: '/data/torrents/watch' })
- expect(createdBody.fields).toContainEqual({ name: 'watchFolder', value: '/data/torrents/completed' })
+ expect(createdBody.fields).toContainEqual({ name: 'host', value: 'jack' })
+ expect(createdBody.fields).toContainEqual({ name: 'port', value: 5225 })
+ expect(createdBody.fields).toContainEqual({ name: 'useSsl', value: false })
+ expect(createdBody.fields).toContainEqual({ name: 'username', value: 'My Radarr' })
+ expect(createdBody.fields).toContainEqual({ name: 'password', value: 'secret' })
+ expect(createdBody.fields).toContainEqual({ name: 'movieCategory', value: 'jack-abc' })
})
- test('registerDownloadClient updates an existing Jack client instead of duplicating', async () => {
- let putCalled = false
+ test('registerDownloadClient uses tvCategory for Sonarr', async () => {
+ let createdBody: any = null
+ server.use(
+ http.post(`${SONARR_URL}/api/v3/downloadclient`, async ({ request }) => {
+ createdBody = await request.json()
+ return HttpResponse.json({ id: 9, name: 'Jack' })
+ }),
+ )
+
+ const sonarr = markInitialized(makeSonarr())
+ const id = await sonarr.registerDownloadClient({
+ name: 'Jack',
+ baseUrl: 'http://jack:5225',
+ username: 'My Sonarr',
+ password: 'secret',
+ category: 'jack-def',
+ })
+
+ expect(id).toBe(9)
+ expect(createdBody.fields).toContainEqual({ name: 'tvCategory', value: 'jack-def' })
+ expect(createdBody.fields).not.toContainEqual({ name: 'movieCategory', value: 'jack-def' })
+ })
+
+ test('registerDownloadClient upgrades an existing blackhole "Jack" client in place (PUT, no duplicate)', async () => {
+ let putBody: any = null
server.use(
http.get(`${RADARR_URL}/api/v3/downloadclient`, () => {
return HttpResponse.json([
- { id: 7, name: 'Jack', fields: [{ name: 'torrentFolder', value: '/data/torrents/watch' }] },
+ { id: 3, name: 'Jack', implementation: 'TorrentBlackhole', fields: [] },
])
}),
- http.put(`${RADARR_URL}/api/v3/downloadclient/7`, () => {
- putCalled = true
- return HttpResponse.json({ id: 7, name: 'Jack' })
+ http.put(`${RADARR_URL}/api/v3/downloadclient/3`, async ({ request }) => {
+ putBody = await request.json()
+ return HttpResponse.json({ id: 3, name: 'Jack' })
}),
)
const radarr = markInitialized(makeRadarr())
const id = await radarr.registerDownloadClient({
name: 'Jack',
- watchPath: '/data/torrents/watch',
- completedPath: '/data/torrents/completed',
- priority: 1,
+ baseUrl: 'http://jack:5225',
+ username: 'My Radarr',
+ password: 'secret',
+ category: 'jack-abc',
})
- expect(putCalled).toBe(true)
- expect(id).toBe(7)
+ expect(id).toBe(3)
+ expect(putBody.implementation).toBe('QBittorrent')
+ expect(putBody.id).toBe(3)
+ })
+
+ test('registerDownloadClient registers at lowest priority and clears tags (so the general pool never picks Jack)', async () => {
+ let putBody: any = null
+ server.use(
+ // A prior version had tagged this client; the upgrade PUT must clear it.
+ http.get(`${RADARR_URL}/api/v3/downloadclient`, () =>
+ HttpResponse.json([{ id: 5, name: 'Jack', implementation: 'QBittorrent', priority: 1, tags: [1] }])),
+ http.put(`${RADARR_URL}/api/v3/downloadclient/5`, async ({ request }) => {
+ putBody = await request.json()
+ return HttpResponse.json({ id: 5, name: 'Jack' })
+ }),
+ )
+
+ const radarr = markInitialized(makeRadarr())
+ const id = await radarr.registerDownloadClient({
+ name: 'Jack',
+ baseUrl: 'http://jack:5225',
+ username: 'My Radarr',
+ password: 'secret',
+ category: 'jack-abc',
+ })
+
+ expect(id).toBe(5)
+ expect(putBody.priority).toBe(50)
+ expect(putBody.tags).toEqual([])
})
})
diff --git a/apps/backend/src/__tests__/peer-download.test.ts b/apps/backend/src/__tests__/peer-download.test.ts
index 487e1b8..2e71304 100644
--- a/apps/backend/src/__tests__/peer-download.test.ts
+++ b/apps/backend/src/__tests__/peer-download.test.ts
@@ -5,6 +5,8 @@ import { join } from 'node:path'
import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
+import { IdleTimeoutError } from '../lib/errors/IdleTimeoutError'
+import { UnknownSizeError } from '../lib/errors/UnknownSizeError'
import { PeerConnector } from '../lib/servers/peer'
const PEER_JACK_URL = 'http://download-peer.test:3000'
@@ -134,7 +136,7 @@ describe('PeerConnector.downloadFile', () => {
}
})
- test('reports indeterminate expected bytes when Content-Length is missing or invalid', async () => {
+ test('falls back to releaseSize for expected bytes when Content-Length is missing or invalid', async () => {
for (const contentLength of [null, 'not-a-number']) {
server.resetHandlers()
server.use(
@@ -145,16 +147,17 @@ describe('PeerConnector.downloadFile', () => {
)
const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
- const dir = await mkdtemp(join(tmpdir(), 'jack-peer-indeterminate-'))
+ const dir = await mkdtemp(join(tmpdir(), 'jack-peer-fallback-'))
const events: unknown[] = []
try {
await peer.downloadFile('remote1:movie:99', join(dir, 'Movie.mkv'), {
+ releaseSize: 2,
onProgress: (event) => { events.push(event) },
})
- expect(events).toContainEqual({ type: 'headers', expectedBytes: null, expectedBytesSource: null, expectedBytesMismatch: false })
- expect(events).toContainEqual({ type: 'completed', downloadedBytes: 2, expectedBytes: null })
+ expect(events).toContainEqual({ type: 'headers', expectedBytes: 2, expectedBytesSource: 'release_size', expectedBytesMismatch: false })
+ expect(events).toContainEqual({ type: 'completed', downloadedBytes: 2, expectedBytes: 2 })
}
finally {
await rm(dir, { recursive: true, force: true })
@@ -162,6 +165,29 @@ describe('PeerConnector.downloadFile', () => {
}
})
+ test('fails fast with UnknownSizeError when neither Content-Length nor releaseSize is known', async () => {
+ server.use(
+ http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () => {
+ return new Response(streamOf([1, 2]), { headers: {} })
+ }),
+ )
+
+ const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
+ const dir = await mkdtemp(join(tmpdir(), 'jack-peer-nosize-'))
+ const destPath = join(dir, 'Movie.mkv')
+ const partPath = `${destPath}.part`
+
+ try {
+ await expect(peer.downloadFile('remote1:movie:99', destPath, { partPath })).rejects.toThrow(UnknownSizeError)
+ // Fail-fast: nothing is written before the size is known.
+ expect(await Bun.file(destPath).exists()).toBe(false)
+ expect(await Bun.file(partPath).exists()).toBe(false)
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
test('reports Content-Length mismatches against releaseSize', async () => {
server.use(
http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () => {
@@ -215,22 +241,21 @@ describe('PeerConnector.downloadFile', () => {
})
test('does not leave the response body locked when opening the .part file fails', async () => {
- let body: ReadableStream | null = null
- const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(async () => {
+ let body: Response['body'] = null
+ const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async () => {
const response = new Response(streamOf([1, 2, 3]), { headers: { 'Content-Length': '3' } })
body = response.body
return response
- },
- )
+ }) as unknown as typeof fetch)
const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
const dir = await mkdtemp(join(tmpdir(), 'jack-peer-open-fails-'))
const destPath = join(dir, 'missing-parent', 'Movie.mkv')
try {
- await expect(peer.downloadFile('remote1:movie:99', destPath, { partPath: `${destPath}.part`, releaseSize: 3 })).rejects.toThrow()
+ expect(peer.downloadFile('remote1:movie:99', destPath, { partPath: `${destPath}.part`, releaseSize: 3 })).rejects.toThrow()
expect(body).not.toBeNull()
- expect(body?.locked).toBe(false)
+ expect(body!.locked).toBe(false)
}
finally {
fetchSpy.mockRestore()
@@ -243,9 +268,9 @@ describe('PeerConnector.downloadFile', () => {
if (before == null)
return
- const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(async () => {
+ const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async () => {
return new Response(streamOf([1, 2, 3]), { headers: { 'Content-Length': '3' } })
- })
+ }) as unknown as typeof fetch)
const getReaderSpy = spyOn(ReadableStream.prototype, 'getReader').mockImplementation(() => {
throw new Error('reader failed')
})
@@ -264,6 +289,69 @@ describe('PeerConnector.downloadFile', () => {
await rm(dir, { recursive: true, force: true })
}
})
+
+ test('aborts with IdleTimeoutError when the peer stops sending bytes', async () => {
+ // Use a fetch spy so the body stream reliably errors when the connector's
+ // idle abort fires (MSW's mock doesn't propagate the fetch signal mid-body).
+ // Real fetch rejects an in-flight read on abort, which this mirrors.
+ const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async (_url, init?: RequestInit) => {
+ const signal = init?.signal
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array([1, 2]))
+ // No further chunks → the transfer stalls. Error the stream when the
+ // connector aborts (its idle timer), like real fetch would.
+ signal?.addEventListener('abort', () => controller.error(new DOMException('aborted', 'AbortError')))
+ },
+ })
+ return new Response(body, { headers: { 'Content-Length': '5' } })
+ }) as typeof fetch)
+
+ const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
+ const dir = await mkdtemp(join(tmpdir(), 'jack-peer-stall-'))
+ const destPath = join(dir, 'Movie.mkv')
+ const partPath = `${destPath}.part`
+
+ try {
+ await expect(peer.downloadFile('remote1:movie:99', destPath, { partPath, releaseSize: 5, idleTimeoutMs: 50 }))
+ .rejects
+ .toThrow(IdleTimeoutError)
+ expect(await Bun.file(destPath).exists()).toBe(false)
+ expect(await Bun.file(partPath).exists()).toBe(true) // preserved for resume
+ }
+ finally {
+ fetchSpy.mockRestore()
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ test('does not abort a slow but active download (chunks within the idle window)', async () => {
+ server.use(
+ http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () => {
+ return new Response(new ReadableStream({
+ async start(controller) {
+ for (const b of [1, 2, 3, 4]) {
+ await Bun.sleep(20)
+ controller.enqueue(new Uint8Array([b]))
+ }
+ controller.close()
+ },
+ }), { headers: { 'Content-Length': '4' } })
+ }),
+ )
+
+ const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
+ const dir = await mkdtemp(join(tmpdir(), 'jack-peer-slow-'))
+ const destPath = join(dir, 'Movie.mkv')
+
+ try {
+ await peer.downloadFile('remote1:movie:99', destPath, { partPath: `${destPath}.part`, releaseSize: 4, idleTimeoutMs: 200 })
+ expect(new Uint8Array(await Bun.file(destPath).arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
})
describe('PeerConnector.downloadFile resume', () => {
@@ -295,6 +383,8 @@ describe('PeerConnector.downloadFile resume', () => {
expect(seen.range).toBe('bytes=2-')
expect(new Uint8Array(await Bun.file(destPath).arrayBuffer())).toEqual(new Uint8Array([0, 1, 2, 3, 4]))
expect(events.some(e => e.type === 'restart')).toBe(false)
+ // On a 206 resume the size comes from Content-Range, not Content-Length.
+ expect(events).toContainEqual({ type: 'headers', expectedBytes: 5, expectedBytesSource: 'content_range', expectedBytesMismatch: false })
expect(events).toContainEqual({ type: 'completed', downloadedBytes: 5, expectedBytes: 5 })
}
finally {
@@ -360,7 +450,9 @@ describe('PeerConnector.downloadFile resume', () => {
}
})
- test('restarts when the peer returns 416 for the resume range', async () => {
+ test('restarts when the peer returns 416 for the resume range (releaseSize unknown)', async () => {
+ // releaseSize omitted so the pre-fetch oversize guard is skipped and the
+ // Range is actually sent — exercising the 416 restart path.
server.use(
http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, ({ request }) => {
if (request.headers.get('Range'))
@@ -378,7 +470,6 @@ describe('PeerConnector.downloadFile resume', () => {
try {
await peer.downloadFile('remote1:movie:99', destPath, {
partPath,
- releaseSize: 5,
onProgress: (e) => { events.push(e) },
})
@@ -390,6 +481,71 @@ describe('PeerConnector.downloadFile resume', () => {
}
})
+ test('discards and restarts when the .part is larger than releaseSize', async () => {
+ let rangeSent = false
+ server.use(
+ http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, ({ request }) => {
+ if (request.headers.get('Range'))
+ rangeSent = true
+ return new Response(streamOf([0, 1, 2, 3, 4]), { headers: { 'Content-Length': '5' } })
+ }),
+ )
+ const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
+ const dir = await mkdtemp(join(tmpdir(), 'jack-resume-oversize-'))
+ const destPath = join(dir, 'Movie.mkv')
+ const partPath = `${destPath}.part`
+ await writeFile(partPath, new Uint8Array([0, 1, 2, 3, 4, 5, 6])) // 7 > releaseSize 5
+ const events: PeerDownloadProgressEvent[] = []
+
+ try {
+ await peer.downloadFile('remote1:movie:99', destPath, {
+ partPath,
+ releaseSize: 5,
+ onProgress: (e) => { events.push(e) },
+ })
+
+ expect(rangeSent).toBe(false) // discarded before requesting; fresh download
+ expect(events.some(e => e.type === 'restart' && e.reason === 'part_oversize')).toBe(true)
+ expect(new Uint8Array(await Bun.file(destPath).arrayBuffer())).toEqual(new Uint8Array([0, 1, 2, 3, 4]))
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ test('finalizes without re-downloading when the .part already equals releaseSize', async () => {
+ let fetched = false
+ server.use(
+ http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () => {
+ fetched = true
+ return new Response(streamOf([0, 1, 2]), { headers: { 'Content-Length': '3' } })
+ }),
+ )
+ const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' }))
+ const dir = await mkdtemp(join(tmpdir(), 'jack-resume-exact-'))
+ const destPath = join(dir, 'Movie.mkv')
+ const partPath = `${destPath}.part`
+ await writeFile(partPath, new Uint8Array([7, 8, 9])) // 3 === releaseSize 3
+ const events: PeerDownloadProgressEvent[] = []
+
+ try {
+ await peer.downloadFile('remote1:movie:99', destPath, {
+ partPath,
+ releaseSize: 3,
+ onProgress: (e) => { events.push(e) },
+ })
+
+ expect(fetched).toBe(false) // no HTTP request at all
+ expect(await Bun.file(partPath).exists()).toBe(false)
+ expect(new Uint8Array(await Bun.file(destPath).arrayBuffer())).toEqual(new Uint8Array([7, 8, 9]))
+ expect(events).toContainEqual({ type: 'headers', expectedBytes: 3, expectedBytesSource: 'release_size', expectedBytesMismatch: false })
+ expect(events).toContainEqual({ type: 'completed', downloadedBytes: 3, expectedBytes: 3 })
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
test('rejects non-ok resume responses without appending the response body', async () => {
server.use(
http.get(`${PEER_JACK_URL}/peer/items/:itemId/file`, () =>
diff --git a/apps/backend/src/__tests__/peer-range-serving.test.ts b/apps/backend/src/__tests__/peer-range-serving.test.ts
index adf2b91..77d009f 100644
--- a/apps/backend/src/__tests__/peer-range-serving.test.ts
+++ b/apps/backend/src/__tests__/peer-range-serving.test.ts
@@ -46,17 +46,10 @@ function controllerForFile() {
return new PeerController([source as any])
}
-async function streamBytes(stream: ReadableStream): Promise {
- const reader = stream.getReader()
- const chunks: number[] = []
- while (true) {
- const { done, value } = await reader.read()
- if (done)
- break
- if (value)
- chunks.push(...value)
- }
- return chunks
+// `body` is now a BunFile/Blob (served via Bun's native backpressure) rather than a
+// hand-pumped ReadableStream, so read its bytes directly.
+async function bodyBytes(body: Blob): Promise {
+ return [...new Uint8Array(await body.arrayBuffer())]
}
describe('PeerController.streamFile range handling', () => {
@@ -66,14 +59,14 @@ describe('PeerController.streamFile range handling', () => {
if (result?.type !== 'partial')
throw new Error('expected partial')
expect({ start: result.start, end: result.end, size: result.size, totalSize: result.totalSize }).toEqual({ start: 2, end: 4, size: 3, totalSize: 10 })
- expect(await streamBytes(result.stream)).toEqual([2, 3, 4])
+ expect(await bodyBytes(result.body)).toEqual([2, 3, 4])
})
test('returns the last N bytes for a suffix range', async () => {
const result = await controllerForFile().streamFile('remote1:movie:1', 'bytes=-3')
if (result?.type !== 'partial')
throw new Error('expected partial')
- expect(await streamBytes(result.stream)).toEqual([7, 8, 9])
+ expect(await bodyBytes(result.body)).toEqual([7, 8, 9])
})
test('clamps an open-ended range to the file end', async () => {
@@ -81,7 +74,7 @@ describe('PeerController.streamFile range handling', () => {
if (result?.type !== 'partial')
throw new Error('expected partial')
expect(result.end).toBe(9)
- expect(await streamBytes(result.stream)).toEqual([8, 9])
+ expect(await bodyBytes(result.body)).toEqual([8, 9])
})
test('reports unsatisfiable when start is beyond the file', async () => {
@@ -94,7 +87,7 @@ describe('PeerController.streamFile range handling', () => {
if (result?.type !== 'full')
throw new Error('expected full')
expect(result.size).toBe(10)
- expect(await streamBytes(result.stream)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
+ expect(await bodyBytes(result.body)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
})
test('returns null when the source is unknown', async () => {
diff --git a/apps/backend/src/__tests__/qbittorrent-api.test.ts b/apps/backend/src/__tests__/qbittorrent-api.test.ts
new file mode 100644
index 0000000..5b9b312
--- /dev/null
+++ b/apps/backend/src/__tests__/qbittorrent-api.test.ts
@@ -0,0 +1,288 @@
+import { Database } from 'bun:sqlite'
+import { beforeEach, describe, expect, test } from 'bun:test'
+import { drizzle } from 'drizzle-orm/bun-sqlite'
+import { getApp } from '../app'
+import { runMigrations } from '../database/connection'
+import * as schema from '../database/schema'
+import { AppConfig } from '../lib/config'
+import { DownloadsRepository } from '../modules/downloads/downloads.repository'
+import { deriveHash, qbCategoryForServer } from '../modules/qbittorrent/qbittorrent.mapper'
+import { createTorrentStub } from '../modules/torznab/torrent'
+
+const envs = { ENVIRONMENT: 'test', ENABLE_LOGS: false, LOG_LEVEL: 'fatal' } as any
+
+const fakeServer = { id: 'abc12345', name: 'My Radarr', type: 'radarr', categories: [2000] } as any
+
+function buildApp() {
+ const sqlite = new Database(':memory:')
+ const db = drizzle({ client: sqlite, schema })
+ runMigrations(db)
+ const repository = new DownloadsRepository(db)
+ const config = AppConfig.parse({
+ jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' },
+ downloads: { completedPath: '/tmp/completed' },
+ servers: [],
+ peers: [],
+ })
+ const app = getApp(envs, config, { servers: [fakeServer], peers: [] }, { downloadsRepository: repository })
+ return { app, repository }
+}
+
+function buildAppWithService(startResult: 'started' | 'duplicate' | 'failed' = 'started') {
+ const sqlite = new Database(':memory:')
+ const db = drizzle({ client: sqlite, schema })
+ runMigrations(db)
+ const repository = new DownloadsRepository(db)
+ const config = AppConfig.parse({
+ jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' },
+ downloads: { completedPath: '/tmp/completed' },
+ servers: [],
+ peers: [],
+ })
+ const calls: any[] = []
+ const downloadsService = {
+ startQbDownload: async (input: any) => {
+ calls.push(input)
+ return startResult
+ },
+ } as any
+ const app = getApp(envs, config, { servers: [fakeServer], peers: [] }, { downloadsRepository: repository, downloadsService })
+ return { app, repository, calls }
+}
+
+function seedDownload(repository: DownloadsRepository, category: string) {
+ return repository.create({
+ torrentFilename: 'movie.torrent',
+ peerId: 'peer0001',
+ peerName: 'Peer',
+ itemId: 'conn:movie:42',
+ filename: 'Big Buck Bunny (2008).mkv',
+ destPath: '/tmp/completed/Big Buck Bunny (2008).mkv',
+ partPath: '/tmp/completed/Big Buck Bunny (2008).mkv.part',
+ releaseSize: 10,
+ release: { id: 'conn:movie:42', title: 'Big Buck Bunny', filename: 'Big Buck Bunny (2008).mkv', category: 2000, size: 10 } as any,
+ qbCategory: category,
+ qbSourceServer: 'My Radarr',
+ })
+}
+
+async function loginCookie(app: ReturnType['app']): Promise {
+ const res = await app.request('/api/v2/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ username: 'My Radarr', password: 'test-api-key' }),
+ })
+ const setCookie = res.headers.get('set-cookie') ?? ''
+ return setCookie.split(';')[0] ?? '' // "SID=..."
+}
+
+describe('qBittorrent auth + app surface', () => {
+ let app: ReturnType['app']
+ beforeEach(() => {
+ app = buildApp().app
+ })
+
+ test('login succeeds for a known server name + correct password', async () => {
+ const res = await app.request('/api/v2/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ username: 'My Radarr', password: 'test-api-key' }),
+ })
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('Ok.')
+ expect(res.headers.get('set-cookie') ?? '').toContain('SID=')
+ })
+
+ test('login fails for unknown username', async () => {
+ const res = await app.request('/api/v2/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ username: 'Nope', password: 'test-api-key' }),
+ })
+ expect(await res.text()).toBe('Fails.')
+ })
+
+ test('protected endpoint returns 403 without session', async () => {
+ const res = await app.request('/api/v2/app/webapiVersion')
+ expect(res.status).toBe(403)
+ })
+
+ test('app endpoints return contract values with a session', async () => {
+ const cookie = await loginCookie(app)
+ const version = await app.request('/api/v2/app/webapiVersion', { headers: { cookie } })
+ expect(await version.text()).toBe('2.9.2')
+
+ const prefs = await app.request('/api/v2/app/preferences', { headers: { cookie } })
+ const body = await prefs.json() as any
+ expect(body.save_path).toBe('/tmp/completed')
+ expect(body.max_ratio_enabled).toBe(false)
+ })
+
+ test('categories include jack-', async () => {
+ const cookie = await loginCookie(app)
+ const res = await app.request('/api/v2/torrents/categories', { headers: { cookie } })
+ const body = await res.json() as Record
+ expect(Object.keys(body)).toContain('jack-abc12345')
+ })
+})
+
+describe('qBittorrent torrent mapping', () => {
+ let app: ReturnType['app']
+ let repository: ReturnType['repository']
+ beforeEach(() => {
+ const built = buildApp()
+ app = built.app
+ repository = built.repository
+ })
+
+ test('info, properties, and files reflect a seeded import_queued download', async () => {
+ const category = qbCategoryForServer('abc12345')
+ const created = seedDownload(repository, category)
+ repository.markImportQueued(created.id)
+ const hash = deriveHash('Big Buck Bunny', 10)
+ const cookie = await loginCookie(app)
+
+ const infoRes = await app.request(`/api/v2/torrents/info?category=${encodeURIComponent(category)}`, { headers: { cookie } })
+ const info = await infoRes.json() as any[]
+ expect(info).toHaveLength(1)
+ expect(info[0].hash).toBe(hash)
+ expect(info[0].state).toBe('pausedUP')
+ expect(info[0].progress).toBe(1)
+
+ const propsRes = await app.request(`/api/v2/torrents/properties?hash=${hash}`, { headers: { cookie } })
+ expect(propsRes.status).toBe(200)
+ const props = await propsRes.json() as { save_path: string }
+ expect(props.save_path).toBe('/tmp/completed')
+
+ const missingRes = await app.request('/api/v2/torrents/properties?hash=deadbeef', { headers: { cookie } })
+ expect(missingRes.status).toBe(404)
+
+ const filesRes = await app.request(`/api/v2/torrents/files?hash=${hash}`, { headers: { cookie } })
+ const files = await filesRes.json() as { name: string }[]
+ expect(files[0]?.name).toBe('Big Buck Bunny (2008).mkv')
+ })
+})
+
+describe('qBittorrent add/delete/setCategory', () => {
+ test('add with a valid jack stub file starts a qB download with the session category', async () => {
+ const { app, calls } = buildAppWithService()
+ const cookie = await loginCookie(app)
+ const stub = createTorrentStub({ name: 'Big Buck Bunny', size: 10, peerId: 'peer0001', itemId: 'conn:movie:42' })
+ const form = new FormData()
+ form.append('torrents', new File([stub], 'x.torrent'))
+
+ const res = await app.request('/api/v2/torrents/add', { method: 'POST', headers: { cookie }, body: form })
+
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('Ok.')
+ expect(calls).toHaveLength(1)
+ expect(calls[0].peerId).toBe('peer0001')
+ expect(calls[0].itemId).toBe('conn:movie:42')
+ expect(calls[0].qbCategory).toBe('jack-abc12345')
+ expect(calls[0].qbSourceServer).toBe('My Radarr')
+ })
+
+ test('add returns 503 when startQbDownload fails so *arr retries promptly', async () => {
+ const { app, calls } = buildAppWithService('failed')
+ const cookie = await loginCookie(app)
+ const stub = createTorrentStub({ name: 'Big Buck Bunny', size: 10, peerId: 'peer0001', itemId: 'conn:movie:42' })
+ const form = new FormData()
+ form.append('torrents', new File([stub], 'x.torrent'))
+
+ const res = await app.request('/api/v2/torrents/add', { method: 'POST', headers: { cookie }, body: form })
+
+ expect(res.status).toBe(503)
+ expect(calls).toHaveLength(1)
+ })
+
+ test('add with a magnet url returns 415 and starts nothing', async () => {
+ const { app, calls } = buildAppWithService()
+ const cookie = await loginCookie(app)
+ const form = new FormData()
+ form.append('urls', 'magnet:?xt=urn:btih:deadbeef')
+
+ const res = await app.request('/api/v2/torrents/add', { method: 'POST', headers: { cookie }, body: form })
+
+ expect(res.status).toBe(415)
+ expect(calls).toHaveLength(0)
+ })
+
+ test('add with non-jack torrent bytes returns 415', async () => {
+ const { app, calls } = buildAppWithService()
+ const cookie = await loginCookie(app)
+ const form = new FormData()
+ form.append('torrents', new File([new Uint8Array([1, 2, 3])], 'x.torrent'))
+
+ const res = await app.request('/api/v2/torrents/add', { method: 'POST', headers: { cookie }, body: form })
+
+ expect(res.status).toBe(415)
+ expect(calls).toHaveLength(0)
+ })
+
+ test('add returns 503 when the download pipeline is unavailable (no downloadsService)', async () => {
+ const { app } = buildApp() // built without a downloadsService
+ const cookie = await loginCookie(app)
+ const stub = createTorrentStub({ name: 'Big Buck Bunny', size: 10, peerId: 'peer0001', itemId: 'conn:movie:42' })
+ const form = new FormData()
+ form.append('torrents', new File([stub], 'x.torrent'))
+
+ const res = await app.request('/api/v2/torrents/add', { method: 'POST', headers: { cookie }, body: form })
+
+ expect(res.status).toBe(503)
+ })
+
+ test('delete removes a session-owned row by hash', async () => {
+ const { app, repository } = buildAppWithService()
+ const category = qbCategoryForServer('abc12345')
+ const created = seedDownload(repository, category)
+ const hash = deriveHash('Big Buck Bunny', 10)
+ const cookie = await loginCookie(app)
+
+ const res = await app.request('/api/v2/torrents/delete', {
+ method: 'POST',
+ headers: { 'cookie': cookie, 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ hashes: hash }),
+ })
+
+ expect(res.status).toBe(200)
+ expect(repository.get(created.id)).toBeNull()
+ })
+
+ test.each(['setShareLimits', 'topPrio', 'setForceStart'])('best-effort no-op %s returns Ok. with a session', async (route) => {
+ const { app } = buildAppWithService()
+ const cookie = await loginCookie(app)
+
+ const res = await app.request(`/api/v2/torrents/${route}`, {
+ method: 'POST',
+ headers: { 'cookie': cookie, 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ hashes: 'all' }),
+ })
+
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('Ok.')
+ })
+
+ test.each(['setShareLimits', 'topPrio', 'setForceStart'])('best-effort no-op %s requires a session (403)', async (route) => {
+ const { app } = buildAppWithService()
+
+ const res = await app.request(`/api/v2/torrents/${route}`, { method: 'POST' })
+
+ expect(res.status).toBe(403)
+ })
+
+ test('setCategory updates a session-owned row', async () => {
+ const { app, repository } = buildAppWithService()
+ const created = seedDownload(repository, qbCategoryForServer('abc12345'))
+ const hash = deriveHash('Big Buck Bunny', 10)
+ const cookie = await loginCookie(app)
+
+ const res = await app.request('/api/v2/torrents/setCategory', {
+ method: 'POST',
+ headers: { 'cookie': cookie, 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ hashes: hash, category: 'renamed' }),
+ })
+
+ expect(res.status).toBe(200)
+ expect(repository.get(created.id)?.qbCategory).toBe('renamed')
+ })
+})
diff --git a/apps/backend/src/__tests__/qbittorrent-mapper.test.ts b/apps/backend/src/__tests__/qbittorrent-mapper.test.ts
new file mode 100644
index 0000000..1744d01
--- /dev/null
+++ b/apps/backend/src/__tests__/qbittorrent-mapper.test.ts
@@ -0,0 +1,65 @@
+import type { DownloadRecord } from '../modules/downloads/downloads.repository'
+import { describe, expect, test } from 'bun:test'
+import { deriveHash, toQbTorrent } from '../modules/qbittorrent/qbittorrent.mapper'
+
+function baseRecord(overrides: Partial = {}): DownloadRecord {
+ return {
+ id: 1,
+ torrentFilename: 'movie.torrent',
+ peerId: 'peer0001',
+ peerName: 'Peer',
+ itemId: 'conn:movie:42',
+ filename: 'Big Buck Bunny (2008).mkv',
+ destPath: '/tmp/completed/Big Buck Bunny (2008).mkv',
+ partPath: '/tmp/completed/Big Buck Bunny (2008).mkv.part',
+ releaseSize: 10,
+ release: { id: 'conn:movie:42', title: 'Big Buck Bunny', filename: 'Big Buck Bunny (2008).mkv', category: 2000, size: 10 } as any,
+ expectedBytes: null,
+ expectedBytesSource: null,
+ expectedBytesMismatch: false,
+ downloadedBytes: 0,
+ attempts: 0,
+ status: 'downloading',
+ startedAt: '2026-06-06T00:00:00.000Z',
+ updatedAt: '2026-06-06T00:00:00.000Z',
+ completedAt: null,
+ error: null,
+ qbCategory: 'jack-abc12345',
+ qbSourceServer: 'My Radarr',
+ ...overrides,
+ }
+}
+
+describe('toQbTorrent', () => {
+ test('downloading: progress, eta, amount_left from bytes', () => {
+ const record = baseRecord({ status: 'downloading', downloadedBytes: 4, expectedBytes: 10 })
+ const torrent = toQbTorrent(record, { completedPath: '/tmp/completed', category: 'jack-abc12345' })
+ expect(torrent.state).toBe('downloading')
+ expect(torrent.progress).toBe(0.4)
+ expect(torrent.eta).toBe(8_640_000)
+ expect(torrent.amount_left).toBe(6)
+ })
+
+ test('import_queued: finished torrent uses pausedUP, full progress, content_path != save_path', () => {
+ const record = baseRecord({ status: 'import_queued', downloadedBytes: 10 })
+ const torrent = toQbTorrent(record, { completedPath: '/tmp/completed', category: 'jack-abc12345' })
+ expect(torrent.state).toBe('pausedUP')
+ expect(torrent.progress).toBe(1)
+ expect(torrent.amount_left).toBe(0)
+ expect(torrent.eta).toBe(0)
+ expect(torrent.content_path).toBe(record.destPath)
+ expect(torrent.content_path).not.toBe(torrent.save_path)
+ })
+
+ test('hash is the stub infohash derived from release title + size', () => {
+ const record = baseRecord()
+ const torrent = toQbTorrent(record, { completedPath: '/tmp/completed', category: 'jack-abc12345' })
+ expect(torrent.hash).toBe(deriveHash('Big Buck Bunny', 10))
+ })
+
+ test('failed maps to error state', () => {
+ const record = baseRecord({ status: 'failed' })
+ const torrent = toQbTorrent(record, { completedPath: '/tmp/completed', category: 'jack-abc12345' })
+ expect(torrent.state).toBe('error')
+ })
+})
diff --git a/apps/backend/src/__tests__/retry-policy.test.ts b/apps/backend/src/__tests__/retry-policy.test.ts
new file mode 100644
index 0000000..949c2fb
--- /dev/null
+++ b/apps/backend/src/__tests__/retry-policy.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from 'bun:test'
+import { IdleTimeoutError } from '../lib/errors/IdleTimeoutError'
+import { IncompleteDownloadError } from '../lib/errors/IncompleteDownloadError'
+import { isTransientDownloadError } from '../modules/downloads/retry-policy'
+
+describe('isTransientDownloadError', () => {
+ test('IdleTimeoutError is transient (retryable)', () => {
+ expect(isTransientDownloadError(new IdleTimeoutError('stalled'))).toBe(true)
+ })
+
+ test('IncompleteDownloadError is transient (retryable)', () => {
+ expect(isTransientDownloadError(new IncompleteDownloadError('short'))).toBe(true)
+ })
+
+ test('a plain AbortError (manual cancel, not idle) is not transient', () => {
+ expect(isTransientDownloadError(new DOMException('aborted', 'AbortError'))).toBe(false)
+ })
+})
diff --git a/apps/backend/src/__tests__/retry.test.ts b/apps/backend/src/__tests__/retry.test.ts
index ceb4c7f..2e1a978 100644
--- a/apps/backend/src/__tests__/retry.test.ts
+++ b/apps/backend/src/__tests__/retry.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, spyOn, test } from 'bun:test'
+import { DownloadsConfig } from '../lib/config'
import { FetchError } from '../lib/errors/FetchError'
import { IncompleteDownloadError } from '../lib/errors/IncompleteDownloadError'
import { retry } from '../lib/retry'
@@ -104,6 +105,58 @@ describe('retry', () => {
})
})
+describe('download retry backoff schedule', () => {
+ // The defaults must keep retrying long enough to outlast a ~15-30 min peer
+ // outage (the .part stays resumable), so a transient peer restart doesn't
+ // terminally fail the download.
+ const defaults = DownloadsConfig.parse({ watchPath: '/w', completedPath: '/c' })
+
+ // Drive the real retry() with random()=1 (worst-case full-jitter delay) and
+ // a no-op sleep, collecting the delay it would have waited before each retry.
+ async function collectMaxDelays(): Promise {
+ const delays: number[] = []
+ await expect(retry(async () => {
+ throw new Error('peer unreachable')
+ }, {
+ maxAttempts: defaults.maxDownloadAttempts,
+ baseDelayMs: defaults.retryBaseDelayMs,
+ maxDelayMs: defaults.retryMaxDelayMs,
+ isRetryable: () => true,
+ random: () => 1,
+ sleep: async (ms) => {
+ delays.push(ms)
+ },
+ })).rejects.toThrow('peer unreachable')
+ return delays
+ }
+
+ test('retries enough times to bridge a multi-minute outage', async () => {
+ const delays = await collectMaxDelays()
+ // maxAttempts total attempts => maxAttempts - 1 retries (one sleep each).
+ expect(delays.length).toBe(defaults.maxDownloadAttempts - 1)
+ })
+
+ test('worst-case total retry window comfortably exceeds 20 minutes', async () => {
+ const delays = await collectMaxDelays()
+ const totalMs = delays.reduce((sum, ms) => sum + ms, 0)
+ expect(totalMs).toBeGreaterThan(20 * 60 * 1000)
+ })
+
+ test('the delay grows exponentially toward the configured max', async () => {
+ const delays = await collectMaxDelays()
+ // Early retry is small (network-blip friendly).
+ expect(delays[0]).toBe(defaults.retryBaseDelayMs)
+ // Each subsequent max delay doubles until it saturates at the cap.
+ for (let i = 1; i < delays.length; i++)
+ expect(delays[i]!).toBe(Math.min(defaults.retryMaxDelayMs, delays[i - 1]! * 2))
+ // The latest retries reach the long cap (~30 min) so the schedule spans an outage.
+ expect(delays.at(-1)).toBe(defaults.retryMaxDelayMs)
+ // At least one retry waits ~15 min (the largest pre-cap step) before saturating.
+ const maxBelowCap = Math.max(...delays.filter(ms => ms < defaults.retryMaxDelayMs))
+ expect(maxBelowCap).toBeGreaterThanOrEqual(15 * 60 * 1000)
+ })
+})
+
describe('isTransientDownloadError', () => {
function fetchError(status: number, headers?: Record) {
return new FetchError('x', new Response(null, { status, headers }))
diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts
index 40ad9c6..979e209 100644
--- a/apps/backend/src/app.ts
+++ b/apps/backend/src/app.ts
@@ -3,6 +3,7 @@ import type { Envs } from './lib/envs'
import type { ArrServerConnector } from './lib/servers/arr/base'
import type { PeerConnector } from './lib/servers/peer'
import type { DownloadsRepository } from './modules/downloads/downloads.repository'
+import type { DownloadsService } from './modules/downloads/downloads.service'
import { httpInstrumentationMiddleware } from '@hono/otel'
import { Hono } from 'hono'
import { secureHeaders } from 'hono/secure-headers'
@@ -16,6 +17,8 @@ import { ItemsController } from './modules/items/items.controller'
import { getItemsRouter } from './modules/items/items.router'
import { PeerController } from './modules/peer/peer.controller'
import { getPeerRouter } from './modules/peer/peer.router'
+import { QbittorrentController } from './modules/qbittorrent/qbittorrent.controller'
+import { getQbittorrentRouter } from './modules/qbittorrent/qbittorrent.router'
import { ServersController } from './modules/servers/servers.controllers'
import { getServersRouter } from './modules/servers/servers.router'
import { getDownloadRouter } from './modules/torznab/download.router'
@@ -29,6 +32,7 @@ interface Connectors {
interface AppServices {
downloadsRepository?: DownloadsRepository
+ downloadsService?: DownloadsService
}
export function getApp(envs: Envs, config: AppConfig, connectors: Connectors, services: AppServices = {}) {
@@ -62,6 +66,21 @@ export function getApp(envs: Envs, config: AppConfig, connectors: Connectors, se
// Enrich the active request span with HTTP details, then emit a compact
// request-completed log without headers, query params, or bodies.
app.use('*', logRequests)
+
+ // qBittorrent WebUI API -- Radarr/Sonarr poll us as a download client. Mounted
+ // BEFORE requireApiKey because qB uses its own SID-cookie auth
+ // (/api/v2/auth/login), not jack's apikey query/header.
+ if (config.jack && config.downloads && services.downloadsRepository) {
+ const qbController = new QbittorrentController({
+ apiKey: config.jack.apiKey,
+ completedPath: config.downloads.completedPath,
+ servers: connectors.servers,
+ repository: services.downloadsRepository,
+ downloadsService: services.downloadsService,
+ })
+ app.route('/api/v2', getQbittorrentRouter(qbController))
+ }
+
app.use('*', requireApiKey(config.jack?.apiKey ?? ''))
app.route('/servers', serversRouter)
diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts
index a957bbb..62fd745 100644
--- a/apps/backend/src/database/schema.ts
+++ b/apps/backend/src/database/schema.ts
@@ -3,7 +3,7 @@ import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-cor
export const DOWNLOAD_STATUSES = ['downloading', 'completed', 'failed', 'import_queued'] as const
export type DownloadStatus = typeof DOWNLOAD_STATUSES[number]
-export type ExpectedBytesSource = 'content_length'
+export type ExpectedBytesSource = 'content_length' | 'content_range' | 'release_size'
export const downloads = sqliteTable('downloads', {
id: integer('id').primaryKey({ autoIncrement: true }),
@@ -26,9 +26,14 @@ export const downloads = sqliteTable('downloads', {
updatedAt: text('updated_at').notNull(),
completedAt: text('completed_at'),
error: text('error'),
+ // qBittorrent emulation: the category *arr sent on add, and the server
+ // connector that added it. Presence of qbSourceServer marks a qB-added
+ // download (→ *arr-pull import, no jack push). Null for blackhole-added rows.
+ qbCategory: text('qb_category'),
+ qbSourceServer: text('qb_source_server'),
}, t => [
check('downloads_status_check', sql`${t.status} in ('downloading', 'completed', 'failed', 'import_queued')`),
- check('downloads_expected_bytes_source_check', sql`${t.expectedBytesSource} is null or ${t.expectedBytesSource} = 'content_length'`),
+ check('downloads_expected_bytes_source_check', sql`${t.expectedBytesSource} is null or ${t.expectedBytesSource} in ('content_length', 'content_range', 'release_size')`),
index('downloads_status_idx').on(t.status),
index('downloads_updated_at_idx').on(t.updatedAt),
])
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index 3bec002..4d2db14 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -7,9 +7,9 @@ import { getAppEnvs } from './lib/envs'
import { FetchError } from './lib/errors/FetchError'
import { initializeConnectors } from './lib/servers'
import { logger } from './logger'
-import { BlackholeWatcher } from './modules/downloads/blackhole.watcher'
import { DownloadsRepository } from './modules/downloads/downloads.repository'
import { DownloadsService } from './modules/downloads/downloads.service'
+import { qbCategoryForServer } from './modules/qbittorrent/qbittorrent.mapper'
function logRegistrationFailure(what: string, destName: string | undefined, err: unknown) {
if (err instanceof FetchError) {
@@ -32,7 +32,11 @@ const destinations = connectors.servers.filter(s => s.canDestination)
const database = await openDatabase({ appConfigPath: envs.APP_CONFIG_PATH })
const downloadsRepository = new DownloadsRepository(database.db)
-const app = getApp(envs, config, connectors, { downloadsRepository })
+const downloadsService = config.downloads
+ ? new DownloadsService(config.downloads, connectors.peers, downloadsRepository)
+ : undefined
+
+const app = getApp(envs, config, connectors, { downloadsRepository, downloadsService })
const server = Bun.serve({
fetch: app.fetch,
})
@@ -46,73 +50,65 @@ logger.info({
destinations: destinations.filter(c => c.isInitialized).length,
}, 'Server listening')
-// Auto-register as Torznab indexer (and Torrent Blackhole download client) in
-// each destination that opts in via its `autoregister` config.
+// Auto-register as a Torznab indexer + qBittorrent download client in each
+// destination that opts in via its `autoregister` config. We register even when
+// there are no peers / an empty catalog (forceSave on the *arr side), so the
+// Jack indexer and client are always present and bound — they start returning
+// results as soon as peers come online.
if (config.jack) {
- // Without peers there's nothing to search and nothing to grab, and *arr rejects
- // an indexer whose test query returns no results — so skip registration entirely.
- if (connectors.peers.length === 0) {
- logger.info('No peers configured; skipping indexer and download client registration (nothing to search or grab yet).')
- }
- else {
- const jackConfig = config.jack
- const downloads = config.downloads
+ const jackConfig = config.jack
+ const downloads = config.downloads
- if (!downloads) {
- logger.warn('No "downloads" config set; skipping download client auto-registration. Grabs will fail until a Torrent Blackhole client is configured.')
- }
-
- const registrable = destinations.filter(d => d.isInitialized && d.autoRegister.enable)
- for (const dest of registrable) {
- // Register the download client first so we can bind the indexer to it:
- // grabs from the Jack indexer must go to the Jack blackhole client, not
- // whatever client *arr would otherwise pick.
- let downloadClientId: number | undefined
- if (downloads) {
- try {
- downloadClientId = await dest.registerDownloadClient({
- name: 'Jack',
- watchPath: downloads.watchPath,
- completedPath: downloads.completedPath,
- priority: dest.autoRegister.priority,
- })
- logger.info({ destination: dest.name, downloadClientId }, 'Registered Jack as Torrent Blackhole download client')
- }
- catch (err) {
- logRegistrationFailure('download client', dest.name, err)
- }
- }
+ if (!downloads) {
+ logger.warn('No "downloads" config set; skipping download client auto-registration. Grabs will fail until a qBittorrent client is configured.')
+ }
+ const registrable = destinations.filter(d => d.isInitialized && d.autoRegister.enable)
+ for (const dest of registrable) {
+ // Register the download client first so we can bind the indexer to it:
+ // grabs from the Jack indexer must go to the Jack qBittorrent client, not
+ // whatever client *arr would otherwise pick.
+ let downloadClientId: number | undefined
+ if (downloads) {
try {
- await dest.registerIndexer({
+ downloadClientId = await dest.registerDownloadClient({
name: 'Jack',
- baseUrl: `${jackConfig.baseUrl}/torznab`,
- apiKey: jackConfig.apiKey,
- priority: dest.autoRegister.priority,
- categories: dest.categories,
- downloadClientId,
+ baseUrl: jackConfig.baseUrl,
+ username: dest.name,
+ password: jackConfig.apiKey,
+ category: qbCategoryForServer(dest.id),
})
- logger.info({ destination: dest.name, categories: dest.categories, downloadClientId }, 'Registered Jack as Torznab indexer')
+ logger.info({ destination: dest.name, downloadClientId }, 'Registered Jack as qBittorrent download client')
}
catch (err) {
- logRegistrationFailure('indexer', dest.name, err)
+ logRegistrationFailure('download client', dest.name, err)
}
}
+
+ try {
+ await dest.registerIndexer({
+ name: 'Jack',
+ baseUrl: `${jackConfig.baseUrl}/torznab`,
+ apiKey: jackConfig.apiKey,
+ priority: dest.autoRegister.priority,
+ categories: dest.categories,
+ downloadClientId,
+ })
+ logger.info({ destination: dest.name, categories: dest.categories, downloadClientId }, 'Registered Jack as Torznab indexer')
+ }
+ catch (err) {
+ logRegistrationFailure('indexer', dest.name, err)
+ }
}
}
-// Start blackhole watcher (and re-drive interrupted downloads from a prior run)
-let blackholeWatcher: BlackholeWatcher | null = null
-if (config.downloads) {
- const downloadsService = new DownloadsService(config.downloads, connectors.peers, destinations, downloadsRepository)
- // Active re-enqueue: resume stale `downloading` rows in place before the
- // watcher scans, so the leftover .torrent stubs are not re-processed as new rows.
+// Re-drive interrupted downloads from a prior run.
+if (config.downloads && downloadsService) {
+ // Active re-enqueue: resume stale `downloading` rows in place, picking up from
+ // their .part files.
const resumed = await downloadsService.resumeStaleDownloads()
if (resumed > 0)
logger.warn({ downloads: resumed, databasePath: database.path }, 'Re-enqueued interrupted downloads from previous Jack run')
-
- blackholeWatcher = new BlackholeWatcher(config.downloads, downloadsService)
- await blackholeWatcher.start()
}
else {
// No downloads config means stale rows cannot be resumed — mark them failed.
@@ -123,7 +119,6 @@ else {
process.on('SIGINT', async () => {
logger.info('SIGINT received, exiting')
- blackholeWatcher?.stop()
database.close()
server.stop()
await shutdownTelemetry()
@@ -132,7 +127,6 @@ process.on('SIGINT', async () => {
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, exiting')
- blackholeWatcher?.stop()
database.close()
server.stop()
await shutdownTelemetry()
diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts
index 4129b38..1abc0ff 100644
--- a/apps/backend/src/lib/config.ts
+++ b/apps/backend/src/lib/config.ts
@@ -88,7 +88,7 @@ export const ConnectorHeadersConfig = z.record(z.string(), ConfigSecret()).defau
export type ConnectorHeadersConfig = z.infer
-// Auto-registration of jack as a Torznab indexer + Torrent Blackhole download
+// Auto-registration of jack as a Torznab indexer + qBittorrent download
// client inside the *arr. `priority` is the indexer/client priority used there.
export const AutoRegisterConfig = z.object({
enable: z.boolean().default(true),
@@ -131,15 +131,29 @@ export const JackConfig = z.object({
export type JackConfig = z.infer
export const DownloadsConfig = z.object({
- watchPath: z.string().min(1),
completedPath: z.string().min(1),
// Max peer file downloads running at once (an async semaphore guards the
// expensive download step). Defaults keep existing configs working.
maxConcurrentDownloads: z.number().int().min(1).default(3),
// Bounded retries for transient failures, with exponential backoff + jitter.
- maxDownloadAttempts: z.number().int().min(1).default(5),
+ // A peer (another jack) can go unreachable for ~15-30 min (restart, tunnel
+ // hiccup); since the .part is preserved and fully resumable, the schedule must
+ // span long enough to outlast such an outage rather than fail fast.
+ //
+ // The backoff (see lib/retry.ts) is full-jitter exponential: each retry waits
+ // up to `min(maxDelayMs, baseDelayMs * 2^(attempt-1))`. Starting at 1s and
+ // capped at 30min, the uncapped backoff reaches the cap at attempt 12
+ // (2^11 = 2048 >= 1800). With 13 total attempts there are 12 retries whose
+ // max delays are 1s,2s,4s,...,512s,1024s(~17m),1800s(30m cap) — a worst-case
+ // total retry window of ~64min (≈32min on average with jitter). That keeps a
+ // ~17min outage well within reach while early retries stay snappy (≈1s) for
+ // ordinary network blips.
+ maxDownloadAttempts: z.number().int().min(1).default(13),
retryBaseDelayMs: z.number().int().min(0).default(1000),
- retryMaxDelayMs: z.number().int().min(0).default(60_000),
+ retryMaxDelayMs: z.number().int().min(0).default(1_800_000),
+ // Abort a peer download if no bytes arrive for this long (inactivity timeout).
+ // Resets on every received chunk; replaces the old whole-request deadline.
+ idleTimeoutMs: z.number().int().min(1000).default(60_000),
})
export type DownloadsConfig = z.infer
diff --git a/apps/backend/src/lib/errors/IdleTimeoutError.ts b/apps/backend/src/lib/errors/IdleTimeoutError.ts
new file mode 100644
index 0000000..73dea5f
--- /dev/null
+++ b/apps/backend/src/lib/errors/IdleTimeoutError.ts
@@ -0,0 +1,12 @@
+import { AppError } from './AppError'
+
+/**
+ * A peer download received no bytes for longer than the idle timeout and was
+ * aborted. The `.part` is preserved, so this is retryable: the next attempt
+ * resumes from where it stalled.
+ */
+export class IdleTimeoutError extends AppError {
+ constructor(message: string, cause?: unknown) {
+ super(message, 'IDLE_TIMEOUT', { cause })
+ }
+}
diff --git a/apps/backend/src/lib/errors/UnknownSizeError.ts b/apps/backend/src/lib/errors/UnknownSizeError.ts
new file mode 100644
index 0000000..a3ecb0e
--- /dev/null
+++ b/apps/backend/src/lib/errors/UnknownSizeError.ts
@@ -0,0 +1,12 @@
+import { AppError } from './AppError'
+
+/**
+ * The download has no known expected size (no Content-Length / Content-Range and
+ * no releaseSize), so completeness can't be verified. Fail-fast and permanent —
+ * retrying won't make a size appear.
+ */
+export class UnknownSizeError extends AppError {
+ constructor(message: string, cause?: unknown) {
+ super(message, 'UNKNOWN_SIZE', { cause })
+ }
+}
diff --git a/apps/backend/src/lib/servers/arr/base.ts b/apps/backend/src/lib/servers/arr/base.ts
index d4c88d1..e403e8d 100644
--- a/apps/backend/src/lib/servers/arr/base.ts
+++ b/apps/backend/src/lib/servers/arr/base.ts
@@ -7,6 +7,7 @@ import { requiresDestination, requiresSource } from '../../decorators/requires-c
import { ServerConnector } from '../base'
const BASENAME_SEPARATOR_REGEX = /[/\\]/
+const TRAILING_SLASH_REGEX = /\/$/
export const DestinationServerHealthIssue = z.array(
z.object({
@@ -32,6 +33,13 @@ export const DestinationServerHealthIssue = z.array(
// the auto-registered indexer to it.
const DownloadClientResource = z.object({ id: z.number().int() })
+// Register the Jack client at *arr's lowest selectable priority (the UI caps it
+// at 50). *arr's general client pool only round-robins among the best-priority
+// group, so a worst-priority Jack client is never picked for real torrents from
+// other indexers — while grabs from the Jack indexer still reach it, because the
+// indexer→client binding is resolved before *arr applies the priority grouping.
+const JACK_DOWNLOAD_CLIENT_PRIORITY = 50
+
export type ReleaseKind = 'movie' | 'episode'
export function basename(path: string): string {
@@ -71,7 +79,8 @@ export abstract class ArrServerConnector extends ServerConnector {
// Category id reported to *arr (2000 movies / 5000 tv).
abstract get categories(): number[]
- protected abstract get importCommandName(): string
+ // qBittorrent settings use a per-app category field name.
+ protected abstract get qbCategoryFieldName(): string
protected override async runInit(): Promise {
const apiInfo = await this.ping(z.object({ appName: z.string(), version: z.string() }))
@@ -161,16 +170,6 @@ export abstract class ArrServerConnector extends ServerConnector {
return this.fetch('/api/v3/health', { schema: z.array(DestinationServerHealthIssue) })
}
- @requiresDestination
- @requireInitialization
- async triggerImport(downloadPath: string) {
- await this.fetch('/api/v3/command', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: this.importCommandName, path: downloadPath }),
- } as any)
- }
-
@requiresDestination
@requireInitialization
async registerIndexer(indexerConfig: { name: string, baseUrl: string, apiKey: string, priority: number, categories: number[], downloadClientId?: number }) {
@@ -201,15 +200,16 @@ export abstract class ArrServerConnector extends ServerConnector {
],
}
- // forceSave: false keeps *arr's validation test on save. We deliberately do
- // NOT want to register when it fails — better to fail loudly (the caller logs
- // the *arr error) than to silently register a broken indexer.
+ // forceSave: true registers the indexer even when *arr's test query returns
+ // no results (e.g. no peers / empty catalog yet). We always want the Jack
+ // indexer present and bound to the Jack client; it starts returning results
+ // as soon as peers come online.
if (existing) {
await this.fetch(`/api/v3/indexer/${existing.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, id: existing.id }),
- query: { forceSave: 'false' },
+ query: { forceSave: 'true' },
} as any)
}
else {
@@ -217,48 +217,61 @@ export abstract class ArrServerConnector extends ServerConnector {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
- query: { forceSave: 'false' },
+ query: { forceSave: 'true' },
} as any)
}
}
@requiresDestination
@requireInitialization
- async registerDownloadClient(clientConfig: { name: string, watchPath: string, completedPath: string, priority: number }): Promise {
+ async registerDownloadClient(clientConfig: { name: string, baseUrl: string, username: string, password: string, category: string }): Promise {
+ const url = new URL(clientConfig.baseUrl)
+ const host = url.hostname
+ const port = url.port ? Number(url.port) : (url.protocol === 'https:' ? 443 : 80)
+ const useSsl = url.protocol === 'https:'
+ // urlBase is the path prefix BEFORE /api/v2 (qB's proxy appends /api/v2).
+ const urlBase = url.pathname.replace(TRAILING_SLASH_REGEX, '')
+
+ // Match by NAME regardless of implementation so an existing TorrentBlackhole
+ // "Jack" client from a previous version is upgraded in place (PUT switches it
+ // to QBittorrent/QBittorrentSettings) instead of leaving a duplicate.
const existingClients = await this.arrGet('/api/v3/downloadclient')
const existing: any = Array.isArray(existingClients)
- ? existingClients.find((client: any) =>
- client.fields?.some((f: any) => f.name === 'torrentFolder' && f.value === clientConfig.watchPath))
+ ? existingClients.find((client: any) => client.name === clientConfig.name)
: null
const body = {
name: clientConfig.name,
enable: true,
protocol: 'torrent',
- priority: clientConfig.priority,
- implementation: 'TorrentBlackhole',
- implementationName: 'Torrent Blackhole',
- configContract: 'TorrentBlackholeSettings',
+ priority: JACK_DOWNLOAD_CLIENT_PRIORITY,
+ implementation: 'QBittorrent',
+ implementationName: 'qBittorrent',
+ configContract: 'QBittorrentSettings',
+ // Explicitly clear tags: an earlier version tagged this client, which broke
+ // grabs (*arr filters the indexer-bound client by movie tags too).
+ tags: [],
fields: [
- // *arr writes the stub .torrent here; jack's watcher picks it up.
- { name: 'torrentFolder', value: clientConfig.watchPath },
- // jack writes the finished file here; *arr scans it to import.
- { name: 'watchFolder', value: clientConfig.completedPath },
- { name: 'saveMagnetFiles', value: false },
- { name: 'readOnly', value: false },
+ { name: 'host', value: host },
+ { name: 'port', value: port },
+ { name: 'useSsl', value: useSsl },
+ { name: 'urlBase', value: urlBase },
+ { name: 'username', value: clientConfig.username },
+ { name: 'password', value: clientConfig.password },
+ { name: this.qbCategoryFieldName, value: clientConfig.category },
],
}
- // forceSave: false keeps *arr's folder-accessibility test on save. We
- // deliberately do NOT want to register when it fails — better to fail loudly
- // (the caller logs the *arr error) than to silently register a download
- // client whose watch/completed folders *arr can't actually reach.
+ // forceSave: true registers the client even if *arr's connection test can't
+ // reach jack at registration time. This guarantees the client is saved and
+ // its id returned, so the indexer can always be bound to it (an unbound
+ // indexer is the failure mode when the test throws here).
if (existing) {
await this.fetch(`/api/v3/downloadclient/${existing.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, id: existing.id }),
- query: { forceSave: 'false' },
+ query: { forceSave: 'true' },
} as any)
return existing.id as number
}
@@ -267,7 +280,7 @@ export abstract class ArrServerConnector extends ServerConnector {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
- query: { forceSave: 'false' },
+ query: { forceSave: 'true' },
schema: DownloadClientResource,
} as any)
return created.id
diff --git a/apps/backend/src/lib/servers/arr/radarr.ts b/apps/backend/src/lib/servers/arr/radarr.ts
index 15d7ec3..8d16650 100644
--- a/apps/backend/src/lib/servers/arr/radarr.ts
+++ b/apps/backend/src/lib/servers/arr/radarr.ts
@@ -19,8 +19,8 @@ export class RadarrServerConnector extends ArrServerConnector {
return [ReleaseCategory.Movie]
}
- protected override get importCommandName(): string {
- return 'DownloadedMoviesScan'
+ protected override get qbCategoryFieldName(): string {
+ return 'movieCategory'
}
private toRelease(movie: MovieResource): Release | null {
diff --git a/apps/backend/src/lib/servers/arr/sonarr.ts b/apps/backend/src/lib/servers/arr/sonarr.ts
index 606b88f..b1e52c6 100644
--- a/apps/backend/src/lib/servers/arr/sonarr.ts
+++ b/apps/backend/src/lib/servers/arr/sonarr.ts
@@ -21,8 +21,8 @@ export class SonarrServerConnector extends ArrServerConnector {
return [ReleaseCategory.Tv]
}
- protected override get importCommandName(): string {
- return 'DownloadedEpisodesScan'
+ protected override get qbCategoryFieldName(): string {
+ return 'tvCategory'
}
private buildRelease(episode: EpisodeResource, series: SeriesResource | undefined, file: EpisodeFileResource | undefined): Release | null {
diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts
index fcb9e5d..e23f909 100644
--- a/apps/backend/src/lib/servers/peer.ts
+++ b/apps/backend/src/lib/servers/peer.ts
@@ -4,7 +4,9 @@ import z from 'zod'
import { logger } from '../../logger'
import { requireInitialization } from '../decorators/require-initialization'
import { FetchError } from '../errors/FetchError'
+import { IdleTimeoutError } from '../errors/IdleTimeoutError'
import { IncompleteDownloadError } from '../errors/IncompleteDownloadError'
+import { UnknownSizeError } from '../errors/UnknownSizeError'
import { normalizeImdbId, Release } from '../release'
import { withSpan } from '../tracing'
import { ServerConnector } from './base'
@@ -16,13 +18,13 @@ const DOWNLOAD_PROGRESS_BYTES = 64 * 1024 * 1024
const CONTENT_RANGE_PATTERN = /^bytes (\d+)-(\d+)\/(\d+)$/
export type PeerDownloadProgressEvent
- = | { type: 'headers', expectedBytes: number | null, expectedBytesSource: 'content_length' | null, expectedBytesMismatch: boolean }
+ = | { type: 'headers', expectedBytes: number | null, expectedBytesSource: 'content_length' | 'content_range' | 'release_size' | null, expectedBytesMismatch: boolean }
| { type: 'progress', downloadedBytes: number, expectedBytes: number | null }
- | { type: 'restart', reason: 'range_ignored' | 'content_range_mismatch' | 'range_not_satisfiable', discardedBytes: number }
+ | { type: 'restart', reason: 'range_ignored' | 'content_range_mismatch' | 'range_not_satisfiable' | 'part_oversize', discardedBytes: number }
| { type: 'completed', downloadedBytes: number, expectedBytes: number | null }
export interface PeerDownloadOptions {
- timeoutMs?: number
+ idleTimeoutMs?: number
torrentFilename?: string
partPath?: string
releaseSize?: number
@@ -166,22 +168,56 @@ export class PeerConnector extends ServerConnector {
'item.id': id,
'torrent.filename': options.torrentFilename,
}, async (span) => {
- const timeoutMs = options.timeoutMs ?? 30 * 60 * 1000
+ const idleTimeoutMs = options.idleTimeoutMs ?? 60_000
const torrentFilename = options.torrentFilename
const url = new URL(`/peer/items/${encodeURIComponent(id)}/file`, this.url)
const partPath = options.partPath ?? `${destPath}.part`
const baseHeaders = { ...this.headers, 'X-Api-Key': this.apiKey }
- span.setAttributes({ 'http.request.timeout_ms': timeoutMs, 'url.path': url.pathname })
+ span.setAttributes({ 'http.request.idle_timeout_ms': idleTimeoutMs, 'url.path': url.pathname })
+
+ // Idle (inactivity) timeout, armed ONLY around network waits (fetch + each
+ // read) and cleared before local file/progress work, so slow disk I/O never
+ // trips it. The abort carries a sentinel reason so only it — not a later real
+ // error — is reclassified as a retryable IdleTimeoutError.
+ const controller = new AbortController()
+ const IDLE_ABORT_REASON = 'jack:idle-timeout'
+ let idleTimer: ReturnType | undefined
+ const clearIdle = () => {
+ if (idleTimer) {
+ clearTimeout(idleTimer)
+ idleTimer = undefined
+ }
+ }
+ const armIdle = () => {
+ clearIdle()
+ idleTimer = setTimeout(() => controller.abort(IDLE_ABORT_REASON), idleTimeoutMs)
+ idleTimer.unref?.()
+ }
+ const isIdleAbort = () => controller.signal.aborted && controller.signal.reason === IDLE_ABORT_REASON
+ const idleTimeout = () => new IdleTimeoutError(`Peer download stalled: no data received for ${idleTimeoutMs}ms`)
const partFile = Bun.file(partPath)
let existingBytes = await partFile.exists() ? partFile.size : 0
- const doFetch = (withRange: boolean) => fetch(url, {
- headers: withRange ? { ...baseHeaders, Range: `bytes=${existingBytes}-` } : baseHeaders,
- signal: AbortSignal.timeout(timeoutMs),
- })
+ const doFetch = async (withRange: boolean): Promise => {
+ armIdle()
+ try {
+ return await fetch(url, {
+ headers: withRange ? { ...baseHeaders, Range: `bytes=${existingBytes}-` } : baseHeaders,
+ signal: controller.signal,
+ })
+ }
+ catch (err) {
+ if (isIdleAbort())
+ throw idleTimeout()
+ throw err
+ }
+ finally {
+ clearIdle()
+ }
+ }
- const emitRestart = async (reason: 'range_ignored' | 'content_range_mismatch' | 'range_not_satisfiable', discardedBytes: number) => {
+ const emitRestart = async (reason: 'range_ignored' | 'content_range_mismatch' | 'range_not_satisfiable' | 'part_oversize', discardedBytes: number) => {
logger.warn({ id, torrentFilename, partPath, discardedBytes, reason, peer: this.name }, 'Resume validation failed; restarting download from byte 0')
try {
await options.onProgress?.({ type: 'restart', reason, discardedBytes })
@@ -203,6 +239,26 @@ export class PeerConnector extends ServerConnector {
return doFetch(false)
}
+ // Resume sanity vs the known release size (available before the request):
+ // a .part larger than the whole file is corrupt → discard; a .part already
+ // equal to the file is complete → finalize without re-downloading.
+ if (existingBytes > 0 && options.releaseSize != null) {
+ if (existingBytes > options.releaseSize) {
+ await unlink(partPath).catch(() => {})
+ await emitRestart('part_oversize', existingBytes)
+ existingBytes = 0
+ }
+ else if (existingBytes === options.releaseSize) {
+ await rename(partPath, destPath)
+ span.setAttribute('download.downloaded_bytes', existingBytes)
+ // Emit headers too so the service persists expectedBytes/source (the
+ // fast path otherwise skips the headers event).
+ await options.onProgress?.({ type: 'headers', expectedBytes: options.releaseSize, expectedBytesSource: 'release_size', expectedBytesMismatch: false })
+ await options.onProgress?.({ type: 'completed', downloadedBytes: existingBytes, expectedBytes: options.releaseSize })
+ return
+ }
+ }
+
let response = await doFetch(existingBytes > 0)
span.setAttribute('http.response.status_code', response.status)
@@ -251,38 +307,50 @@ export class PeerConnector extends ServerConnector {
if (!response.body)
throw new Error('Peer returned a file response without a body')
- // Total file size: from Content-Range on a resume (206), else Content-Length.
- const expectedBytes = resuming
+ // Expected total size: the transfer header (Content-Range total on resume,
+ // else Content-Length), falling back to the *arr release size. Fail-fast if
+ // none is known — we never import a file we can't size-check.
+ const transferSize = resuming
? parseContentRange(response.headers.get('Content-Range'))?.total ?? null
: parseContentLength(response.headers)
- const expectedBytesMismatch = expectedBytes != null && options.releaseSize != null && expectedBytes !== options.releaseSize
- if (expectedBytes != null)
- span.setAttribute('download.expected_bytes', expectedBytes)
+ const expectedBytes = transferSize ?? options.releaseSize ?? null
+ // Source = where the TRANSFER advertised the size: Content-Range on a resume
+ // (206), else Content-Length; 'release_size' means it came only from *arr
+ // metadata (the peer advertised no size).
+ const expectedBytesSource: 'content_length' | 'content_range' | 'release_size' | null
+ = transferSize != null ? (resuming ? 'content_range' : 'content_length') : (expectedBytes != null ? 'release_size' : null)
+ const expectedBytesMismatch = transferSize != null && options.releaseSize != null && transferSize !== options.releaseSize
span.setAttributes({
'download.resuming': resuming,
'download.resume_from_bytes': existingBytes,
- 'download.expected_bytes_source': expectedBytes == null ? 'unknown' : 'content_length',
+ 'download.expected_bytes_source': expectedBytesSource ?? 'unknown',
'download.expected_bytes_mismatch': expectedBytesMismatch,
})
+ if (expectedBytes == null) {
+ void response.body.cancel().catch(() => {})
+ throw new UnknownSizeError(`Cannot verify download for item ${id}: no Content-Length/Content-Range and no release size`)
+ }
+ span.setAttribute('download.expected_bytes', expectedBytes)
+
if (expectedBytesMismatch) {
- logger.warn({ id, torrentFilename, releaseSize: options.releaseSize, expectedBytes, peer: this.name }, 'Peer file total size differs from release metadata size')
+ logger.warn({ id, torrentFilename, releaseSize: options.releaseSize, expectedBytes: transferSize, peer: this.name }, 'Peer file total size differs from release metadata size')
}
await options.onProgress?.({
type: 'headers',
expectedBytes,
- expectedBytesSource: expectedBytes == null ? null : 'content_length',
+ expectedBytesSource,
expectedBytesMismatch,
})
- if (expectedBytes != null && expectedBytes > MAX_DOWNLOAD_BYTES)
+ if (expectedBytes > MAX_DOWNLOAD_BYTES)
throw new Error(`File too large: ${expectedBytes} bytes exceeds ${MAX_DOWNLOAD_BYTES} byte limit`)
const handle = await open(partPath, resuming ? 'a' : 'w')
let reader: ReadableStreamDefaultReader
try {
- reader = response.body.getReader()
+ reader = response.body.getReader() as ReadableStreamDefaultReader
}
catch (err) {
await handle.close().catch(() => {})
@@ -302,7 +370,19 @@ export class PeerConnector extends ServerConnector {
try {
while (true) {
- const { done, value } = await reader.read()
+ // Arm the idle timer only for the network read; clear it immediately
+ // after so disk writes / progress callbacks don't count as "idle".
+ armIdle()
+ let done: boolean
+ let value: Uint8Array | undefined
+ try {
+ const result = await reader.read()
+ done = result.done
+ value = result.value
+ }
+ finally {
+ clearIdle()
+ }
if (done)
break
if (!value)
@@ -327,10 +407,13 @@ export class PeerConnector extends ServerConnector {
await handle.datasync().catch(() => {})
await closeHandle()
- reader.releaseLock()
- if (expectedBytes != null && downloadedBytes !== expectedBytes)
+ // Release the lock only after the completeness check passes, so the catch
+ // block's cancel+release runs against a still-locked reader on the
+ // IncompleteDownloadError path (which is the one that gets retried).
+ if (downloadedBytes !== expectedBytes)
throw new IncompleteDownloadError(`Incomplete file download: got ${downloadedBytes} bytes, expected ${expectedBytes}`)
+ reader.releaseLock()
await rename(partPath, destPath)
span.setAttribute('download.downloaded_bytes', downloadedBytes)
@@ -344,13 +427,15 @@ export class PeerConnector extends ServerConnector {
}
catch (err) {
await closeHandle()
+ // Cancel the reader so the remote stream is torn down (not left to GC),
+ // then release. Leave the .part in place so the next attempt resumes.
+ await reader.cancel().catch(() => {})
try {
reader.releaseLock()
}
catch {}
- // Leave the .part file in place so the next attempt can resume from the
- // durable bytes written so far. The atomic rename above means an
- // incomplete download never reaches `destPath`.
+ if (isIdleAbort())
+ throw idleTimeout()
throw err
}
})
diff --git a/apps/backend/src/modules/downloads/blackhole.watcher.ts b/apps/backend/src/modules/downloads/blackhole.watcher.ts
deleted file mode 100644
index 5a65968..0000000
--- a/apps/backend/src/modules/downloads/blackhole.watcher.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import type { AppConfig } from '../../lib/config'
-import type { DownloadsService } from './downloads.service'
-import { watch } from 'node:fs'
-import { readdir } from 'node:fs/promises'
-import { join } from 'node:path'
-import { logger } from '../../logger'
-
-const STABILITY_DELAY_MS = 500
-const STABILITY_RETRIES = 3
-
-export class BlackholeWatcher {
- private watcher: ReturnType | null = null
- private processing = new Set()
-
- constructor(
- private readonly config: NonNullable,
- private readonly downloadsService: DownloadsService,
- ) {}
-
- async start() {
- const { watchPath, completedPath } = this.config
-
- await Bun.$`mkdir -p ${watchPath} ${completedPath}`.quiet()
-
- // Register the watcher BEFORE scanning so a .torrent dropped during the
- // scan is not missed. The `processing` Set dedupes a file caught by both
- // the watch event and the scan.
- this.watcher = watch(watchPath, async (_event, filename) => {
- if (!filename)
- return
-
- const torrentFilename = String(filename)
- if (!torrentFilename.endsWith('.torrent'))
- return
-
- const filePath = join(watchPath, torrentFilename)
- logger.debug({ torrentFilename, filePath, watchPath }, 'Torrent file detected in watch folder')
- if (!await this.waitForStableFile(filePath))
- return
- await this.processTorrentFile(filePath, torrentFilename)
- })
-
- await this.scanExisting()
-
- logger.info({ watchPath, completedPath }, 'Blackhole watcher started')
- }
-
- stop() {
- this.watcher?.close()
- this.watcher = null
- logger.info('Blackhole watcher stopped')
- }
-
- private async waitForStableFile(filePath: string): Promise {
- let lastSize = -1
- for (let i = 0; i < STABILITY_RETRIES; i++) {
- await Bun.sleep(STABILITY_DELAY_MS)
- const file = Bun.file(filePath)
- if (!await file.exists())
- return false
- const size = file.size
- if (size === lastSize && size > 0)
- return true
- lastSize = size
- }
- return lastSize > 0
- }
-
- private async scanExisting() {
- logger.debug({ watchPath: this.config.watchPath }, 'Starting watch folder scan')
-
- try {
- const files = await readdir(this.config.watchPath)
- const torrentFiles = files.filter(file => file.endsWith('.torrent'))
-
- logger.debug({ watchPath: this.config.watchPath, filesFound: torrentFiles.length }, 'Watch folder scan complete')
-
- for (const file of torrentFiles) {
- const filePath = join(this.config.watchPath, file)
- logger.debug({ torrentFilename: file, filePath, watchPath: this.config.watchPath }, 'Torrent file found in watch folder scan')
- await this.processTorrentFile(filePath, file)
- }
- }
- catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- logger.warn({ watchPath: this.config.watchPath, error: message }, 'Watch folder scan failed')
- }
- }
-
- private async processTorrentFile(filePath: string, filename: string) {
- if (this.processing.has(filename))
- return
- this.processing.add(filename)
-
- try {
- await this.downloadsService.processTorrentFile(filePath, filename)
- }
- finally {
- this.processing.delete(filename)
- }
- }
-}
diff --git a/apps/backend/src/modules/downloads/downloads.repository.ts b/apps/backend/src/modules/downloads/downloads.repository.ts
index 67eaf9a..04ee487 100644
--- a/apps/backend/src/modules/downloads/downloads.repository.ts
+++ b/apps/backend/src/modules/downloads/downloads.repository.ts
@@ -25,6 +25,8 @@ export interface DownloadRecord {
updatedAt: string
completedAt: string | null
error: string | null
+ qbCategory: string | null
+ qbSourceServer: string | null
}
export interface CreateDownloadInput {
@@ -37,6 +39,8 @@ export interface CreateDownloadInput {
partPath: string
releaseSize: number
release: Release
+ qbCategory?: string | null
+ qbSourceServer?: string | null
}
function nowIso() {
@@ -65,6 +69,8 @@ function toRecord(row: DownloadRow): DownloadRecord {
updatedAt: row.updatedAt,
completedAt: row.completedAt,
error: row.error,
+ qbCategory: row.qbCategory ?? null,
+ qbSourceServer: row.qbSourceServer ?? null,
}
}
@@ -83,6 +89,8 @@ export class DownloadsRepository {
partPath: input.partPath,
releaseSize: input.releaseSize,
releaseJson: JSON.stringify(input.release),
+ qbCategory: input.qbCategory ?? null,
+ qbSourceServer: input.qbSourceServer ?? null,
downloadedBytes: 0,
status: 'downloading',
startedAt: timestamp,
@@ -156,6 +164,17 @@ export class DownloadsRepository {
.run()
}
+ setQbCategory(id: number, qbCategory: string): void {
+ this.db.update(downloads)
+ .set({ qbCategory, updatedAt: nowIso() })
+ .where(eq(downloads.id, id))
+ .run()
+ }
+
+ delete(id: number): void {
+ this.db.delete(downloads).where(eq(downloads.id, id)).run()
+ }
+
/** Stale `downloading` rows from a prior run, returned for active re-drive (no mutation). */
listStaleDownloads(): DownloadRecord[] {
return this.db.select().from(downloads).where(eq(downloads.status, 'downloading')).all().map(toRecord)
diff --git a/apps/backend/src/modules/downloads/downloads.service.ts b/apps/backend/src/modules/downloads/downloads.service.ts
index 37a9837..1afbcf2 100644
--- a/apps/backend/src/modules/downloads/downloads.service.ts
+++ b/apps/backend/src/modules/downloads/downloads.service.ts
@@ -1,143 +1,160 @@
import type { AppConfig } from '../../lib/config'
-import type { ArrServerConnector } from '../../lib/servers/arr/base'
import type { PeerConnector, PeerDownloadProgressEvent } from '../../lib/servers/peer'
import type { DownloadRecord, DownloadsRepository } from './downloads.repository'
-import { Buffer } from 'node:buffer'
-import { unlink } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { retry } from '../../lib/retry'
import { Semaphore } from '../../lib/semaphore'
-import { withSpan } from '../../lib/tracing'
import { logger } from '../../logger'
-import { parseTorrentStub } from '../torznab/torrent'
import { downloadRetryAfterMs, isTransientDownloadError } from './retry-policy'
type DownloadsServiceConfig = NonNullable
+// Characters disallowed in the synthetic qB torrent filename (keep word chars, dot, dash).
+const UNSAFE_FILENAME_CHARS = /[^\w.-]/g
+
+/**
+ * Outcome of a qB add:
+ * - 'started' — a new download row was created and is running.
+ * - 'duplicate' — a download for the same destination is already in flight; the
+ * add is a no-op but still a success (the release is being fetched).
+ * - 'failed' — no row could be created (unknown peer or an unsafe peer filename).
+ */
+export type StartQbDownloadResult = 'started' | 'duplicate' | 'failed'
+
+// createDownload's internal outcome: a record to run, a benign duplicate, or no peer.
+type CreateDownloadOutcome
+ = | { kind: 'created', record: DownloadRecord }
+ | { kind: 'duplicate' }
+ | { kind: 'no-peer' }
+
export class DownloadsService {
private readonly semaphore: Semaphore
// Dest paths with a download in flight — guards two concurrent live drops that
// resolve to the same destination (no duplicate rows / writers).
private readonly active = new Set()
- // Torrent filenames owned by the startup re-enqueue. Their leftover stubs are
- // skipped by the watcher's initial scan for the rest of the run, so a re-drive
- // that fails fast cannot be re-processed into a duplicate row.
- private readonly reenqueued = new Set()
constructor(
private readonly config: DownloadsServiceConfig,
private readonly peers: PeerConnector[],
- private readonly destinations: ArrServerConnector[],
private readonly downloadsRepository?: DownloadsRepository,
) {
this.semaphore = new Semaphore(config.maxConcurrentDownloads)
}
- async processTorrentFile(filePath: string, filename: string) {
- try {
- await withSpan('blackhole.process_torrent', { 'torrent.filename': filename }, async (span) => {
- // The startup re-enqueue owns this stub — it is being (or will be)
- // re-driven from the persisted row. Skip it so we never create a
- // duplicate row, even if that re-drive already failed and cleared `active`.
- if (this.reenqueued.has(filename)) {
- span.setAttribute('torrent.reenqueued', true)
- logger.debug({ torrentFilename: filename }, 'Stub owned by startup re-enqueue; skipping watcher processing')
- return
- }
-
- const file = Bun.file(filePath)
- if (!await file.exists()) {
- span.setAttribute('torrent.exists', false)
- return
- }
-
- span.setAttribute('torrent.exists', true)
- const data = Buffer.from(await file.arrayBuffer())
- const stub = parseTorrentStub(data)
-
- if (!stub) {
- span.setAttribute('torrent.stub.valid', false)
- logger.warn({ torrentFilename: filename, filename }, 'Could not parse torrent stub, skipping')
- return
- }
-
- span.setAttribute('torrent.stub.valid', true)
- const { peerId, itemId } = stub
- span.setAttributes({ 'peer.id': peerId, 'item.id': itemId })
-
- const peer = this.peers.find(p => p.id === peerId)
- if (!peer) {
- span.setAttribute('peer.found', false)
- logger.error({ torrentFilename: filename, peerId, filename }, 'Peer not found')
- return
- }
-
- span.setAttributes({ 'peer.found': true, 'peer.name': peer.name ?? peer.url })
-
- const release = await peer.getRelease(itemId)
+ /**
+ * Shared creation core for the qB add path. Returns the created record, a
+ * benign duplicate (a download for the same destination is already active),
+ * or no-peer when the peer is unknown. Throws on an unsafe filename.
+ */
+ private async createDownload(input: {
+ peerId: string
+ itemId: string
+ torrentFilename: string
+ qbCategory?: string | null
+ qbSourceServer?: string | null
+ }): Promise {
+ const { peerId, itemId, torrentFilename } = input
+ const peer = this.peers.find(p => p.id === peerId)
+ if (!peer) {
+ logger.error({ torrentFilename, peerId }, 'Peer not found')
+ return { kind: 'no-peer' }
+ }
- // `release.filename` is peer-controlled and only validated as a string.
- // Force it to a plain basename inside `completedPath` so a value like
- // `../../evil.mkv` or an absolute path cannot escape the directory.
- const safeName = basename(release.filename)
- const isSafeName = safeName.length > 0 && safeName !== '.' && safeName !== '..'
- && !safeName.includes('/') && !safeName.includes('\\')
- && release.filename === safeName
+ const release = await peer.getRelease(itemId)
- if (!isSafeName)
- throw new Error(`Unsafe release filename from peer: ${release.filename}`)
+ // `release.filename` is peer-controlled and only validated as a string.
+ // Force it to a plain basename inside `completedPath` so a value like
+ // `../../evil.mkv` or an absolute path cannot escape the directory.
+ const safeName = basename(release.filename)
+ const isSafeName = safeName.length > 0 && safeName !== '.' && safeName !== '..'
+ && !safeName.includes('/') && !safeName.includes('\\')
+ && release.filename === safeName
+ if (!isSafeName)
+ throw new Error(`Unsafe release filename from peer: ${release.filename}`)
- const destPath = join(this.config.completedPath, safeName)
- const partPath = `${destPath}.part`
- span.setAttributes({ 'release.filename': safeName, 'release.size': release.size })
+ const destPath = join(this.config.completedPath, safeName)
+ const partPath = `${destPath}.part`
- if (this.active.has(destPath)) {
- logger.debug({ torrentFilename: filename, destPath }, 'A download for this destination is already active; skipping duplicate')
- return
- }
+ if (this.active.has(destPath)) {
+ logger.debug({ torrentFilename, destPath }, 'A download for this destination is already active; skipping duplicate')
+ return { kind: 'duplicate' }
+ }
- const created = this.downloadsRepository?.create({
- torrentFilename: filename,
- peerId,
- peerName: peer.name ?? peer.url,
- itemId,
- filename: safeName,
- destPath,
- partPath,
- releaseSize: release.size,
- release,
- })
+ const created = this.downloadsRepository?.create({
+ torrentFilename,
+ peerId,
+ peerName: peer.name ?? peer.url,
+ itemId,
+ filename: safeName,
+ destPath,
+ partPath,
+ releaseSize: release.size,
+ release,
+ qbCategory: input.qbCategory ?? null,
+ qbSourceServer: input.qbSourceServer ?? null,
+ })
- const record: DownloadRecord = created ?? {
- id: -1,
- torrentFilename: filename,
- peerId,
- peerName: peer.name ?? peer.url,
- itemId,
- filename: safeName,
- destPath,
- partPath,
- releaseSize: release.size,
- release,
- expectedBytes: null,
- expectedBytesSource: null,
- expectedBytesMismatch: false,
- downloadedBytes: 0,
- attempts: 0,
- status: 'downloading',
- startedAt: '',
- updatedAt: '',
- completedAt: null,
- error: null,
- }
+ return {
+ kind: 'created',
+ record: created ?? {
+ id: -1,
+ torrentFilename,
+ peerId,
+ peerName: peer.name ?? peer.url,
+ itemId,
+ filename: safeName,
+ destPath,
+ partPath,
+ releaseSize: release.size,
+ release,
+ expectedBytes: null,
+ expectedBytesSource: null,
+ expectedBytesMismatch: false,
+ downloadedBytes: 0,
+ attempts: 0,
+ status: 'downloading',
+ startedAt: '',
+ updatedAt: '',
+ completedAt: null,
+ error: null,
+ qbCategory: input.qbCategory ?? null,
+ qbSourceServer: input.qbSourceServer ?? null,
+ },
+ }
+ }
- await this.runDownload(record)
- })
+ /**
+ * qB `/api/v2/torrents/add` entrypoint: create the row and drive the download
+ * in the background (the HTTP handler returns immediately).
+ */
+ async startQbDownload(input: {
+ peerId: string
+ itemId: string
+ qbCategory: string
+ qbSourceServer: string
+ }): Promise {
+ // qB-added downloads have no on-disk stub, but createDownload + the row still
+ // need a stable filename.
+ const torrentFilename = `qb-${input.peerId}-${input.itemId}.torrent`.replace(UNSAFE_FILENAME_CHARS, '_')
+ let outcome: CreateDownloadOutcome
+ try {
+ outcome = await this.createDownload({ ...input, torrentFilename })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
- logger.error({ torrentFilename: filename, filename, error: message }, 'Failed to process torrent')
+ logger.error({ peerId: input.peerId, itemId: input.itemId, error: message }, 'Failed to create qB download')
+ return 'failed'
}
+ if (outcome.kind === 'no-peer')
+ return 'failed'
+ // A duplicate is already in flight: no new row, but a success — don't make *arr retry.
+ if (outcome.kind === 'duplicate')
+ return 'duplicate'
+ void this.runDownload(outcome.record).catch((err) => {
+ const message = err instanceof Error ? err.message : String(err)
+ logger.error({ itemId: input.itemId, error: message }, 'qB download failed')
+ })
+ return 'started'
}
/** Re-drive stale `downloading` rows from a prior run, resuming from their .part files. */
@@ -159,13 +176,8 @@ export class DownloadsService {
seen.add(record.destPath)
resumable.push(record)
}
- // Claim every resumable stub up-front (synchronously, before the watcher
- // starts) so the initial scan skips them regardless of re-drive timing/outcome.
- for (const record of resumable)
- this.reenqueued.add(record.torrentFilename)
for (const record of resumable) {
- // Fire-and-forget: the semaphore caps concurrency, and the stub is already
- // claimed in `reenqueued` so the watcher won't duplicate it.
+ // Fire-and-forget: the semaphore caps concurrency.
void this.runDownload(record).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
logger.error({ torrentFilename: record.torrentFilename, error: message }, 'Failed to resume stale download')
@@ -213,8 +225,6 @@ export class DownloadsService {
repo?.markCompleted(record.id, event.downloadedBytes)
}
- const stubPath = join(this.config.watchPath, record.torrentFilename)
-
try {
await retry(async () => {
repo?.incrementAttempts(record.id)
@@ -222,6 +232,7 @@ export class DownloadsService {
torrentFilename: record.torrentFilename,
partPath: record.partPath,
releaseSize: record.releaseSize,
+ idleTimeoutMs: this.config.idleTimeoutMs,
onProgress,
})
}, {
@@ -236,15 +247,8 @@ export class DownloadsService {
},
})
- await unlink(stubPath).catch(() => {})
- await this.triggerImport(record)
repo?.markImportQueued(record.id)
- // Release the startup-re-enqueue claim now that the stub is gone, so a
- // later legitimate re-drop of the same filename isn't silently skipped.
- // Only on success: a failed re-drive keeps its stub, so it stays claimed
- // (and is re-driven on the next restart) to avoid in-session hammering.
- this.reenqueued.delete(record.torrentFilename)
- logger.info({ torrentFilename: record.torrentFilename, filename: record.filename }, 'Download complete, triggered import')
+ logger.info({ torrentFilename: record.torrentFilename, filename: record.filename }, 'Download complete')
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -254,30 +258,4 @@ export class DownloadsService {
logger.error({ torrentFilename: record.torrentFilename, filename: record.filename, error: message }, 'Download failed')
}
}
-
- private async triggerImport(record: DownloadRecord) {
- const torrentFilename = record.torrentFilename
- // Route the import to the *arr that owns this release's category (movie →
- // Radarr, tv → Sonarr). Firing at every destination makes the wrong app scan
- // a folder it can't match, and Sonarr in particular answers with a 500.
- const category = record.release.category
- const matching = this.destinations.filter(d => d.isInitialized && d.canDestination && d.categories.includes(category))
-
- if (matching.length === 0) {
- logger.warn({ torrentFilename, category }, 'No initialized destination handles this release category; skipping import trigger')
- return
- }
-
- for (const dest of matching) {
- try {
- await withSpan('blackhole.trigger_import', { 'torrent.filename': torrentFilename, 'destination.name': dest.name, 'release.category': category }, async () => {
- await dest.triggerImport(this.config.completedPath)
- })
- }
- catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- logger.error({ torrentFilename, destination: dest.name, error: message }, 'Failed to trigger import')
- }
- }
- }
}
diff --git a/apps/backend/src/modules/downloads/retry-policy.ts b/apps/backend/src/modules/downloads/retry-policy.ts
index 71c786e..bd247cb 100644
--- a/apps/backend/src/modules/downloads/retry-policy.ts
+++ b/apps/backend/src/modules/downloads/retry-policy.ts
@@ -1,4 +1,5 @@
import { FetchError } from '../../lib/errors/FetchError'
+import { IdleTimeoutError } from '../../lib/errors/IdleTimeoutError'
import { IncompleteDownloadError } from '../../lib/errors/IncompleteDownloadError'
/**
@@ -9,6 +10,8 @@ import { IncompleteDownloadError } from '../../lib/errors/IncompleteDownloadErro
export function isTransientDownloadError(error: unknown): boolean {
if (error instanceof IncompleteDownloadError)
return true
+ if (error instanceof IdleTimeoutError)
+ return true
if (error instanceof FetchError) {
const status = error.response?.status ?? error.extras.status
if (status == null)
diff --git a/apps/backend/src/modules/peer/peer.controller.ts b/apps/backend/src/modules/peer/peer.controller.ts
index f7b7e1b..7bb3f50 100644
--- a/apps/backend/src/modules/peer/peer.controller.ts
+++ b/apps/backend/src/modules/peer/peer.controller.ts
@@ -28,9 +28,14 @@ export function parseRangeHeader(value: string | undefined | null): { start?: nu
return { start, end }
}
+// `body` is the raw BunFile (or a sliced view of it), NOT a manual ReadableStream.
+// Handing the BunFile straight to `new Response` lets Bun.serve stream it with native
+// backpressure (sendfile): if the consumer stalls or aborts mid-download, we stop
+// reading from disk instead of buffering the whole file into RAM. Pumping `.stream()`
+// ourselves does NOT backpressure and lets one stalled peer OOM the process.
export type StreamFileResult
- = | { type: 'full', stream: ReadableStream, size: number, filename: string }
- | { type: 'partial', stream: ReadableStream, size: number, totalSize: number, start: number, end: number, filename: string }
+ = | { type: 'full', body: Blob, size: number, filename: string }
+ | { type: 'partial', body: Blob, size: number, totalSize: number, start: number, end: number, filename: string }
| { type: 'unsatisfiable', totalSize: number }
/**
@@ -151,7 +156,7 @@ export class PeerController {
const range = parseRangeHeader(rangeHeader)
if (!range) {
- return { type: 'full', stream: file.stream(), size: totalSize, filename }
+ return { type: 'full', body: file, size: totalSize, filename }
}
let start: number
@@ -178,7 +183,7 @@ export class PeerController {
span.setAttributes({ 'range.satisfiable': true, 'range.start': start, 'range.end': end })
// Bun.file().slice is half-open [start, end), so +1 to include `end`.
- return { type: 'partial', stream: file.slice(start, end + 1).stream(), size: end - start + 1, totalSize, start, end, filename }
+ return { type: 'partial', body: file.slice(start, end + 1), size: end - start + 1, totalSize, start, end, filename }
})
}
}
diff --git a/apps/backend/src/modules/peer/peer.router.ts b/apps/backend/src/modules/peer/peer.router.ts
index 7d454cc..b518549 100644
--- a/apps/backend/src/modules/peer/peer.router.ts
+++ b/apps/backend/src/modules/peer/peer.router.ts
@@ -50,7 +50,7 @@ export function getPeerRouter(controller: PeerController) {
}
if (result.type === 'partial') {
- return new Response(result.stream, {
+ return new Response(result.body, {
status: 206,
headers: {
'Content-Type': 'application/octet-stream',
@@ -62,7 +62,7 @@ export function getPeerRouter(controller: PeerController) {
})
}
- return new Response(result.stream, {
+ return new Response(result.body, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(result.size),
diff --git a/apps/backend/src/modules/qbittorrent/qbittorrent.controller.ts b/apps/backend/src/modules/qbittorrent/qbittorrent.controller.ts
new file mode 100644
index 0000000..c559f90
--- /dev/null
+++ b/apps/backend/src/modules/qbittorrent/qbittorrent.controller.ts
@@ -0,0 +1,219 @@
+import type { ArrServerConnector } from '../../lib/servers/arr/base'
+import type { DownloadRecord, DownloadsRepository } from '../downloads/downloads.repository'
+import type { DownloadsService } from '../downloads/downloads.service'
+import type { QbTorrent } from './qbittorrent.mapper'
+import type { QbSession } from './qbittorrent.session'
+import { Buffer } from 'node:buffer'
+import { unlink } from 'node:fs/promises'
+import { parseTorrentStub } from '../torznab/torrent'
+import { deriveHash, qbCategoryForServer, toQbTorrent } from './qbittorrent.mapper'
+import { QbSessionStore } from './qbittorrent.session'
+
+export interface QbittorrentControllerDeps {
+ apiKey: string
+ completedPath: string
+ servers: ArrServerConnector[]
+ repository: DownloadsRepository
+ downloadsService?: DownloadsService
+}
+
+// Matches a jack download URL path: /torznab/download/.torrent
+const JACK_DOWNLOAD_PATH = /\/torznab\/download\/(.+)\.torrent$/
+
+export class QbittorrentController {
+ readonly sessions = new QbSessionStore()
+
+ constructor(private readonly deps: QbittorrentControllerDeps) {}
+
+ /**
+ * New SID on success; null on unknown username or wrong password. Username
+ * must match a configured server connector name; password must equal jack's
+ * apiKey (skipped when apiKey is empty, i.e. jack auth disabled).
+ */
+ login(username: string, password: string): string | null {
+ const { apiKey, servers } = this.deps
+ const server = servers.find(s => s.name === username)
+ if (!server)
+ return null
+ if (apiKey !== '' && password !== apiKey)
+ return null
+ return this.sessions.create({ serverName: server.name, serverId: server.id })
+ }
+
+ logout(sid: string | undefined): void {
+ this.sessions.delete(sid)
+ }
+
+ webapiVersion(): string {
+ return '2.9.2' // >= 2.6.1 so *arr reads content_path; >= 2.0 selects the V2 proxy
+ }
+
+ version(): string {
+ return 'v4.6.4'
+ }
+
+ preferences() {
+ // Tuned so *arr's RemovesCompletedDownloads() is false (max_ratio_act=Pause,
+ // ratio/seeding-time limits disabled) and its priority test is skipped.
+ return {
+ save_path: this.deps.completedPath,
+ queueing_enabled: true,
+ dht: true,
+ max_ratio_enabled: false,
+ max_ratio: -1,
+ max_ratio_act: 0,
+ max_seeding_time_enabled: false,
+ max_seeding_time: -1,
+ }
+ }
+
+ categories(): Record {
+ const out: Record = {}
+ for (const server of this.deps.servers) {
+ const name = qbCategoryForServer(server.id)
+ out[name] = { name, savePath: this.deps.completedPath }
+ }
+ return out
+ }
+
+ private findByHash(hash: string): DownloadRecord | null {
+ const target = hash.toLowerCase()
+ return this.deps.repository.list().find(r => deriveHash(r.release.title, r.releaseSize) === target) ?? null
+ }
+
+ /**
+ * All rows sharing an infohash (the same release added by ≥1 server). Used by
+ * the session-scoped mutations so a shared hash never touches another
+ * server's row.
+ */
+ private findAllByHash(hash: string): DownloadRecord[] {
+ const target = hash.toLowerCase()
+ return this.deps.repository.list().filter(r => deriveHash(r.release.title, r.releaseSize) === target)
+ }
+
+ torrentsInfo(filter: { category?: string, hashes?: string[] }): QbTorrent[] {
+ const { completedPath } = this.deps
+ let result = this.deps.repository.list()
+ .map(r => toQbTorrent(r, { completedPath, category: r.qbCategory ?? '' }))
+ if (filter.category !== undefined)
+ result = result.filter(t => t.category === filter.category)
+ if (filter.hashes && filter.hashes.length > 0) {
+ const set = new Set(filter.hashes.map(h => h.toLowerCase()))
+ result = result.filter(t => set.has(t.hash))
+ }
+ return result
+ }
+
+ torrentProperties(hash: string): { save_path: string, seeding_time: number } | null {
+ const record = this.findByHash(hash)
+ if (!record)
+ return null
+ return { save_path: this.deps.completedPath, seeding_time: 0 }
+ }
+
+ torrentFiles(hash: string): { name: string }[] {
+ const record = this.findByHash(hash)
+ return record ? [{ name: record.filename }] : []
+ }
+
+ /**
+ * Add result:
+ * - 'ok' — accepted (jack stub upload or jack download URL).
+ * - 'unsupported' (→ HTTP 415) — a magnet or a non-jack/foreign torrent.
+ * - 'unavailable' (→ HTTP 503) — jack has no downloads config, so the add
+ * pipeline isn't wired; a server-side misconfiguration, not a bad torrent.
+ * - 'failed' (→ HTTP 503) — the torrent was accepted but no download row could
+ * be created (e.g. unknown peer or an unsafe peer-supplied filename). Surfaced
+ * as 503 so *arr retries promptly instead of waiting out its stuck-download grace period.
+ */
+ async addTorrent(input: { session: QbSession, category?: string, urls: string[], torrentFiles: Uint8Array[] }): Promise<'ok' | 'unsupported' | 'unavailable' | 'failed'> {
+ const service = this.deps.downloadsService
+ if (!service)
+ return 'unavailable'
+
+ const stubs: { peerId: string, itemId: string }[] = []
+ for (const bytes of input.torrentFiles) {
+ const stub = parseTorrentStub(Buffer.from(bytes))
+ if (!stub)
+ return 'unsupported'
+ stubs.push(stub)
+ }
+ for (const url of input.urls) {
+ const parsed = this.parseJackUrl(url)
+ if (!parsed)
+ return 'unsupported'
+ stubs.push(parsed)
+ }
+ if (stubs.length === 0)
+ return 'unsupported'
+
+ const category = input.category && input.category.length > 0
+ ? input.category
+ : qbCategoryForServer(input.session.serverId)
+
+ for (const stub of stubs) {
+ // 'started' and 'duplicate' are both successes; only 'failed' (unknown peer
+ // or unsafe peer filename) leaves no row, so surface that to *arr.
+ const result = await service.startQbDownload({
+ peerId: stub.peerId,
+ itemId: stub.itemId,
+ qbCategory: category,
+ qbSourceServer: input.session.serverName,
+ })
+ if (result === 'failed')
+ return 'failed'
+ }
+ return 'ok'
+ }
+
+ /**
+ * Parse a jack download URL into peerId/itemId. Rejects magnets and any URL
+ * that isn't a `/torznab/download/.torrent` link.
+ */
+ private parseJackUrl(url: string): { peerId: string, itemId: string } | null {
+ if (url.startsWith('magnet:'))
+ return null
+ let parsed: URL
+ try {
+ parsed = new URL(url)
+ }
+ catch {
+ return null
+ }
+ const match = parsed.pathname.match(JACK_DOWNLOAD_PATH)
+ if (!match || !match[1])
+ return null
+ const guid = decodeURIComponent(match[1])
+ const [peerId, ...rest] = guid.split(':')
+ const itemId = rest.join(':')
+ if (!peerId || !itemId)
+ return null
+ return { peerId, itemId }
+ }
+
+ // Session-scoped: only ever touch rows added by the calling server. Because a
+ // shared release yields the SAME infohash across servers, an unscoped delete
+ // could remove another server's (or a blackhole) row.
+ async deleteTorrents(session: QbSession, hashesParam: string, deleteFiles: boolean): Promise {
+ const mine = (r: DownloadRecord) => r.qbSourceServer === session.serverName
+ const records = hashesParam === 'all'
+ ? this.deps.repository.list().filter(mine)
+ : hashesParam.split('|').flatMap(h => this.findAllByHash(h)).filter(mine)
+ for (const record of records) {
+ if (deleteFiles) {
+ await unlink(record.destPath).catch(() => {})
+ await unlink(record.partPath).catch(() => {})
+ }
+ this.deps.repository.delete(record.id)
+ }
+ }
+
+ setCategory(session: QbSession, hashes: string[], category: string): void {
+ for (const hash of hashes) {
+ for (const record of this.findAllByHash(hash)) {
+ if (record.qbSourceServer === session.serverName)
+ this.deps.repository.setQbCategory(record.id, category)
+ }
+ }
+ }
+}
diff --git a/apps/backend/src/modules/qbittorrent/qbittorrent.mapper.ts b/apps/backend/src/modules/qbittorrent/qbittorrent.mapper.ts
new file mode 100644
index 0000000..e00eb51
--- /dev/null
+++ b/apps/backend/src/modules/qbittorrent/qbittorrent.mapper.ts
@@ -0,0 +1,107 @@
+import type { DownloadStatus } from '../../database/schema'
+import type { DownloadRecord } from '../downloads/downloads.repository'
+import { getStubInfoHash } from '../torznab/torrent'
+
+/**
+ * The torrent's real BitTorrent infohash. jack has no peer wire, but *arr
+ * computes this hash from the stub it grabbed and matches torrents/info by it,
+ * so it MUST equal the served stub's infohash -- derive it from the same
+ * (release title, size) the stub was built from, NOT from peerId:itemId.
+ */
+export function deriveHash(name: string, size: number): string {
+ return getStubInfoHash(name, size)
+}
+
+/**
+ * The qB category string jack assigns to a destination server. Unique per
+ * server so two same-type *arr instances never see each other's torrents.
+ */
+export function qbCategoryForServer(serverId: string): string {
+ return `jack-${serverId}`
+}
+
+export type QbState = 'downloading' | 'pausedUP' | 'error'
+
+const ETA_UNKNOWN = 8_640_000 // qB's "unknown ETA" sentinel (= 100 days); *arr recognises this specific value as "no ETA"
+
+function mapState(status: DownloadStatus): QbState {
+ switch (status) {
+ case 'completed':
+ case 'import_queued':
+ return 'pausedUP' // finished → *arr marks Completed and imports from content_path
+ case 'failed':
+ return 'error'
+ case 'downloading':
+ default:
+ return 'downloading'
+ }
+}
+
+function toEpoch(iso: string | null): number {
+ if (!iso)
+ return 0
+ const ms = Date.parse(iso)
+ return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000)
+}
+
+export interface QbTorrent {
+ hash: string
+ name: string
+ size: number
+ total_size: number
+ progress: number
+ eta: number
+ state: QbState
+ category: string
+ tags: string
+ save_path: string
+ content_path: string
+ ratio: number
+ ratio_limit: number
+ seeding_time_limit: number
+ amount_left: number
+ completed: number
+ completion_on: number
+ added_on: number
+ dlspeed: number
+ upspeed: number
+ num_seeds: number
+ num_complete: number
+ num_leechs: number
+ num_incomplete: number
+}
+
+export function toQbTorrent(record: DownloadRecord, opts: { completedPath: string, category: string }): QbTorrent {
+ const size = record.expectedBytes ?? record.releaseSize
+ const isDone = record.status === 'completed' || record.status === 'import_queued'
+ const progress = isDone ? 1 : (size > 0 ? Math.min(record.downloadedBytes / size, 1) : 0)
+ const amountLeft = isDone ? 0 : Math.max(size - record.downloadedBytes, 0)
+ return {
+ // Real stub infohash (from release title + size), NOT peerId:itemId — *arr
+ // matches torrents/info to the hash it computed from the grabbed .torrent.
+ hash: deriveHash(record.release.title, record.releaseSize),
+ name: record.filename,
+ size,
+ total_size: size,
+ progress,
+ eta: isDone ? 0 : ETA_UNKNOWN,
+ state: mapState(record.status),
+ category: opts.category,
+ tags: '',
+ save_path: opts.completedPath,
+ content_path: record.destPath, // must differ from save_path or *arr warns "path error"
+ ratio: 0,
+ ratio_limit: -2,
+ seeding_time_limit: -2,
+ amount_left: amountLeft,
+ completed: isDone ? size : record.downloadedBytes,
+ completion_on: toEpoch(record.completedAt),
+ added_on: toEpoch(record.startedAt),
+ dlspeed: 0,
+ upspeed: 0,
+ num_seeds: 1,
+ num_complete: 1,
+ num_leechs: 0,
+ num_incomplete: 0,
+ }
+}
diff --git a/apps/backend/src/modules/qbittorrent/qbittorrent.router.ts b/apps/backend/src/modules/qbittorrent/qbittorrent.router.ts
new file mode 100644
index 0000000..9834df0
--- /dev/null
+++ b/apps/backend/src/modules/qbittorrent/qbittorrent.router.ts
@@ -0,0 +1,116 @@
+import type { QbittorrentController } from './qbittorrent.controller'
+import type { QbSession } from './qbittorrent.session'
+import { Hono } from 'hono'
+import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
+import { createMiddleware } from 'hono/factory'
+
+const SID_COOKIE = 'SID'
+
+// Splits a multiline `urls` field into individual entries (CRLF or LF).
+const NEWLINE = /\r?\n/
+
+export function getQbittorrentRouter(controller: QbittorrentController) {
+ const app = new Hono<{ Variables: { qbSession: QbSession } }>()
+
+ // ---- Public: auth (qB returns "Ok."/"Fails." as text) ----
+ app.post('/auth/login', async (c) => {
+ const body = await c.req.parseBody()
+ const sid = controller.login(String(body.username ?? ''), String(body.password ?? ''))
+ if (!sid)
+ return c.text('Fails.', 200)
+ setCookie(c, SID_COOKIE, sid, { path: '/', httpOnly: true, sameSite: 'Strict' })
+ return c.text('Ok.', 200)
+ })
+
+ app.post('/auth/logout', (c) => {
+ controller.logout(getCookie(c, SID_COOKIE))
+ deleteCookie(c, SID_COOKIE, { path: '/' })
+ return c.text('Ok.', 200)
+ })
+
+ // ---- SID guard: qB returns 403 (not 401) when unauthenticated ----
+ const requireSession = createMiddleware<{ Variables: { qbSession: QbSession } }>(async (c, next) => {
+ const session = controller.sessions.get(getCookie(c, SID_COOKIE))
+ if (!session)
+ return c.text('Forbidden', 403)
+ c.set('qbSession', session)
+ await next()
+ })
+ app.use('/app/*', requireSession)
+ app.use('/torrents/*', requireSession)
+
+ // ---- app ----
+ app.get('/app/webapiVersion', c => c.text(controller.webapiVersion()))
+ app.get('/app/version', c => c.text(controller.version()))
+ app.get('/app/preferences', c => c.json(controller.preferences()))
+
+ // ---- torrents (connection-test surface; Phase 2 fills info with real data) ----
+ app.get('/torrents/info', (c) => {
+ const category = c.req.query('category') ?? undefined
+ const hashesRaw = c.req.query('hashes')
+ const hashes = hashesRaw ? hashesRaw.split('|') : undefined
+ return c.json(controller.torrentsInfo({ category, hashes }))
+ })
+ app.get('/torrents/properties', (c) => {
+ const props = controller.torrentProperties(c.req.query('hash') ?? '')
+ if (!props)
+ return c.body(null, 404)
+ return c.json(props)
+ })
+ app.get('/torrents/files', c => c.json(controller.torrentFiles(c.req.query('hash') ?? '')))
+ app.get('/torrents/categories', c => c.json(controller.categories()))
+
+ app.post('/torrents/add', async (c) => {
+ const session = c.get('qbSession')
+ const body = await c.req.parseBody({ all: true })
+
+ // Normalize: parseBody({ all: true }) returns string | File | Array of those.
+ const asList = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null ? [] : [v])
+ const firstString = (v: unknown): string | undefined => asList(v).find((x): x is string => typeof x === 'string')
+
+ const urls = asList(body.urls)
+ .flatMap(v => (typeof v === 'string' ? v.split(NEWLINE) : []))
+ .map(s => s.trim())
+ .filter(Boolean)
+
+ const torrentFiles: Uint8Array[] = []
+ for (const f of asList(body.torrents)) {
+ if (f instanceof File)
+ torrentFiles.push(new Uint8Array(await f.arrayBuffer()))
+ }
+
+ const category = firstString(body.category)
+ const result = await controller.addTorrent({ session, category, urls, torrentFiles })
+ if (result === 'unavailable')
+ return c.text('Download pipeline unavailable: jack has no downloads config.', 503)
+ if (result === 'failed')
+ return c.text('Failed to start download. Retry later.', 503)
+ if (result === 'unsupported')
+ return c.text('Unsupported torrent. Only Jack releases are accepted.', 415)
+ return c.text('Ok.', 200)
+ })
+
+ app.post('/torrents/delete', async (c) => {
+ const session = c.get('qbSession')
+ const body = await c.req.parseBody()
+ await controller.deleteTorrents(session, String(body.hashes ?? ''), String(body.deleteFiles ?? 'false') === 'true')
+ return c.text('Ok.', 200)
+ })
+
+ app.post('/torrents/setCategory', async (c) => {
+ const session = c.get('qbSession')
+ const body = await c.req.parseBody()
+ controller.setCategory(session, String(body.hashes ?? '').split('|').filter(Boolean), String(body.category ?? ''))
+ return c.text('Ok.', 200)
+ })
+
+ app.post('/torrents/createCategory', c => c.text('Ok.', 200))
+
+ // Best-effort: *arr issues these for priority/seeding; jack doesn't seed, so
+ // acknowledge and no-op (real qB returns 200 here too).
+ app.post('/torrents/setShareLimits', c => c.text('Ok.', 200))
+ app.post('/torrents/topPrio', c => c.text('Ok.', 200))
+ app.post('/torrents/setForceStart', c => c.text('Ok.', 200))
+
+ return app
+}
diff --git a/apps/backend/src/modules/qbittorrent/qbittorrent.session.ts b/apps/backend/src/modules/qbittorrent/qbittorrent.session.ts
new file mode 100644
index 0000000..6332be5
--- /dev/null
+++ b/apps/backend/src/modules/qbittorrent/qbittorrent.session.ts
@@ -0,0 +1,49 @@
+export interface QbSession {
+ serverName: string
+ serverId: string
+}
+
+const SESSION_TTL_MS = 60 * 60 * 1000 // 1h, refreshed on each successful login
+
+export class QbSessionStore {
+ private readonly sessions = new Map()
+
+ create(session: QbSession): string {
+ // Sweep expired entries on each login so abandoned SIDs don't accumulate
+ // (get() only evicts lazily on access). Logins are infrequent, so the O(n)
+ // pass is cheap.
+ this.sweep()
+ const sid = new Bun.CryptoHasher('sha256').update(crypto.randomUUID()).digest('hex')
+ this.sessions.set(sid, { session, expiresAt: Date.now() + SESSION_TTL_MS })
+ return sid
+ }
+
+ private sweep(): void {
+ const now = Date.now()
+ for (const [sid, entry] of this.sessions) {
+ if (entry.expiresAt < now)
+ this.sessions.delete(sid)
+ }
+ }
+
+ get(sid: string | undefined): QbSession | null {
+ if (!sid)
+ return null
+ const entry = this.sessions.get(sid)
+ if (!entry)
+ return null
+ if (entry.expiresAt < Date.now()) {
+ this.sessions.delete(sid)
+ return null
+ }
+ // Refresh TTL on each successful access, matching real qBittorrent: *arr
+ // polls continuously, so an active SID must not expire mid-session.
+ entry.expiresAt = Date.now() + SESSION_TTL_MS
+ return entry.session
+ }
+
+ delete(sid: string | undefined): void {
+ if (sid)
+ this.sessions.delete(sid)
+ }
+}
diff --git a/apps/backend/src/modules/torznab/torrent.ts b/apps/backend/src/modules/torznab/torrent.ts
index b27ce26..f81fc3e 100644
--- a/apps/backend/src/modules/torznab/torrent.ts
+++ b/apps/backend/src/modules/torznab/torrent.ts
@@ -18,22 +18,35 @@ function getPieceCount(size: number): number {
return Math.ceil(size / PIECE_LENGTH)
}
-export function createTorrentStub(options: TorrentStubOptions): Buffer {
- const pieces = Buffer.alloc(getPieceCount(options.size) * SHA1_HASH_LENGTH)
+// Shared so the served stub and the reported hash bencode the SAME info dict.
+function buildStubInfo(name: string, size: number) {
+ return {
+ 'name': Buffer.from(name),
+ 'piece length': PIECE_LENGTH,
+ 'length': size,
+ 'pieces': Buffer.alloc(getPieceCount(size) * SHA1_HASH_LENGTH),
+ }
+}
+export function createTorrentStub(options: TorrentStubOptions): Buffer {
const torrent = {
- info: {
- 'name': Buffer.from(options.name),
- 'piece length': PIECE_LENGTH,
- 'length': options.size,
- 'pieces': pieces, // Dummy hashes. Radarr validates count/shape before writing to blackhole.
- },
+ info: buildStubInfo(options.name, options.size),
comment: Buffer.from(`jack:${options.peerId}:${options.itemId}`),
}
-
return Buffer.from(bencode.encode(torrent))
}
+/**
+ * The stub's BitTorrent v1 infohash (lowercase 40-hex) = sha1(bencode(info)).
+ * arr computes this same hash from the .torrent it grabbed and matches
+ * torrents/info by it, so jack MUST report exactly this. bencode is
+ * deterministic (sorted keys), so re-encoding the same info dict reproduces the
+ * bytes *arr hashed.
+ */
+export function getStubInfoHash(name: string, size: number): string {
+ return new Bun.CryptoHasher('sha1').update(bencode.encode(buildStubInfo(name, size))).digest('hex')
+}
+
export function parseTorrentStub(data: Buffer): { peerId: string, itemId: string } | null {
try {
const torrent = bencode.decode(data) as any
diff --git a/bun.lock b/bun.lock
index 1523e86..0959bdb 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,7 +14,7 @@
"eslint": "10.1.0",
},
"peerDependencies": {
- "typescript": "^5",
+ "typescript": "^6.0.3",
},
},
"apps/backend": {
@@ -1023,7 +1023,7 @@
"type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="],
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
diff --git a/e2e/.gitignore b/e2e/.gitignore
index 24610d0..b0de9d3 100644
--- a/e2e/.gitignore
+++ b/e2e/.gitignore
@@ -2,3 +2,4 @@ config/jack-alpha.jsonc
config/jack-beta.jsonc
config/test-env.json
volumes/
+config/database.sqlite*
diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml
index b38af36..7a112b8 100644
--- a/e2e/docker-compose.yml
+++ b/e2e/docker-compose.yml
@@ -9,7 +9,6 @@ services:
volumes:
- radarr-config:/config
- ./fixtures/media:/media
- - ./volumes/blackhole-watch:/downloads/watch
- ./volumes/blackhole-completed:/downloads/completed
healthcheck:
test: [CMD, curl, -f, 'http://localhost:7878/ping']
@@ -28,7 +27,6 @@ services:
volumes:
- sonarr-config:/config
- ./fixtures/media:/media
- - ./volumes/blackhole-watch:/downloads/watch
- ./volumes/blackhole-completed:/downloads/completed
healthcheck:
test: [CMD, curl, -f, 'http://localhost:8989/ping']
@@ -74,7 +72,6 @@ services:
- PORT=3000
volumes:
- ./config:/config
- - ./volumes/blackhole-watch:/downloads/watch
- ./volumes/blackhole-completed:/downloads/completed
- ./fixtures/media:/media:ro
depends_on:
diff --git a/e2e/setup.ts b/e2e/setup.ts
index c7b9e78..07c5446 100644
--- a/e2e/setup.ts
+++ b/e2e/setup.ts
@@ -120,7 +120,7 @@ async function writeJackConfigs(radarrApiKey: string, sonarrApiKey: string) {
// jack-beta searches jack-alpha and registers itself into its own Radarr.
const betaConfig = {
jack: { baseUrl: 'http://jack-beta:3000', apiKey: 'beta-test-key' },
- downloads: { watchPath: '/downloads/watch', completedPath: '/downloads/completed' },
+ downloads: { completedPath: '/downloads/completed' },
servers: [
{ type: 'radarr', url: 'http://radarr:7878', apiKey: radarrApiKey, name: 'Test Radarr', source: false, destination: true },
],
diff --git a/e2e/tests/auto-registration.test.ts b/e2e/tests/auto-registration.test.ts
index cecbc17..ac2ad6c 100644
--- a/e2e/tests/auto-registration.test.ts
+++ b/e2e/tests/auto-registration.test.ts
@@ -9,7 +9,7 @@ beforeAll(async () => {
})
describe('Auto-registration (e2e)', () => {
- test('Jack Alpha does NOT register as indexer (no peers = nothing to search)', async () => {
+ test('Jack Alpha does NOT register as indexer (source-only: no destination servers)', async () => {
const indexers = await fetchJson }>>(
`${env.radarrUrl}/api/v3/indexer`,
{ headers: { 'X-Api-Key': env.radarrApiKey } },
@@ -40,7 +40,7 @@ describe('Auto-registration (e2e)', () => {
expect(jackIndexer.name).toBe('Jack')
})
- test('Jack Beta registered as Torrent Blackhole download client in Radarr', async () => {
+ test('Jack Beta registered as qBittorrent download client in Radarr', async () => {
const jackClient = await retry(async () => {
const clients = await fetchJson }>>(
`${env.radarrUrl}/api/v3/downloadclient`,
@@ -48,7 +48,7 @@ describe('Auto-registration (e2e)', () => {
)
const registered = clients.find(client =>
- client.fields?.some(f => f.name === 'torrentFolder' && f.value === '/downloads/watch'),
+ client.name === 'Jack' && client.implementation === 'QBittorrent',
)
if (!registered)
throw new Error('Jack Beta download client is not registered yet')
@@ -57,6 +57,45 @@ describe('Auto-registration (e2e)', () => {
}, { retries: 30, delay: 1_000 })
expect(jackClient.name).toBe('Jack')
- expect(jackClient.implementation).toBe('TorrentBlackhole')
+ expect(jackClient.implementation).toBe('QBittorrent')
+ // Points at jack-beta's own /api/v2 (host from jack.baseUrl http://jack-beta:3000).
+ const host = jackClient.fields?.find(f => f.name === 'host')?.value
+ const port = jackClient.fields?.find(f => f.name === 'port')?.value
+ expect(host).toBe('jack-beta')
+ expect(port).toBe(3000)
+ })
+
+ test('Jack indexer is BOUND to the Jack qBittorrent download client', async () => {
+ const headers = { 'X-Api-Key': env.radarrApiKey }
+
+ // The Jack qBittorrent client's id.
+ const clients = await retry(async () => {
+ const list = await fetchJson>(
+ `${env.radarrUrl}/api/v3/downloadclient`,
+ { headers },
+ )
+ const jack = list.find(c => c.name === 'Jack' && c.implementation === 'QBittorrent')
+ if (!jack)
+ throw new Error('Jack qBittorrent client not registered yet')
+ return jack
+ }, { retries: 30, delay: 1_000 })
+
+ // The Jack indexer must point its Download Client at exactly that client
+ // (downloadClientId), not 0/"Any" — otherwise *arr may route a Jack grab to
+ // an unrelated client. This is the regression guard for the binding bug.
+ const indexer = await retry(async () => {
+ const list = await fetchJson }>>(
+ `${env.radarrUrl}/api/v3/indexer`,
+ { headers },
+ )
+ const jack = list.find(idx =>
+ idx.fields?.some(f => f.name === 'baseUrl' && String(f.value).includes('jack-beta')),
+ )
+ if (!jack)
+ throw new Error('Jack indexer not registered yet')
+ return jack
+ }, { retries: 30, delay: 1_000 })
+
+ expect(indexer.downloadClientId).toBe(clients.id)
})
})
diff --git a/e2e/tests/download-flow.test.ts b/e2e/tests/download-flow.test.ts
deleted file mode 100644
index ef74306..0000000
--- a/e2e/tests/download-flow.test.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import type { TestEnv } from '../helpers'
-import { readdir } from 'node:fs/promises'
-import { join } from 'node:path'
-import { beforeAll, describe, expect, test } from 'bun:test'
-import { getTestEnv, retry } from '../helpers'
-
-let env: TestEnv
-
-const BLACKHOLE_WATCH = join(import.meta.dir, '..', 'volumes', 'blackhole-watch')
-const BLACKHOLE_COMPLETED = join(import.meta.dir, '..', 'volumes', 'blackhole-completed')
-
-beforeAll(async () => {
- env = await getTestEnv()
-})
-
-describe('Download flow (e2e)', () => {
- test('Full blackhole download: search → torrent → download → import', async () => {
- // 1. Search via jack-beta's Torznab API
- const searchRes = await fetch(`${env.jackBetaUrl}/torznab/api?t=search&apikey=${env.jackBetaApiKey}`)
- expect(searchRes.status).toBe(200)
- const xml = await searchRes.text()
-
- // 2. Extract the download URL from the RSS XML
- const enclosureMatch = xml.match(/url="([^"]*\.torrent[^"]*)"/)
- expect(enclosureMatch).not.toBeNull()
-
- // The URL in the XML will have the docker-internal host, replace with localhost
- const downloadUrl = enclosureMatch![1]!
- .replace('http://jack-beta:3000', env.jackBetaUrl)
- .replace(/&/g, '&')
-
- expect(new URL(downloadUrl).searchParams.get('apikey')).toBe(env.jackBetaApiKey)
-
- // 3. Download the .torrent stub
- const torrentRes = await fetch(downloadUrl)
- expect(torrentRes.status).toBe(200)
- expect(torrentRes.headers.get('Content-Type')).toBe('application/x-bittorrent')
- const torrentData = await torrentRes.arrayBuffer()
- expect(torrentData.byteLength).toBeGreaterThan(0)
-
- // 4. Write the .torrent to the blackhole watch directory
- const torrentPath = join(BLACKHOLE_WATCH, 'test-download.torrent')
- await Bun.write(torrentPath, torrentData)
-
- // 5. Wait for jack-beta's BlackholeWatcher to process it
- const completedFiles = await retry(async () => {
- const files = await readdir(BLACKHOLE_COMPLETED)
- const mediaFiles = files.filter(f => !f.endsWith('.torrent'))
- if (mediaFiles.length === 0)
- throw new Error('No completed files yet')
- return mediaFiles
- }, { retries: 30, delay: 2_000 })
-
- // 6. Verify a file was downloaded to completed
- expect(completedFiles.length).toBeGreaterThan(0)
-
- // 7. Verify the .torrent was cleaned up from watch dir
- const watchFiles = await readdir(BLACKHOLE_WATCH)
- const remainingTorrents = watchFiles.filter(f => f === 'test-download.torrent')
- expect(remainingTorrents.length).toBe(0)
- }, 120_000) // 2 minute timeout for the full flow
-})
diff --git a/e2e/tests/qbittorrent-flow.test.ts b/e2e/tests/qbittorrent-flow.test.ts
new file mode 100644
index 0000000..79aeb9b
--- /dev/null
+++ b/e2e/tests/qbittorrent-flow.test.ts
@@ -0,0 +1,97 @@
+import type { TestEnv } from '../helpers'
+import { readdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import { beforeAll, describe, expect, test } from 'bun:test'
+import { fetchJson, getTestEnv, retry } from '../helpers'
+
+let env: TestEnv
+
+const BLACKHOLE_COMPLETED = join(import.meta.dir, '..', 'volumes', 'blackhole-completed')
+
+// jack-beta's destination *arr connector name (see e2e/setup.ts → betaConfig).
+const DEST_CONNECTOR_NAME = 'Test Radarr'
+// The docker-internal URL jack-beta uses for that *arr; the category is derived
+// from it the same way jack does (sha256(url).slice(0, 8)).
+const DEST_INTERNAL_URL = 'http://radarr:7878'
+
+// Mirror ServerConnector's id derivation (lib/servers/base.ts).
+function serverId(url: string): string {
+ return new Bun.CryptoHasher('sha256').update(url).digest('hex').slice(0, 8)
+}
+
+function qbCategory(url: string): string {
+ return `jack-${serverId(url)}`
+}
+
+beforeAll(async () => {
+ env = await getTestEnv()
+})
+
+describe('qBittorrent flow (e2e)', () => {
+ test('qB path: login → add → progress → import, and *arr lists the qB client', async () => {
+ const jack = env.jackBetaUrl
+ const category = qbCategory(DEST_INTERNAL_URL)
+
+ // 1. Log in to jack's qBittorrent API as the destination connector.
+ // username = connector name, password = jack-beta's apiKey.
+ const loginRes = await fetch(`${jack}/api/v2/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ username: DEST_CONNECTOR_NAME, password: env.jackBetaApiKey }),
+ })
+ expect(loginRes.status).toBe(200)
+ expect(await loginRes.text()).toBe('Ok.')
+ const cookie = (loginRes.headers.get('set-cookie') ?? '').split(';')[0] ?? ''
+ expect(cookie).toContain('SID=')
+
+ // 2. Discover a Jack release URL via the Torznab search jack-beta exposes.
+ const searchRes = await fetch(`${jack}/torznab/api?t=search&apikey=${env.jackBetaApiKey}`)
+ expect(searchRes.status).toBe(200)
+ const xml = await searchRes.text()
+ const enclosureMatch = xml.match(/url="([^"]*\.torrent[^"]*)"/)
+ expect(enclosureMatch).not.toBeNull()
+ const downloadUrl = enclosureMatch![1]!
+ .replace('http://jack-beta:3000', jack)
+ .replace(/&/g, '&')
+
+ // 3. Add it through the qB API (multipart: urls = jack's own stub URL).
+ const addForm = new FormData()
+ addForm.append('urls', downloadUrl)
+ addForm.append('category', category)
+ const addRes = await fetch(`${jack}/api/v2/torrents/add`, {
+ method: 'POST',
+ headers: { cookie },
+ body: addForm,
+ })
+ expect(addRes.status).toBe(200)
+ expect(await addRes.text()).toBe('Ok.')
+
+ // 4. Poll torrents/info until the torrent finishes (state pausedUP, progress 1).
+ const finished = await retry(async () => {
+ const infoRes = await fetch(`${jack}/api/v2/torrents/info?category=${encodeURIComponent(category)}`, {
+ headers: { cookie },
+ })
+ if (infoRes.status !== 200)
+ throw new Error(`torrents/info ${infoRes.status}`)
+ const torrents = await infoRes.json() as Array<{ name: string, state: string, progress: number }>
+ const done = torrents.find(t => t.state === 'pausedUP' && t.progress === 1)
+ if (!done)
+ throw new Error(`no completed torrent yet (have ${JSON.stringify(torrents.map(t => ({ s: t.state, p: t.progress })))})`)
+ return done
+ }, { retries: 30, delay: 2_000 })
+
+ expect(finished.state).toBe('pausedUP')
+ expect(finished.progress).toBe(1)
+
+ // 5. The completed media file exists in jack's completedPath.
+ const completedFiles = (await readdir(BLACKHOLE_COMPLETED)).filter(f => !f.endsWith('.torrent'))
+ expect(completedFiles.length).toBeGreaterThan(0)
+
+ // 6. The destination *arr lists a QBittorrent download client.
+ const clients = await fetchJson>(
+ `${env.radarrUrl}/api/v3/downloadclient`,
+ { headers: { 'X-Api-Key': env.radarrApiKey } },
+ )
+ expect(clients.some(client => client.implementation === 'QBittorrent')).toBe(true)
+ }, 120_000)
+})
diff --git a/examples/compose-with-otel.yml b/examples/compose-with-otel.yml
index 1ae5c81..19b6304 100644
--- a/examples/compose-with-otel.yml
+++ b/examples/compose-with-otel.yml
@@ -67,12 +67,21 @@ services:
# *arr's media path(s), and add one mount per path if they use several
# (e.g. /movies and /tv).
- ${MEDIA_PATH:-./data/media}:/data/media
- # Blackhole download dirs — must match `downloads.*` in config.jsonc.
- # IMPORTANT: mount this SAME folder into your Radarr and Sonarr containers
- # at the SAME path (e.g. /data/torrents). jack registers the Torrent
- # Blackhole client with these literal paths, and *arr resolves them in its
- # own filesystem — if they don't match, *arr can't drop stubs or import.
+ # Completed downloads dir — must match `downloads.completedPath` in
+ # config.jsonc. IMPORTANT: mount this SAME folder into your Radarr and
+ # Sonarr containers at the SAME path (e.g. /data/torrents). jack writes
+ # finished files here and *arr resolves that literal path in its own
+ # filesystem to import them — if they don't match, *arr can't import.
+ # (No watch folder: *arr hands grabs to jack over the qBittorrent API.)
- ${TORRENTS_PATH:-./data/torrents}:/data/torrents
+ # If Radarr / Sonarr are defined in this same compose file, wait for them to
+ # be healthy before starting jack. Auto-registration only runs at startup, so
+ # this makes the Torznab indexer + qBittorrent client register on first boot
+ # instead of needing a jack restart. (Needs `healthcheck` blocks on those
+ # services — the linuxserver.io images ship with them.)
+ # depends_on:
+ # radarr: {condition: service_healthy}
+ # sonarr: {condition: service_healthy}
# If Radarr / Sonarr run in their own compose network, attach this service
# to it so `jack.baseUrl` / server URLs resolve by container name.
# Otherwise reach them via host IP.
diff --git a/examples/config.jsonc b/examples/config.jsonc
index cf832c1..a1e8481 100644
--- a/examples/config.jsonc
+++ b/examples/config.jsonc
@@ -21,10 +21,10 @@
"apiKey": { "env": "JACK_API_KEY" }
},
- // Blackhole watcher: drops .torrent files for your client to pick up,
- // and moves finished downloads. Paths are *inside the container*.
+ // Downloads: jack registers itself in your *arr as a qBittorrent download
+ // client, so grabs are handed to jack directly (no watch folder). Finished
+ // files are written here for your *arr to import. Path is *inside the container*.
"downloads": {
- "watchPath": "/data/torrents/watch",
"completedPath": "/data/torrents/completed",
// Optional download hardening knobs — values below are the defaults, so you
// can delete any line to keep the default. (Note the comma after
@@ -39,10 +39,13 @@
// chars (Settings -> General). Each server can be a:
// - "source": its library is shared with your peers (read via /peer).
// - "destination": jack registers itself there as a Torznab indexer +
- // Torrent Blackhole download client, and triggers imports.
+ // qBittorrent download client, and triggers imports.
// - both: share your library AND search your friends', symmetric.
// "autoregister" controls the indexer/client registration (destinations only):
// { "enable": true, "priority": 1 }
+ // The qBittorrent client is always registered at *arr's lowest priority so real
+ // torrents from your other indexers never get routed to jack (Jack grabs still
+ // reach it via the indexer binding, which *arr resolves before priority).
"servers": [
// {
// "name": "Main Radarr",
diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml
index ff65c75..fb2624b 100644
--- a/examples/docker-compose.yml
+++ b/examples/docker-compose.yml
@@ -23,12 +23,21 @@ services:
# *arr's media path(s), and add one mount per path if they use several
# (e.g. /movies and /tv).
- ${MEDIA_PATH:-./data/media}:/data/media
- # Blackhole download dirs — must match `downloads.*` in config.jsonc.
- # IMPORTANT: mount this SAME folder into your Radarr and Sonarr containers
- # at the SAME path (e.g. /data/torrents). jack registers the Torrent
- # Blackhole client with these literal paths, and *arr resolves them in its
- # own filesystem — if they don't match, *arr can't drop stubs or import.
+ # Completed downloads dir — must match `downloads.completedPath` in
+ # config.jsonc. IMPORTANT: mount this SAME folder into your Radarr and
+ # Sonarr containers at the SAME path (e.g. /data/torrents). jack writes
+ # finished files here and *arr resolves that literal path in its own
+ # filesystem to import them — if they don't match, *arr can't import.
+ # (No watch folder: *arr hands grabs to jack over the qBittorrent API.)
- ${TORRENTS_PATH:-./data/torrents}:/data/torrents
+ # If Radarr / Sonarr are defined in this same compose file, wait for them to
+ # be healthy before starting jack. Auto-registration only runs at startup, so
+ # this makes the Torznab indexer + qBittorrent client register on first boot
+ # instead of needing a jack restart. (Needs `healthcheck` blocks on those
+ # services — the linuxserver.io images ship with them.)
+ # depends_on:
+ # radarr: {condition: service_healthy}
+ # sonarr: {condition: service_healthy}
# If Radarr / Sonarr run in their own compose network, attach this service
# to it so `jack.baseUrl` / server URLs resolve by container name.
# Otherwise reach them via host IP.
diff --git a/mise-tasks/test/e2e b/mise-tasks/test/e2e
index 0f6085b..3b6c97f 100755
--- a/mise-tasks/test/e2e
+++ b/mise-tasks/test/e2e
@@ -11,12 +11,12 @@ docker compose -f "$E2E_DIR/docker-compose.yml" down -v 2>/dev/null || true
rm -f "$E2E_DIR/config/jack-alpha.jsonc" "$E2E_DIR/config/jack-beta.jsonc" "$E2E_DIR/config/test-env.json"
rm -f "$E2E_DIR/config/database.sqlite" "$E2E_DIR/config/database.sqlite-shm" "$E2E_DIR/config/database.sqlite-wal"
rm -rf "$E2E_DIR/volumes"
-mkdir -p "$E2E_DIR/volumes/blackhole-watch" "$E2E_DIR/volumes/blackhole-completed"
+mkdir -p "$E2E_DIR/volumes/blackhole-completed"
mkdir -p "$E2E_DIR/config"
# jack and the *arr containers run as uid 1000; the CI runner that creates these
# dirs may be a different uid. Make them world-writable so both can read/write
-# the blackhole watch/completed folders.
-chmod 777 "$E2E_DIR/volumes/blackhole-watch" "$E2E_DIR/volumes/blackhole-completed"
+# the completed-downloads folder.
+chmod 777 "$E2E_DIR/volumes/blackhole-completed"
# jack also creates database.sqlite next to its mounted config file on startup.
chmod 777 "$E2E_DIR/config"
# Same uid mismatch applies to the media fixtures: Radarr (uid 1000) must be able
diff --git a/package.json b/package.json
index 8f76568..2cb0094 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"private": true,
"license": "GPL-3.0-only",
"peerDependencies": {
- "typescript": "^5"
+ "typescript": "^6.0.3"
},
"workspaces": [
"packages/*",
diff --git a/tsconfig.json b/tsconfig.json
index bb65c37..dc421e5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,7 +9,7 @@
// Bundler mode
"moduleResolution": "bundler",
- "types": ["@types/bun"],
+ "types": ["bun"],
"allowImportingTsExtensions": true,
"allowJs": true,