Skip to content

fix(arrs): never remove a category folder through the arr or metadata delete paths - #1056

Merged
javi11 merged 3 commits into
kipsilabs:mainfrom
firestaerter3:fix/category-root-deletes
Nov 11, 2026
Merged

javi11 merged 3 commits into
kipsilabs:mainfrom
firestaerter3:fix/category-root-deletes

Conversation

@firestaerter3

Copy link
Copy Markdown
Contributor

Follow-up to #1029 / #1034.

#1034 guards recursive deletes in RemoveFile. Several other paths delete without going through RemoveFile, or ask an arr to delete a folder itself. When a queue item or a stored storage path names a category folder (as the SABnzbd history did in #1013, see also #1054), each of them acts on the whole category:

  • The scanner's queue fallback (failRadarrQueueItemByPath, failSonarrQueueItemByPath) picks the queue item for a broken file by exact path, path prefix or final name. A queue item whose output path is the category folder matches every file in it, and removing it with RemoveFromClient makes the arr delete that folder. Radarr's prefix match also has no component boundary, so Show.S01E0 matches Show.S01E02.
  • The stuck-queue cleanup and the import-failure give-up remove queue items with RemoveFromClient=true: deleteStarrQueue for Radarr, Lidarr and Readarr, the pending removal for Sonarr and Whisparr, and the Sportarr native delete.
  • The arr webhook deletes the metadata of every file an Upgrade lists.
  • The stremio cleanup deletes an expired item's storage path with DeleteDirectory.

Changes

  • config.IsCategoryRootVirtualPath and IsCategoryRootDownloadPath name the folders that hold many jobs' output: the mount root, complete_dir, the corrupted folder, a known category (a SABnzbd category name or dir, the Default dir, or an arr instance's category), and a folder above a nested category dir (tv for tv/uhd). A path an arr reports outside mount_path is judged by its trailing folder names: the category dir relative to complete_dir, preceded by a folder named like complete_dir. Backslashes count as separators, for Windows arrs.
  • The queue fallback skips an item whose output is a category root, with a warning, and matches a prefix only on a path-component boundary.
  • The cleanup paths still remove and blocklist such an item, but with RemoveFromClient=false. For Sonarr this is in the pending removal, which every Sonarr removal goes through. Sportarr's native delete always removes from the client, so such an item is skipped there.
  • The webhook refuses a listed path that is a category root. Other files of the same upgrade are still deleted.
  • The stremio cleanup refuses a category-root storage path and only drops the queue entry, so it does not retry on every run.
  • A second commit logs successful metadata deletes at info in the health API (single and bulk) and in the zombie cleanup. They were silent, so a mass delete outside RemoveFile could not be traced afterwards.

An empty output path is unchanged: with no path the arr has nothing to delete.

How to reproduce

Have a Radarr or Sonarr queue item from altmount whose outputPath is <mount>/complete/<category> (what the history reported in #1013), and a broken file under that category that the scanner repairs. The queue fallback matches the item by prefix and sends DELETE /api/v3/queue/{id}?removeFromClient=true&blocklist=true, and the arr then deletes <mount>/complete/<category>. The new scanner tests do exactly this against a stub arr.

Why a separate predicate from isDeleteGuardRoot

In RemoveFile, #1034 makes a delete of any existing folder directly under complete_dir a no-op (isDeleteGuardRoot). The files inside it can still be deleted one by one. These paths are different: they act on one job's output as a whole, by asking the arr to remove it from the client or by deleting its metadata. A job without a category is stored directly under complete_dir, so with that rule its own release folder would count as a root, and the arr would never be asked to remove it from the client. So these paths count only complete_dir itself and known categories. #1034 is left as it is.

Known limits

  • A category folder that is not configured anywhere (no SABnzbd category, not the Default dir, no arr instance using it) is not recognised.
  • The webhook check runs on the normalized path. A library path outside the mount that maps to a category folder only through the health record is caught by the second check, after the health record lookup.
  • A path an arr reports outside mount_path is matched by folder names. If a category itself is named like complete_dir (a category called complete), a release folder in it can be taken for a category folder, so these paths then leave its files alone. This errs on the safe side.

Testing

  • New tests that fail on main without the change:
    • the Radarr queue fallback with a category-folder output (no queue delete sent), and with a sibling release sharing a prefix;
    • the Sonarr queue fallback with a category-folder output (no repair started);
    • deleteStarrQueue sending RemoveFromClient=false for a category root, while still removing and blocklisting;
    • the Sonarr import-failure give-up sending removeFromClient=false for a category-folder output, and still true for a release folder;
    • an Upgrade webhook listing the category folder, complete_dir and the mount root next to a real file (only the real file goes);
    • the stremio cleanup with a category folder as storage path.
  • Unit tests for the two predicates, including Windows paths, a job without a category, and a nested category dir (tv/uhd) seen both inside mount_path and from an arr that mounts it elsewhere.
  • go vet and go test pass for internal/config, internal/arrs/scanner, internal/arrs/worker, internal/stremio, internal/api and internal/health. No frontend changes.

The Radarr, webhook, stremio and logging parts have run in our fork since 2026-10-26. The Sonarr part is new, because #1033 changed that code.

firestaerter3 and others added 3 commits November 6, 2026 15:26
… delete paths

kipsilabs#1034 guards recursive deletes in RemoveFile, but several paths delete
without going through it, or make an arr delete a folder itself. When a
queue item or a storage path names a category folder (as the history did
in kipsilabs#1013), each of these acts on the whole category:

- The scanner's queue fallback (failRadarrQueueItemByPath,
  failSonarrQueueItemByPath) picks the queue item for a broken file by
  exact path, path prefix or final name. A queue item whose output path
  is the category folder matches every file in it, and removing it with
  RemoveFromClient makes the arr delete that folder. Radarr's prefix
  match also had no component boundary (Show.S01E0 matched
  Show.S01E02).
- The stuck-queue cleanup and the import-failure give-up remove queue
  items with RemoveFromClient=true: deleteStarrQueue for Radarr, Lidarr
  and Readarr, the pending removal for Sonarr and Whisparr, and the
  Sportarr native delete.
- The arr webhook deletes the metadata of every file an Upgrade lists.
- The stremio cleanup deletes an expired item's storage path with
  DeleteDirectory.

config.IsCategoryRootVirtualPath and IsCategoryRootDownloadPath name the
folders that hold many jobs' output: the mount root, complete_dir, the
corrupted folder, and a known category (a SABnzbd category name or dir,
the Default dir, or an arr instance's category). A release folder of a
job without a category also sits directly under complete_dir, so an
unknown name there is not a root. A path an arr reports outside
mount_path is judged by folder names, and backslashes count as
separators for Windows arrs.

With that:

- the queue fallback skips an item whose output is a category root (with
  a warning) and matches a prefix only on a path-component boundary;
- the cleanup paths still remove and blocklist such an item, but with
  RemoveFromClient=false. Sportarr's native delete always removes from
  the client, so such an item is skipped there;
- the webhook refuses a listed path that is a category root;
- the stremio cleanup refuses a category-root storage path and only
  drops the queue entry, so it does not retry every run.

An empty output path is unchanged: with no path the arr has nothing to
delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The health API (single and bulk delete with delete_meta) and the zombie
cleanup in the health worker logged only failed metadata deletes. Log
each successful one at info, the API ones with the caller's address, so
a mass delete outside RemoveFile can be traced afterwards. Import
rollback stays at debug: it only removes files the failed import itself
just wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… roots

A category dir with a slash, such as "tv/uhd", was only recognised
inside mount_path. For a path an arr reports from outside mount_path
the fallback compared the last folder name with a known category and
required the folder just above it to be named like complete_dir, so
/data/complete/tv/uhd was not a root and could still be removed with
RemoveFromClient. The folder above a nested category ("complete/tv")
was not a root anywhere, although it holds that category too.

Category dirs are now compared relative to complete_dir as a whole
path suffix, and a folder above a known category dir counts as a root,
both for metadata paths and for paths an arr reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@javi11
javi11 merged commit 26659b9 into kipsilabs:main Nov 11, 2026
2 checks passed
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.

2 participants