Queue management¶
SnatchArr can clean up the download queue of each Sonarr, Radarr, Lidarr and Readarr-fork instance: downloads that stall, crawl, fail, cannot import or are no longer wanted are removed through the *arr queue API, and the item falls back into the wanted list for SnatchArr to search again under the hourly cap. It never talks to a download client itself (ADR-0006); the design is ADR-0012.
Start here¶
- Queue management is off for every instance until you turn it on in the instance's snatch policy.
- When on, every rule starts in observe mode. An item that breaks a rule gets a strike; at the threshold SnatchArr emits
queue_item_struckwithwould_remove=trueand deletes nothing. - Watch the struck items in the instance view for a while. When a rule only flags downloads you want gone, set that rule to act. From then on, reaching the threshold removes the item with
DELETE .../queue/{id}?removeFromClient=true&skipRedownload=true&blocklist=<per rule>.
skipRedownload=true keeps the *arr from searching on its own; SnatchArr's planner searches the item again within the instance's hourly cap.
Never touched¶
A queue item gets no strike while any of these hold (api/internal/queue/sweep.go, isNeverTouched):
statusisqueued,paused,delayordownloadClientUnavailable;trackedDownloadStateisimporting,importedorignored;- the item was
addedless thangrace_secondsago (default 15 minutes, at most 24 hours); - queue management is off for the instance.
An instance kind whose queue lacks the fields the rules read (Whisparr today) is skipped, and the skip is counted in the queue metrics (api/internal/queue/service.go). Tests: TestEveryRuleNegative (its "never touched" cases), TestGraceProtectsNewItem, TestWhisparrSkipped.
States¶
Each sweep checks an item against the rules in this order and gives it at most one strike, for the first rule it breaks. A sweep in which the item breaks no rule resets its count to zero.
| State | Strike when (one sweep) | Default strikes | Default blocklist | Test |
|---|---|---|---|---|
| stalled | trackedDownloadState=downloading, sizeleft > 0, and sizeleft did not drop since the previous sweep |
6 | yes | TestStateStalled |
| slow | downloading, sizeleft dropped, but the rate since the previous sweep is below min_bytes_per_second |
6 | yes | TestStateSlow |
| failed | trackedDownloadState is failed, failedPending, downloadFailed, downloadFailedPending or importFailed, or status=failed |
2 | yes | TestStateFailed |
| import-blocked | trackedDownloadState=importBlocked, or importPending with trackedDownloadStatus=warning |
6 | yes | TestStateImportBlocked |
| id-matched | the ID-match note is the item's only problem (see ID-matched); force-imports instead of removing (Sonarr, Radarr) | 1 | never removes | TestIDMatchedSweepDecisions |
| unwanted (score) | customFormatScore below min_custom_format_score |
1 | yes | TestStateUnwantedScore |
| not an upgrade | the media item already has a file on disk and customFormatScore is not above that file's score (Sonarr, Radarr) |
1 | no | TestStateNotAnUpgrade |
| duplicate | another download in the queue for the same episode, movie, album or book scores higher | 1 | no | TestStateDuplicate |
| unwanted (unmonitored) | the item is not monitored | 1 | no | TestStateUnwantedUnmonitored |
Each rule carries its own blocklist switch next to mode and threshold (the Blocklist column in the instance view). Left out of a write, the rule keeps the default from the table; reads always return the effective value. Switching it off removes the item without a blocklist entry, so the release may be grabbed again; switching it on for not an upgrade, duplicate or unwanted (unmonitored) blocklists the release as well. Tests: TestRuleBlocklistSetting and TestRuleBlocklistDefaultsMatchTable in api/internal/queue/rules_test.go.
The state tests live in api/internal/queue/states_test.go. Each has positive/, negative/ and boundary/ subtests: the threshold minus one strike removes nothing, the threshold strike removes, and a clean sweep resets the count.
Stalled¶
Zero progress is stalled, never slow, even when a speed floor is set. A sizeleft that grew also counts. The first sighting of an item gives no strike, because there is no earlier sizeleft to compare, and neither does sizeleft = 0.
Slow¶
The rate is (previous sizeleft - sizeleft) / seconds since the previous sweep. A rate equal to min_bytes_per_second is not slow; it has to be below. min_bytes_per_second = 0, the default, turns the rule off.
Failed¶
Either field alone is enough, compared without regard to case. A failed item that is also queued or paused stays untouched.
Sonarr and Radarr call the states failed and failedPending. Lidarr and the Readarr family call them downloadFailed and downloadFailedPending, and add importFailed, which marks an import the app gave up on and never retries; all five count as failed. An item in importBlocked is still import-blocked on every kind.
trackedDownloadStatus=error on its own is not a failure. Sonarr and Radarr set it only when they fail a download, together with the state failedPending, which the rule already matches; Lidarr and Readarr never set it (failedErrorStatusCases).
Import-blocked¶
importPending with status ok is an import in progress and gets no strike.
The import-blocked rule never strikes an item whose only problem is the ID-match note, "release was matched to series by ID" on Sonarr or "release was matched to movie by ID" on Radarr: status warning, state importBlocked or importPending, and every status message line is either that note or the item's own release title. Such an item belongs to the id-matched rule below. Any other line, no lines at all, or a status other than warning strikes as usual (TestStateImportBlocked, id_match_ cases). Lidarr, Readarr and Whisparr items with the same note are left alone and need a manual import.
ID-matched¶
The *arr matched such a release to its series or movie by grab history but cannot parse its title, for example a release named after the episode title. A manual import clears it, and the id-matched rule sends that import itself (ADR-0013). It covers Sonarr and Radarr, whose manual-import candidates carry the mapping, and needs a downloadId.
- The rule reads
GET /api/v3/manualimport?downloadId=…&filterExistingFiles=trueand keeps only candidates without a rejection that are mapped: a series and at least one episode on Sonarr, a movie on Radarr. It sends those withPOST /api/v3/command(name=ManualImport,importMode=auto), each call under the client timeout (api/internal/arrclient/forceimport.go,TestForceImport). - A download without such a candidate is refused: nothing is sent, the item stays for you, and SnatchArr reports
queue_item_import_failedwithno_clean_candidate. The rule never removes or blocklists. - In observe mode, the default, the strike event carries
would_import=trueand nothing is sent. In act mode each sweep imports at mostmax_imports_per_sweepdownloads (default 25); further items wait for the next sweep. A season pack lists one item per episode under onedownloadIdand costs one import (TestIDMatchedImportBound,TestIDMatchedSeasonPackImportsOnce). - The command runs asynchronously, so the next sweep checks the result. A download still ID-matched one sweep after its import is reported once as
queue_item_import_failedwithstill_id_matched. No download is imported again until 24 hours after its last import, when its strike row would expire (TestIDMatchedReadBack,TestServiceForceImportSent). - In observe mode the sweep in which a download's strike reaches the threshold logs
queue: would force import(instance,download_id,title) and countssnatcharr_queue_imports_total{result="would_import"}, once per download. The first sweep of a strike under any rule logsqueue: item struck(instance,rule,download_id,title). Tests:api/internal/queue/shadow_test.go. - The parity week of ADR-0014 compares those lines with the cluster unblocker's:
go run ./tools/idmatchparity -day 2026-10-19 -snatcharr api.log -unblocker unblocker.log, whereapi.logholds the api's JSON lines andunblocker.logthe unblocker runs'kubectl logs --timestamps. It prints one row (matched,skipped,unseen,mismatches,verdict) and one line per mismatch, and exits 1 on a red day. - A failed command call (
error) leaves no import stamp, so the next sweep tries again (TestServiceForceImportSendErrorRetries). make arr-forceimport-liveproves the path on real Sonarr and Radarr started for the test, never on a live stack: each grabs a release by ID under a title it cannot parse, and the production client imports it and reads the queue back (TestLiveForceImportinapi/internal/arrclient/forceimport_live_test.go, harnesstools/e2e/forceimport_live.sh). A file the *arr rejects, such as one without an audio track or, in a download of several files, one it maps to an episode the series lacks, is never sent. CI runs it as the jobarr-forceimport-live.
Unwanted¶
A score equal to min_custom_format_score is wanted; it has to be below. Leaving min_custom_format_score unset, the default, turns the score rule off. An unmonitored item is removed without a blocklist entry, so the release stays available if the item is monitored again. When an item breaks an earlier rule as well, that rule wins: a failed, unmonitored download is struck as failed.
Not an upgrade¶
The rule compares the download's customFormatScore with the score of the file already on disk for the same episode or movie. A score equal to the file's score is no upgrade; it has to be above. The comparison covers custom-format scores only, the way the cron script it replaces did: a download the *arr grabbed for a better quality but a lower custom-format score is struck too, so watch the rule in observe mode before you set it to act. It removes without a blocklist entry by default, because the release itself is not defective.
The file score comes from extra reads during the queue read (api/internal/arrclient/queue.go, withFileScores): Sonarr's /api/v3/episodefile/{id} for the queue item's episode.episodeFileId, and Radarr's /api/v3/moviefile/{id} for movie.movieFileId, because the movie the queue embeds carries no custom-format score. One queue read makes at most 50 file reads, each with the client timeout, and reads each file once. A file past that bound, a failed read and a file without a score leave the score unknown, and an unknown score gives no strike. Lidarr and Readarr have no single file per queue item; the rule never strikes there. Tests: TestQueueFileScores, TestQueueFileScoreLookupBound.
Duplicate¶
The sweep groups the instance's queue items by media item: the episode for Sonarr, the movie for Radarr, the album for Lidarr and the book for Readarr (Item.MediaID, api/internal/arrclient/queue.go). In a group with downloads from two or more downloadIds, every download except the one with the highest customFormatScore is a duplicate; with equal scores the item with the lowest queue id stays, whatever order the pages came in. A season pack lists one queue item per episode under one downloadId: its items never strike each other, and the pack is struck as a whole once it loses any one of its episodes to a better download, as the cron script did. Items without a media id are never grouped. The rule removes without a blocklist entry by default, because the release itself is not defective. Tests: TestStateDuplicate, TestDuplicateDeterministic, TestDuplicateObserveDefault in api/internal/queue/duplicate_test.go, TestQueueMediaID in api/internal/arrclient/queue_filescore_test.go.
Strikes, bounds and events¶
- Strike rows live in the
queue_strikestable, one per instance anddownloadId, with the rule, count, lastsizeleft, last seen time, the last force import (imported_at,import_refused) andexpires_at(last seen plus 24 hours). The API is their only writer; rows of items that left the queue expire. - The sweep runs every 10 minutes on the elected leader (
leader_loops). Per instance it reads at most 10 pages of 200 items plus at most 50 file scores, and removes at mostmax_removals_per_sweepitems (default 10); the rest wait for the next sweep without an extra strike (TestRemovalBoundExhaustion,TestMaxRemovalsBound). - A removal that fails keeps the row and is retried next sweep without counting twice (
TestFailedDeleteKeepsRowAndRetries). - Events on the SSE stream:
queue_item_struck(rule,count,threshold,mode,would_remove,would_import),queue_item_removed(rule,blocklist),queue_item_importedandqueue_item_import_failed(detailno_clean_candidate,still_id_matchedorerror). The metricsnatcharr_queue_imports_total{instance,result}counts the same import results, withokfor a sent import andwould_importfor an observed one.
Settings¶
Per instance, in the snatch policy (QueuePolicy in api/openapi/openapi.yaml, bounds in Settings.Validate, api/internal/queue/rules.go):
| Setting | Default | Range |
|---|---|---|
enabled |
false |
|
rules.<rule>.mode |
observe |
observe or act |
rules.<rule>.threshold |
per state, see the table above | 1 to 100 |
grace_seconds |
900 | 0 to 86400 |
min_bytes_per_second |
0 (slow rule off) | 0 or more |
min_custom_format_score |
unset (score rule off) | any integer |
max_removals_per_sweep |
10 | 1 to 100 |
max_imports_per_sweep |
25 | 1 to 100 |
Cleanuparr¶
Queue management is the precondition for retiring a separate Cleanuparr deployment. SnatchArr's read-only Cleanuparr link is already gone (ADR-0012); nothing is removed from your deployment. Do not let both tools remove items from the same *arr instance: turn Cleanuparr's queue cleaner off for an instance before you set its SnatchArr rules to act.