jack lets you and your friends share media libraries with each other through the *arr stack you already run. You point Radarr/Sonarr at jack, search like you would on any indexer, and when a friend has the movie or episode you want, jack pulls it straight from their server into your library — no public trackers, no BitTorrent swarm, just a private peer-to-peer bridge between your media servers.
- Concepts
- Quick start (Docker Compose)
- How it works
- The API key
- Configuration
- Environment variables
- Health check
- CLI and local API
- Running without Docker
- Troubleshooting
- Development
- Project layout
jack sits between three kinds of servers. You only need to configure the ones relevant to what you want to do (share, consume, or both).
jack talks to Radarr/Sonarr for everything — there's no separate media
server. Each server you configure is one entry in servers, with two role
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 + 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
destination: true (share your library and search your friends'), plus some
peers (friends, to consume from).
Torznab is the search API that indexers speak to Radarr/Sonarr (the same protocol Prowlarr/Jackett expose). jack pretends to be a Torznab indexer so your *arr apps can search your friends' libraries with zero special setup — to them, jack looks like any other indexer.
Running with Docker Compose is the recommended way to self-host jack. The image
is published to GitHub Container Registry (ghcr.io/roziscoding/jack:main) on
every push to main, so you don't need to clone the repo — just grab
examples/docker-compose.yml and
examples/config.jsonc and drop them in a folder:
# 1. Create your config from the template
mkdir -p config
cp config.jsonc config/config.jsonc # the template you downloaded
$EDITOR config/config.jsonc # fill in your servers (see below)
# 2. Pull and run
docker compose up -d
# 3. Watch the logs
docker compose logs -fYou should see Server listening and, if you configured destinations,
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:
| Mount | Purpose | Related config |
|---|---|---|
./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 |
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 thenetworks:block in the compose file so jack joins it, and setjack.baseUrlto 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 literaldownloads.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 (movieFile.path/episodeFile.path) — i.e. the path inside the Radarr/Sonarr container. Mount your media into jack at that same path (you may need to mirror more than one, e.g./moviesand/tv). The/data/mediain the example is just a placeholder — replace it with whatever paths your *arr use. Migrating from the Jellyfin-based version? This path likely changed: it's now the *arr path, not Jellyfin's library path.
There are two flows: searching for media (Torznab) and downloading it
(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.
sequenceDiagram
participant ARR as Radarr / Sonarr (you)
participant JACK as jack (you)
participant FJACK as jack (friend)
participant FARR as Radarr/Sonarr (friend)
ARR->>JACK: Torznab query /torznab/api?apikey=…
JACK->>FJACK: /peer/search (X-Api-Key)
FJACK->>FARR: search library (movies/episodes with files)
FARR-->>FJACK: matching releases
FJACK-->>JACK: results
JACK-->>ARR: releases (stub .torrent)<br/>link → /torznab/download/{peerId}:{itemId}.torrent
- On startup jack registers itself as a Torznab indexer in each
destinationserver (Radarr/Sonarr), usingjack.baseUrl+jack.apiKey, and registers a qBittorrent download client pointing back at jack's own qBittorrent API (jack.baseUrl, only ifdownloadsis configured). (Auto, unless you set that server'sautoregister.enable: false.) - When you search or monitor something, Radarr/Sonarr query jack's
/torznabendpoint with that API key. - jack fans the query out to every
peeryou've configured, calling their/peer/search(authenticated with that peer's API key). - Each peer searches its own Radarr/Sonarr library (movies/episodes that have files) and returns matching releases, mirroring the *arr file metadata.
- jack turns each match into a Torznab "release" whose download link points
back at itself:
/torznab/download/<peerId>:<itemId>.torrent. - Radarr/Sonarr show these as grabbable releases — indistinguishable from a normal indexer's results.
sequenceDiagram
participant ARR as Radarr / Sonarr (you)
participant JACK as jack (you)
participant FJACK as jack (friend)
ARR->>JACK: grab release → fetch stub .torrent
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<br/>→ downloads.completedPath
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
- 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), soarr fetches the stub
.torrentfrom jack and immediately POSTs it back to jack at/api/v2/torrents/add. - That
.torrentis a stub — bencoded data that just encodes thepeerIdanditemId. No trackers, no pieces; it's never written to disk. - jack parses the stub, finds the matching peer, and queues the download.
- jack downloads the real file over HTTP from that peer's
/peer/items/:id/fileendpoint intodownloads.completedPath. - *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.
When a friend lists you as a peer, their jack calls your /peer/* endpoints
(all guarded by your jack.apiKey):
/peer/search— search your Radarr/Sonarr library./peer/items/:id— release metadata./peer/items/:id/file— stream the actual file.
jack streams files straight from disk using the paths your Radarr/Sonarr report, so the jack process must be able to read your media files at those same paths (mount your media into the container the same way your *arr apps see it).
jack.apiKey is a single shared secret that protects every HTTP endpoint
except /ping when the jack block is configured, including /torznab and
/peer. It is used in two directions:
- Your Radarr/Sonarr send it as the Torznab
apikeywhen they search you (jack fills this in automatically when it registers itself). - Your peers send it as the
X-Api-Keyheader when they search/download from you.
It can be any non-empty string, but use something long and random:
openssl rand -hex 32Put the result in jack.apiKey in your config.jsonc. If you don't set one,
jack does not expose the Torznab or peer APIs. The default config jack writes on
first boot reads this from JACK_API_KEY; set that environment variable or
replace it with a plain string/secret-file reference before peering.
Peering is symmetric — you each run jack and exchange two things: your
baseUrl and your apiKey.
-
You give a friend your
jack.baseUrl+jack.apiKey. They add you underpeersin their config: -
They give you theirs, and you add them the same way in your config.
After that, each side's Radarr/Sonarr can find and pull media the other has.
⚠️ It's one shared secret. The same key authenticates peers and the Torznab endpoint, so anyone you hand it to can also query your indexer. There are no per-peer keys yet — only share it with people you trust. (Per-peer, revocable keys are a planned improvement.)
jack reads a JSONC file
(comments allowed) from APP_CONFIG_PATH (default /config/config.jsonc). If
the file doesn't exist, jack writes a default one on first boot. If that default
references JACK_API_KEY and the variable is not set yet, jack starts with an
empty config until you provide it. Copy examples/config.jsonc
as a starting point.
Every top-level block is optional — configure only what you need for what you're doing.
{
// This instance's identity. Needed to expose a Torznab indexer and to be
// reachable by peers.
"jack": {
"baseUrl": "http://jack:5225", // URL your *arr apps / peers reach you at
"apiKey": "a-long-random-string" // openssl rand -hex 32 — see "The API key"
},
// 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": {
"completedPath": "/data/torrents/completed" // jack writes finished files here
},
// Your Radarr/Sonarr servers. Each can be a source, a destination, or both.
"servers": [
{
"name": "Main Radarr",
"type": "radarr", // "radarr" | "sonarr"
"url": "http://radarr:7878",
"apiKey": "<32 hex chars>", // *arr API key (Settings → General)
"headers": { "X-Forwarded-User": "jack" }, // optional extra outbound headers
"source": true, // share this library with peers
"destination": true, // register jack here + import grabs
"autoregister": { // indexer/client registration (destinations)
"enable": true, // set false to register it yourself
"priority": 1 // indexer priority in *arr (lower = preferred)
}
},
{ "name": "Main Sonarr", "type": "sonarr", "url": "http://sonarr:8989", "apiKey": "<32 hex chars>" }
],
// Other jack instances (friends) you consume from. Sources only.
"peers": [
{
"name": "friend",
"url": "https://their-jack.example.com",
"apiKey": "...",
"headers": {
"CF-Access-Client-Id": { "env": "FRIEND_CF_CLIENT_ID" },
"CF-Access-Client-Secret": { "env": "FRIEND_CF_CLIENT_SECRET" }
}
}
]
}source, destination default to true; autoregister defaults to
{ "enable": true, "priority": 1 }.
Field notes:
jack.baseUrlmust be reachable by your *arr apps (and by peers, if you're sharing). On a shared Docker network use the container name; otherwise the host IP/domain.jack.apiKey— see The API key.peers[].apiKeyis that peer'sjack.apiKey(what they gave you), not your own.servers[].name/peers[].nameare required display names used in logs, health output, and search results.servers[].apiKeyis the Radarr/Sonarr API key — exactly 32 hex characters (Settings → General).servers[].headers/peers[].headersare optional extra HTTP headers sent to that server/peer. Header values support the same plain-string or{ "env": "NAME" }/{ "file": "/absolute/path" }secret forms as API keys. Use these for reverse proxies or access layers such as Cloudflare Access, Authelia, or a custom gateway. These are outbound connector headers only; jack still adds the required *arrX-Api-Keyor peerX-Api-Keyauth header separately.downloads.completedPathmust 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). The otherdownloads.*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.
Any apiKey can be given as a plain string, as a reference to an environment
variable, or as a reference to a secret file, so secrets can stay out of the
config file:
{
"jack": {
"baseUrl": "http://jack:5225",
"apiKey": { "env": "JACK_API_KEY" } // resolved from $JACK_API_KEY at startup
}
}{
"jack": {
"baseUrl": "http://jack:5225",
"apiKey": { "file": "/run/secrets/jack_api_key" } // path must be absolute
}
}All three forms are interchangeable everywhere an apiKey appears (jack,
servers, peers) and for servers[].headers / peers[].headers values. The
plain-string form keeps working unchanged. File paths must be absolute; trailing
line endings are ignored. If a referenced variable is unset/empty, or a secret
file cannot be read or resolves to an empty value at startup, jack reports the
problem and refuses to load that config. The default config jack writes on first
boot uses the env form for jack.apiKey (reading JACK_API_KEY); on that first
boot only, a missing JACK_API_KEY leaves jack running with an empty config so
you can fill in the file.
| Var | Default | Description |
|---|---|---|
PORT |
5225 |
HTTP port |
LOG_LEVEL |
info |
trace/debug/info/warn/error/fatal |
ENVIRONMENT |
development |
production switches logs to JSON (no pretty-print) |
APP_CONFIG_PATH |
/config/config.jsonc |
Path to the config file |
HTTP_TIMEOUT_MS |
30000 |
Default timeout, in milliseconds, for outbound connector requests |
OTEL_EXPORTER_OTLP_ENDPOINT |
unset | Enables OpenTelemetry traces and logs and sends OTLP/HTTP data to this base endpoint |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
unset | Also enables OpenTelemetry when set; useful if traces use a signal-specific endpoint |
OTEL_SERVICE_NAME |
jack-backend |
Service name attached to emitted telemetry |
ENABLE_LOGS |
true |
Set false to disable pino logging; logs are also disabled automatically when NODE_ENV=test |
Set
LOG_LEVEL=traceto log every HTTP request — method, path, response status, and duration — as it completes.
When an OTLP endpoint is configured, jack emits request traces and bridges pino
logs into OpenTelemetry logs. Request spans include redacted headers/query
attributes and bounded textual request/response bodies; binary bodies are
omitted. See examples/compose-with-otel.yml
for a compose setup with OpenObserve.
jack exposes an unauthenticated GET /ping that returns { "status": "OK" }
with a 200, handy for uptime monitors and orchestrators:
curl http://localhost:5225/ping
# {"status":"OK"}The Docker image wires this endpoint up as a built-in HEALTHCHECK, so
docker ps and Compose report the container's health automatically — no extra
configuration needed.
The repo includes a small Bun CLI for talking to a running jack:
JACK_URL=http://localhost:5225 \
JACK_API_KEY=your-key \
bun scripts/cli.ts api GET /serversSupported commands:
bun scripts/cli.ts api [METHOD] <path> [items...]— generic HTTP request. Items use httpie-style syntax:key==query,key=body,key:=rawjson, orHeader:value.bun scripts/cli.ts peer search [--imdbId id] [--tmdbId id] [--tvdbId id] [--season n] [--episode n]— call your local/peer/search.bun scripts/cli.ts torznab search [--imdbId id] [--tmdbId id] [--tvdbId id] [--season n] [--episode n] [--cat id]— call/torznab/apiand print XML.
Operational endpoints:
GET /servers— list configured Radarr/Sonarr connectors and peers, including initialization state.GET /servers/health— return health issues from destination Sonarr connectors.GET /items?searchTerm=...— search local source Radarr/Sonarr libraries directly.
bun install
APP_CONFIG_PATH=./config/config.jsonc \
ENVIRONMENT=production \
PORT=5225 \
bun apps/backend/src/index.tsFor local development with hot reload:
mise run dev # bun --cwd apps/backend --hot src/index.tsRegistration runs on every startup and logs the *arr response body, so check
docker compose logs jack first. The common failures:
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 meansarr 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).
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 folder into Radarr and Sonarr at the same container path jack uses. If jack has:
# jack
volumes:
- /srv/media/jack-completed:/data/torrents/completedthen Radarr and Sonarr each need:
# radarr AND sonarr
volumes:
- /srv/media/jack-completed:/data/torrents/completedTwo gotchas:
- Use a dedicated completed folder. Don't point
completedPathat a folder 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/PGIDthe 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/configmount) are readable/writable by uid 1000 —chown -R 1000:1000them; if your *arr uses a differentPUID, set it to match.
No "downloads" config set; skipping download client auto-registration. Grabs will fail until a qBittorrent client is configured.
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: add a downloads block with completedPath and restart jack.
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.
Failed to initialize connector radarr: Unable to connect. Is the computer able to access the url?
jack tries to connect to your servers at boot. If it starts before Radarr/Sonarr
are ready, those connectors fail initially and you'll see sources:0 /
destinations:0 in the Server listening line. Source and peer connectors are
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 qBittorrent download client is created in Radarr/Sonarr. To make startup deterministic, wait for the dependencies to be healthy:
# jack
depends_on:
radarr: {condition: service_healthy}
sonarr: {condition: service_healthy}(This needs healthcheck blocks on those services — the linuxserver.io images
ship with them.)
Set LOG_LEVEL=trace to log every HTTP request (method, path, status,
duration). Registration failures always log the raw arr response body at
error level, which carries the real validation message — read that body, it
usually tells you exactly whatarr is unhappy about.
bun test # run tests
mise run lint # lint
mise run lint:fixAPI types for external services are generated from OpenAPI specs:
mise run clients # regenerate packages/schemas/src/generatedapps/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
{ "peers": [ { "name": "you", "url": "https://your-jack.example.com", "apiKey": "<your jack.apiKey>" } ] }