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,