Skip to content

feat: source releases from Radarr/Sonarr, drop Jellyfin#7

Merged
roziscoding merged 22 commits into
mainfrom
feat/arr-source-drop-jellyfin
Jun 3, 2026
Merged

feat: source releases from Radarr/Sonarr, drop Jellyfin#7
roziscoding merged 22 commits into
mainfrom
feat/arr-source-drop-jellyfin

Conversation

@roziscoding
Copy link
Copy Markdown
Owner

@roziscoding roziscoding commented Jun 2, 2026

What

Drops the Jellyfin integration. Jack now reads media availability directly from Radarr/Sonarr. Because a source and a destination are now the same kind of server, the connector hierarchy collapses into a single capability-based connector.

This PR also grew a few adjacent things on top of the migration: id-based torznab search, OpenTelemetry traces + logs, connector resilience, and a small CLI. See the sections below.

Config (breaking)

servers.{sources,destinations,peers} + the global indexer block are replaced by:

  • servers — flat array of radarr/sonarr, each with source / destination / autoregister: { enable, priority } (source/destination default true).
  • peers — separate array of other jacks (sources only).

name is now required on both. See README.md and examples/config.jsonc.

"servers": [
  { "type": "radarr", "url": "http://radarr:7878", "apiKey": "<32 hex>",
    "source": true, "destination": true, "autoregister": { "enable": true, "priority": 1 } }
],
"peers": [ { "name": "friend", "url": "https://their-jack", "apiKey": "..." } ]

New optional env vars: OTEL_EXPORTER_OTLP_ENDPOINT (+ OTEL_SERVICE_NAME) to turn on telemetry, and HTTP_TIMEOUT_MS (default 30s) for outgoing connector requests.

Architecture

  • ArrServerConnector implements both roles, gated by canSource/canDestination and guarded by new @requiresSource / @requiresDestination decorators (mirroring @requireInitialization). Radarr/Sonarr subclasses hold the movie- vs series/episode-specific queries. PeerConnector (was JackServerConnector) stays source-only. The sources/ + destinations/ split is gone.
  • New normalized Release type replaces Jellyfin's BaseItemDto across the peer → torznab → blackhole flow, mirroring the *arr file metadata (scene/release name, quality, size, languages, mediaInfo, imdb/tmdb/tvdb ids). The absolute file path is re-resolved on the source jack and never serialized.
  • Auto-registration is now per-server (autoregister.enable + autoregister.priority) instead of the global indexer flag.

Search behaviour

Searches match how Radarr/Sonarr actually query, and avoid listing peers' whole libraries:

  • By id, server-side where possible: Radarr prefers tmdbid (a targeted GET /api/v3/movie?tmdbId= lookup) then falls back to imdbid (compared with the tt prefix normalized — Radarr queries without it). Sonarr uses tvdbid (GET /api/v3/series?tvdbId=).
  • No text fan-out: t=search&q=<term> returns empty (the *arr always also searches by id). A query with no term returns the catalog, which powers the RSS feed and the indexer self-test. Caps advertises only id params for movie-search/tv-search, so the *arr stops sending text queries.
  • cat filtering: results are filtered by the torznab cat param (2xxx → movies, 5xxx → TV).
  • Robust to older peers: the requesting jack filters a peer's response by the id it asked for, so a peer on an older version (which doesn't understand tmdbId and dumps its whole catalog) can't pollute results.

Observability

  • OpenTelemetry: every request is wrapped in a server span (@hono/otel); pino logs are bridged to the OTel Logs API in-process so they carry the active span's trace/span ids (native correlation). Both are exported over OTLP/protobuf, enabled whenever an OTLP endpoint is configured. The /ping healthcheck is excluded from tracing and request logging.
  • Verbose debug/trace logging across the whole search flow (fan-out, per-peer/per-source counts, outgoing HTTP, non-2xx and schema failures).
  • examples/compose-with-otel.yml — jack + OpenObserve (single image, no external DB) wired together; secrets via a gitignored .env.

Resilience

  • Connector init() is a retry-aware state machine: it re-pings only when never tried or the last attempt failed (logging the retry), and is a no-op while in flight or once connected.
  • Searches no longer pre-filter by isInitialized — a connector that was down at boot is retried lazily and rejoins without a restart, and each connector is isolated (one failure → zero results, not a failed search).
  • Every outgoing connector request is time-bounded (HTTP_TIMEOUT_MS).

CLI

scripts/cli.ts + a mise cli task to talk to a running jack: an httpie-style api command plus peer search / torznab search (id-based, --cat). Reads JACK_URL / JACK_API_KEY; colorizes JSON on a TTY (plain when piped).

Schemas

Radarr/Sonarr API types generated via @hey-api/openapi-ts (specs committed: Radarr master, Sonarr main). Connectors use ServerConnector.fetch (existing X-Api-Key auth) typed by the generated types. Jellyfin client removed.

Tests

  • Unit (bun test apps packages): config, torznab (release→XML), the MSW integration suite (Radarr/Sonarr + peer releases, id search, cat filter, over-eager-peer filtering), and the connector init/retry/timeout state machine. 56 pass, tsc --noEmit clean.
  • e2e: Jellyfin container dropped; setup.ts seeds Radarr with the fixture movie via its API; configs use the new servers/peers shape; tests updated to the id-based / catalog search protocol.

Jack now reads media availability directly from Radarr/Sonarr instead of
Jellyfin. Since a source and a destination are now the same kind of server,
the connector hierarchy collapses into a single capability-based connector.

- Config: replace `servers.{sources,destinations,peers}` and the global
  `indexer` block with a flat `servers` array (radarr/sonarr, each with
  `source`/`destination`/`autoregister:{enable,priority}`) plus a separate
  `peers` array. `name` is now required on servers and peers.
- Connectors: `ArrServerConnector` implements both source and destination
  roles, gated by `canSource`/`canDestination` and guarded by new
  `@requiresSource`/`@requiresDestination` decorators (alongside
  `@requireInitialization`). `PeerConnector` (was JackServerConnector) stays
  source-only. The sources/ and destinations/ split is gone.
- New normalized `Release` type replaces Jellyfin's BaseItemDto across the
  peer/torznab/blackhole flow, mirroring the *arr file metadata (scene/release
  name, quality, size, languages, mediaInfo, ids). The absolute file path never
  leaves the source jack.
- Torznab output carries the richer release info (imdbid/tmdbid/tvdbid,
  season/episode, download/upload volume factors).
- schemas: generate Radarr/Sonarr API types via openapi-ts (specs committed),
  drop the Jellyfin client.
- Tests + e2e + docs updated to the new model.

BREAKING CHANGE: the `servers`/`peers`/`indexer` config layout changed; see
README and examples/config.jsonc.
The --build flag was on the radarr/sonarr line (image-based, nothing to
build), so the jack containers reused a stale cached image on repeat local
runs. Move --build to the jack-alpha/jack-beta up so the e2e always runs the
current code.
Radarr's /ping returns 200 before the v3 API finishes migrating, so the
first /api/v3/movie call raced and returned 400 on the cold CI runner. Poll
the authenticated /api/v3/system/status until it's ready before seeding.
system/status returns 200 while the DB-backed endpoints still 400 during
startup. Poll the actual movie endpoint until it's ready, which also fetches
the existing list.
Include the HTTP body in fetchJson errors and the Radarr readiness poll so CI
logs show what Radarr actually returns (the 400 is reproducible only there).
@roziscoding roziscoding force-pushed the feat/arr-source-drop-jellyfin branch from 9809e76 to 161eae4 Compare June 2, 2026 06:52
The add-movie POST failed in CI with 'Root folder /media/movies does not
exist': the fixtures weren't readable by Radarr's uid 1000 (CI runner checks
them out as a different uid), so the swallowed rootfolder POST never took.
chmod a+rX the fixtures (like the blackhole dirs) and add+verify the root
folder before seeding instead of ignoring failures.
Radarr's FolderWritableValidator rejects a root folder that isn't writable by
its uid-1000 user; a+rX only granted read. Use a+rwX on the fixtures.
@roziscoding
Copy link
Copy Markdown
Owner Author

roziscoding commented Jun 2, 2026

Migration guide — upgrading to the Radarr/Sonarr version

This release drops Jellyfin and sources media straight from Radarr/Sonarr. Migrating is a config + volumes change, plus coordinating with your peers. Follow the steps below.

1. Coordinate the upgrade with your peers

The /peer payload changed, so this is a breaking peer-protocol change (see #8): a new jack and an old jack can't talk to each other. Everyone in your peer group upgrades together — agree on a time and all bump versions, or sharing silently returns nothing.

2. Rewrite your config.jsonc

Work through these in order:

  1. Flatten servers. It's now a top-level array (not an object with sources/destinations/peers). Move each Radarr/Sonarr from the old servers.destinations[] into the new servers[].
  2. Add role flags to each server. source: true shares that library with peers (this is what replaces Jellyfin), destination: true registers jack there + imports grabs. Both default to true, so you can omit them unless you want one role off.
  3. Move peers out. servers.peers[] → top-level peers[] (same shape).
  4. Replace the indexer block with per-server autoregister: indexer.autoRegisterautoregister.enable, indexer.priorityautoregister.priority (defaults to { "enable": true, "priority": 1 }). Then delete the indexer block.
  5. Drop Jellyfin. Remove the servers.sources Jellyfin entry and any JELLYFIN_API_KEY env — your shared library now comes from the Radarr/Sonarr you marked source: true.
  6. Add a name to every server and peer — it's now required.

Result:

Before → After

Before

{
  "jack": { "baseUrl": "http://jack:5225", "apiKey": { "env": "JACK_API_KEY" } },
  "indexer": { "priority": 1, "autoRegister": true },
  "downloads": { "watchPath": "/data/torrents/watch", "completedPath": "/data/torrents/completed" },
  "servers": {
    "sources": [
      { "type": "jellyfin", "url": "http://jellyfin:8096", "apiKey": { "env": "JELLYFIN_API_KEY" } }
    ],
    "peers": [
      { "name": "friend", "url": "https://their-jack.example.com", "apiKey": "their-secret" }
    ],
    "destinations": [
      { "type": "radarr", "url": "http://radarr:7878", "apiKey": { "env": "RADARR_API_KEY" } },
      { "type": "sonarr", "url": "http://sonarr:8989", "apiKey": { "env": "SONARR_API_KEY" } }
    ]
  }
}

After

{
  "jack": { "baseUrl": "http://jack:5225", "apiKey": { "env": "JACK_API_KEY" } },
  "downloads": { "watchPath": "/data/torrents/watch", "completedPath": "/data/torrents/completed" },
  "servers": [
    {
      "type": "radarr",
      "url": "http://radarr:7878",
      "apiKey": { "env": "RADARR_API_KEY" },
      "name": "My Radarr",
      "source": true,        // share this library with peers (replaces Jellyfin)
      "destination": true,   // register jack here + import grabs
      "autoregister": { "enable": true, "priority": 1 }
    },
    {
      "type": "sonarr",
      "url": "http://sonarr:8989",
      "apiKey": { "env": "SONARR_API_KEY" },
      "name": "My Sonarr"
    }
  ],
  "peers": [
    { "name": "friend", "url": "https://their-jack.example.com", "apiKey": "their-secret" }
  ]
}

(Automating this rewrite is tracked in #9.)

3. Re-point your media mount

jack streams shared files straight from disk using the absolute path each Radarr/Sonarr stores for the file (movieFile.path / episodeFile.path) — the path inside the *arr container. This used to be Jellyfin's library path; it's now the *arr path, which usually differs. So in your jack docker-compose:

  • Mount your media at the same path(s) your Radarr/Sonarr use, mirroring each root path:

    # jack
    volumes:
      - /srv/media/movies:/movies   # same path Radarr reports
      - /srv/media/tv:/tv           # same path Sonarr reports
  • Replace the /data/media placeholder in examples/docker-compose.yml accordingly.

  • If jack can't read a file at the reported path, the grab fails with a "file not found" in the blackhole watcher (same alignment rule as the blackhole watch/completed folders).

4. Restart and verify

  1. docker compose up -d and watch the logs.
  2. Expect Server listening, and for each destination with autoregister.enable + peers configured: Registered Jack as Torznab indexer / ... Torrent Blackhole download client.
  3. Confirm sharing works: GET /peer/search?q=<title>&apikey=<jack.apiKey> should return { "items": [ ... ] } with title/size/category populated.
  4. From a peer, a Torznab search (or your *arr's interactive search) should surface releases and a grab should download + import.

See the updated examples/config.jsonc and the README's Configuration + Quick start sections for the full reference.

jack streams from disk using the *arr's reported file path, which differs from
the old Jellyfin library path. Add a callout + sharpen the compose comment so
the media mount target matches the *arr container path (not a convenient one).
Wrap every request in an OTel server span via @hono/otel and bridge pino
logs to the OTel Logs API in-process, both exported over OTLP/protobuf and
enabled whenever an OTLP endpoint is configured. Logs carry the active span's
trace/span ids for native correlation. The /ping healthcheck is excluded from
both tracing and request logging. Span/log export is immediate (Simple
processors), flushed on shutdown.

Also: set up eslint at the workspace root and apply lint autofix across the repo.
@gitguardian

This comment was marked as outdated.

@roziscoding roziscoding force-pushed the feat/arr-source-drop-jellyfin branch from ffc3196 to 54b978f Compare June 2, 2026 21:50
Add debug/trace logging through the torznab -> peer -> source search path
(fan-out with per-peer/per-source counts, outgoing HTTP requests, non-2xx and
schema-validation failures, capability/init guards) and include the response
body in the request-completed trace log, to diagnose empty peer searches.
Run with LOG_LEVEL=debug for the flow, trace for HTTP/response bodies.

Also fix @requireInitialization: it awaited the isInitialized boolean (which
resolves immediately) instead of the initialization promise, so guarded calls
could run before init finished.
Connectors that failed to connect at boot were excluded from searches until a
restart. init() is now an idempotent, retry-aware state machine: it re-pings
only when never tried or the last attempt FAILED (logging that it is retrying
the previously-down connector), and is a no-op while an attempt is in flight
or once connected. Subclasses provide the check via runInit() instead of
overriding init().

The search controllers (torznab fan-out, peer, items) drop the isInitialized
pre-filter: every source/peer is attempted and re-initialized lazily by
@requireInitialization, so one that comes back online rejoins searches without
a restart. Each connector is isolated — a failure is logged and treated as zero
results instead of failing the whole search.

Add tests for the init retry state machine and the search resilience/recovery.
Add an AbortSignal.timeout to the shared ServerConnector.fetch (which all *arr
and peer calls go through), so a hung host can't stall a search — important now
that searches lazily re-ping connectors that were down. Default is 30s via the
new HTTP_TIMEOUT_MS env, overridable per call with a timeoutMs option. Timeouts
and network errors are logged distinctly. Add a test covering the timeout.
…h-otel

GitGuardian flagged the base64 basic-auth header (example creds) baked into the
compose. Make O2_PASS and OTLP_AUTH required env vars sourced from a gitignored
.env (no committed defaults), and add a .env.example with placeholders and the
command to generate the header.
httpie-style 'api' command (key==query, key=body, key:=rawjson, Header:value),
plus 'peer search' and 'torznab search' subcommands. Reads JACK_URL and
JACK_API_KEY from the env, sends X-Api-Key, and colorizes JSON output on a TTY
(plain valid JSON when piped). Run via 'mise cli ...' (quiet).
@roziscoding roziscoding force-pushed the feat/arr-source-drop-jellyfin branch from 54b978f to a045f1e Compare June 2, 2026 23:09
Radarr (and torznab clients in general) query by the numeric IMDb id without
the "tt" prefix (imdbid=0133093), but *arr stores it with the prefix
(tt0133093). doSearchByImdbId did an exact string compare, so movie searches
never matched and returned no releases. Normalize both sides (strip tt) via a
new normalizeImdbId helper.
…n-out

Make the torznab search match how Radarr/Sonarr actually query and stop
listing peers' whole libraries for text searches:

- Radarr: add tmdbid lookup via GET /api/v3/movie?tmdbId= (server-side filter,
  a targeted one-movie lookup instead of list-all). Prefer tmdbid over imdbid.
- Sonarr: tvdbid already filters server-side; tmdbid/imdbid -> empty.
- Peer protocol: drop the free-text 'q' param; search by imdbId/tmdbId/tvdbId
  (+season/ep), and no params = full catalog. No fuzzy title matching.
- Torznab: id queries -> by-id; a 'q' term -> empty (not fanned out); no term
  -> catalog (powers RSS + the indexer self-test). Remove 'q' from the
  movie-search/tv-search caps so *arr only searches by id.

Add doListReleases() for the catalog feed. Update tests; add tmdbid + empty-q
coverage.
…mdbId)

peer search no longer takes --q (the peer protocol is id-only now); add
--tmdbId to both peer and torznab search, with torznab preferring tmdbid.
…peers)

A peer on an older version doesn't understand the new tmdbId param, drops it,
and its old fallback returns the whole catalog (movies + TV) — so a movie
search came back with everything. The requesting jack now filters a peer's
response by the id it asked for (imdb/tmdb/tvdb + season/ep), so an old or
over-eager peer can't pollute results. Add a regression test.
Honor the torznab 'cat' query param: a release matches when its category's
top-level bucket is requested (2xxx -> movies, 5xxx -> TV). Mainly affects the
catalog/RSS feed (t=movie&cat=2000 now returns only movies, t=tvsearch&cat=5000
only TV); id searches already return the right category. Add a test.
The search refactor dropped free-text fan-out: t=search&q=<term> now returns
empty and the peer protocol no longer takes q. Switch the e2e tests to the
catalog (t=search with no term) and the id-only peer search, matching the new
behavior. (Unrelated: the last e2e CI failure was an lscr.io image-pull
timeout, not these tests.)
@roziscoding roziscoding merged commit 0253c83 into main Jun 3, 2026
5 checks passed
@roziscoding roziscoding deleted the feat/arr-source-drop-jellyfin branch June 3, 2026 01:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant