Upstream contracts¶
SnatchArr talks to Sonarr, Radarr, Lidarr, Whisparr v2 and v3, three Readarr forks and Seerr through clients whose wire types are written by hand. When one of those projects renames or drops a field, a hand-written type keeps compiling and the value is silently empty. The contract tests catch that before a release: they compare each client with the OpenAPI spec its upstream project publishes.
This page is for contributors. Running SnatchArr needs none of it.
Run it¶
The target downloads the pinned specs on the first run (about 2.9 MB in total, into
.cache/upstream-specs/), so a cold cache needs network access. It is part of
make verify-all and runs in CI as the job contracts (.github/workflows/ci.yml).
What a failure means¶
| Message | Meaning | What to do |
|---|---|---|
<path> at commit <commit> has sha256 <new>, the manifest pins <old> |
The spec file changed between two pinned commits, or the pin is wrong. | Renew the pin. |
goenvoy manifest at <commit12> has sha256 <new>, specs.yaml pins <old> |
The source manifest changed between two commits, or the pin is wrong. | Renew the pin. |
<operation>: ... <field> ... the spec does not document ... |
The client reads a field the pinned spec does not have. | Fix the wire type, or the code that depends on the field. |
... query parameter "<name>" is not in the spec or no such operation in the contract |
The client sends a request the spec does not describe. | Fix the request. |
... listed as absent and the spec documents it |
An entry of a known-gap list is out of date. | Remove the entry. |
command <Name>: field "<x>" is not a property of the upstream class or command <Name>: the upstream class has the property "<x>" and the body lacks it |
A command body the worker sends has a field the pinned C# class lacks, or lacks one it has. | Fix the body; see Command bodies. |
no contract test left a receipt |
A contract test was skipped, renamed or deleted. | Restore the test; see Receipts. |
... is in the git index, .gitignore does not ignore ... or .dockerignore has no rule ... |
A spec or a file built from one is about to be committed, or could enter an image build. | Unstage the file or restore the rule; see Licence rule. |
How it works¶
tools/upstreamspec/specs.yamlpins the source manifest fromgolusoris/goenvoy(sourcewith repository, commit, pathupstream/specs.yamland sha256). Goenvoy owns the upstream spec pins (repository, release tag or branch, commit, path, sha256, license, prefix). SnatchArr lists only its consumer operations and, for an entry withPOST .../command, the upstream C# command classes (path and sha256 at the commit of its spec). A new upstream release is pinned in goenvoy, whose Renovate configuration readsupstream/specs.yamlthere; SnatchArr follows when its source pin moves to that commit.upstreamspec fetchdownloads each file at its commit and refuses one whose checksum differs. For each spec it writes a contract file: per listed operation the documented query parameter names and one example response in which every documented property is present (tools/upstreamspec/openapi.go). It downloads each pinned command class the same way and writes the camel-cased names of its public properties into the contract file (tools/upstreamspec/commands.go).upstreamspec guardchecks that nothing of the cache is committed or can enter an image build context.- The contract tests run the real client against a local server that answers with the
example responses:
- Go, Seerr:
TestUpstreamContractinapi/internal/seerrclient/contract_test.go. - Rust, Sonarr, Radarr, Lidarr, Whisparr v2 and v3 and the Readarr forks Bookshelf,
Chaptarr and Faustvii/Readarr:
upstream_contract_*inworker/crates/arr-client/src/contract_tests.rs. Readarr lists the fork versions.
- Go, Seerr:
upstreamspec receiptschecks that each of those tests passed.
A contract test reports four kinds of difference: a field a wire type reads that the documented response lacks, a request the spec does not describe (path or query parameter), a client method that fails on the documented example, a listed operation the client never requests, and a command body that differs from its upstream class.
Command bodies¶
The OpenAPI schema of POST .../command lists only the generic properties of a command and
forbids others, so it cannot describe the body of a search. Those fields live in one C# class
per command (EpisodeSearchCommand, MoviesSearchCommand, AuthorSearchCommand, ...), and
the command name is the class name without Command. The manifest pins each class the worker
sends. The local server of the Rust test compares every command body it receives with the
properties of the class of its name: an unknown field fails, and so does a missing one. A
renamed field in CommandBody (worker/crates/arr-client/src/lib.rs) therefore fails the
contracts job. A class with no public get/set property fails fetch, so an extractor that
matches nothing cannot let every body pass.
The class files are GPL-3.0 sources like the specs: they stay in the cache and are covered by the licence rule.
Whisparr¶
Whisparr tags its v3 (Eros) releases, but the spec committed with them was last generated on
2025-06-08 and lacks wanted/missing and wanted/cutoff, which the v3 API has served since
2025-11-17 (src/Whisparr.Api.V3/Wanted/, checked at tag v3.1.0.2116). Whisparr
publishes a generated spec per generation on its own branch instead: api-docs for v2 and
eros-api-docs for v3. Both are pinned with branch.
Whisparr's wanted records are not Sonarr's episodes or Radarr's movies, so the client reads them
with their own wire types (SceneLike and WhisparrMovieLike in
worker/crates/arr-client/src/lib.rs):
- A v2 scene has no
episodeNumberand noairDateUtc; it carries the day of its release asreleaseDate. The spec documents thatDateOnlyas an object withyear,monthandday, while the server writes a string such as"2020-01-02"(STJDateOnlyConverter); the client reads both. - A v3 movie has one
releaseDatein the place of Radarr'sinCinemas,digitalReleaseandphysicalRelease, so the Radarr release-date setting does not apply to it.
Receipts¶
A test command that matches no test exits 0, so a renamed or deleted contract test would
leave the gate green. Each passing contract test therefore writes <spec>.checked with
the pinned commit into the cache. fetch removes the receipts of earlier runs and
receipts fails for every pin without one (tools/upstreamspec/receipts.go).
Outside make contracts-verify the contract tests do not run: the Go test skips without
the SNATCHARR_SPEC_CACHE variable and the Rust tests are marked #[ignore]. Both
packages also test the comparison itself against contracts written by hand, with and
without a planted difference; those tests run in make api-verify and
make worker-verify.
Licence rule¶
Sonarr, Radarr, Lidarr and the three Readarr forks publish their specs under GPL-3.0. SnatchArr is EUPL-1.2. The project keeps the two apart with one rule: no spec, and nothing built from a spec, is committed or copied into an image. That covers the contract files, fixtures and generated client code. The clients stay hand-written, and the sample payloads in the tests are written by hand as well. The Seerr spec is MIT; the same rule applies to it so that there is one mechanism.
upstreamspec guard enforces the rule (tools/upstreamspec/guard.go). It fails when
- a file inside the cache directory is in the git index,
- an indexed file has the content of a cache file under another name,
- no
.gitignorerule covers the cache directory, or .dockerignoredoes not exclude it, or re-includes part of it.
Renewing a pin¶
Renovate reads source.repository and source.commit (customManagers in renovate.json)
and opens one pull request, upstream specs, when golusoris/goenvoy has a newer commit on branch
main (datasource github-digest, which dates the commit, so the waiting time for new releases
applies). Renovate cannot renew the sha256, so these pull requests are never merged automatically.
- Run
go run ./tools/upstreamspec fetch(ormake contracts-verify). - If
source.sha256differs,fetchfails and prints the new checksum:goenvoy manifest at <commit12> has sha256 <new>, specs.yaml pins <old>. - Put the new sha256 into
tools/upstreamspec/specs.yamlundersource.sha256. - If an upstream spec's commit moved and a C# command class changed at that commit,
fetchwill report the new checksum for the class; put the new sha256 intocommandsintools/upstreamspec/specs.yaml. - Run
make contracts-verify. A difference the contract tests report now is the finding this gate exists for: fix the client in the same pull request.
TestRenovateKeepsEveryPinCurrent in tools/upstreamspec/renovate_test.go checks that the pattern
in renovate.json captures source.repository and source.commit exactly as in specs.yaml.
make contracts-verify runs the tests of tools/upstreamspec first, so the contracts job covers
a change to renovate.json as well.
Download clients¶
The download clients publish no specs, so make dlclients-live checks them against real
releases instead. It starts qBittorrent, Transmission, Deluge, rTorrent, SABnzbd and NZBGet from
tools/e2e/dlclients/compose.yaml (pinned by digest, kept current by Renovate), sets each up as
a user would, and runs TestLiveDownloadClients (api/internal/dlclients/live_test.go)
through the adapters production uses. Each client must be reachable with its secret, report an
empty queue and the right paused state, and refuse a wrong secret; the run repeats with SABnzbd
and NZBGet paused. Two planted cases, a wrong secret for every client and a pause that never
happened, must fail first. CI runs it as the job dlclients-live; it needs Docker and is not
part of make verify-all.
Real clients behave in ways a fake did not show:
| Client | Behaviour | Where it is handled |
|---|---|---|
| qBittorrent 5 | Answers HTTP 401 when the port in the Host header is not the port the WebUI listens on. |
The compose file publishes the WebUI on the port it listens on. |
| Deluge 2 | The web UI starts without a daemon connection and answers every daemon call with "Unknown method". | The snapshot reports ErrDelugeNoDaemon; the user connects a daemon in the web UI's connection manager. |
| Deluge 2 | Serves no daemon.info, which goenvoy asks for the version (golusoris/goenvoy#101). |
The version stays empty; unknownVersion in live_test.go fails once a fixed release reports it. |
| NZBGet | Starts with downloads paused. | tools/e2e/dlclients_live.sh resumes it before the first run. |
Force import on Sonarr and Radarr¶
The ManualImport write path of rule id_matched (ADR-0013) is checked against real releases by make arr-forceimport-live. It starts Sonarr, Radarr and qBittorrent from tools/e2e/forceimport/compose.yaml (pinned by digest, kept current by Renovate), adds one series and one movie, and pushes a release for each by tvdb or tmdb id under a title the parser cannot match (tools/e2e/forceimport/arr_setup.py). qBittorrent seeds the prepared files, so grab history is the only link and the queue item blocks with the ID-match note. TestLiveForceImport then runs the production queue read, sweep and ManualImport and waits until the download has left the queue. Four planted cases must fail first: videos without an audio track, a torrent whose two files map to no episode, a second status line and no import sent. The metadata lookups of both apps need internet access. CI runs it as the job arr-forceimport-live; it is not part of make verify-all.
What the real releases showed:
| Behaviour | Where it is handled |
|---|---|
| Radarr words the note "matched to movie by ID. Manual Import required.", not "Automatic import is not possible". | idMatchNeedles in api/internal/queue/sweep.go, case negative/id_match_radarr_note |
GET /manualimport matches downloadId case-sensitively; the queue lists the info hash in upper case. |
The service passes the queue's own downloadId. |
| Radarr maps a pushed release by tmdb id only when the title's year matches the movie. | RADARR_TITLE in arr_setup.py |
| A file without an audio track is rejected ("No audio tracks detected"). | Planted case audio |
| Sonarr maps the only video file of a download through grab history, whatever its name; a file it maps to an episode the series lacks is rejected ("Invalid season or episode") only when the download holds more than one. | Planted case unmapped puts two such files in the torrent. |
| ## Limits |
The check says "the spec documents what the client uses". It does not say more than that.
- The example of an array has one element, an enumeration its first value,
anyOfandoneOftheir first alternative.requiredandnullableare not read, so a field the spec documents as optional counts as present. - A command body is checked for field names only, not for value types or ranges. The class
properties are read as text, not compiled: a property with
[JsonIgnore], a computed one, an override, a static one and one without a public setter are skipped. - A spec can be wrong about its own server. Five fields of the Seerr request list are
returned by the server and missing from the spec; they are listed with their source in
decodesinapi/internal/seerrclient/contract_test.goand stay covered by the fixture tests inclient_test.go.
Gaps¶
| Client | State |
|---|---|
worker/crates/arr-client, the retired Readarr |
No pinned spec. Its wire types are the ones the Readarr forks use, and the hand-written contracts in contract_tests.rs cover all six kinds. The spec of Faustvii/Readarr at v0.10.0 is byte for byte the one Readarr published at v0.4.18.2805. |
api/internal/arrclient |
Not in scope: it has no wire types of its own and calls the typed clients of goenvoy. |