# Changelog ## v1.8.0 — 2026-09-27 **The largest release auditorr has had, and almost all of it is one idea.** Every workflow that can delete, hardlink or replace a file was reviewed end to end, and most of what came out shares a single root cause: auditorr could not tell *"your client says nothing claims this file"* from *"auditorr could not ask"*. Both arrived as an empty answer — and an empty answer is what makes a file look orphaned, a title look absent from your library, or a cross-seed look like it does not exist. Acting on it deletes things. The fixes run from the torrent-client layer up through every page that has a button on it, and the rule throughout is that absence of evidence is reported as absence of evidence. Nothing here needs a configuration change and nothing is migrated: an install that upgrades keeps its database, and the first scan after upgrading rebuilds what the new pages read. With thanks to **essentrix83** (issue #17 and their fork) and **Joloxx9** ([#24](https://github.com/thrill-burn/auditorr/pull/24)), whose findings are credited in the entries below, and to the reviewer whose 2026-09-10 read of the workflow code found eleven more. ### Safety Everything in this section could cost you data, or already has. Ordered by what happens to your files rather than by which part of auditorr was at fault. #### Silence was read as an answer - **A torrent client that answered badly could get your whole torrent folder listed as orphaned** — "orphaned" is not something auditorr reads off a file. It means *no torrent in your client claims this path*, and it was worked out from one snapshot of the client's answer, with no way to tell "your client says nothing claims this" apart from "your client didn't answer". A per-torrent file listing that timed out came back as an empty list, so every file of a live, seeding torrent turned up in **Cleanup** with a green *frees X* beside it. A client that answered with no torrents at all — a rebuilt container with a fresh session directory, a restored config pointing at an empty profile, or simply a scan landing while qBittorrent was still loading its session — was believed absolutely, and the entire torrent tree was rendered as orphans under a single *Select all*. Nothing stood between that and a delete script. A failed listing is now recognised as one, and the torrent's files are located on disk instead, so they stay claimed rather than defaulting to orphaned. **A scan whose view of the client is implausible — far fewer torrents or files than the last scan, instances that didn't answer, or nothing at all while your torrent folder is full — refuses to save anything.** Your file lists, health score and change log keep describing the last scan that completed, and the reason is shown; if the drop is real, running a scan manually accepts it. Throughout the torrent-client layer, an unanswerable question now looks different from a negative answer. - **A torrent listing that stopped short was taken for the whole client** — qui hands over its torrents a page at a time and says how many there are in total. auditorr stopped at the first short page without ever comparing the two, so an instance that returned three of your torrents when it had said it had three thousand was treated as having answered completely — and every torrent missing from that answer was classified as orphaned, with its files offered for deletion. A listing that can't be shown to be complete is now a failed instance, which the scan refuses to save, Cleanup refuses to build a delete script from, and Trumped refuses to resolve a group with. - **With several qui instances, one that didn't answer left the others looking like the whole picture** — the Trumped workflow's group resolution asks your client for every torrent it knows. If an instance failed, that was logged and the listing carried on, one instance short, indistinguishable from a complete one. A cross-seed group built from it silently loses every member living on the instance that stayed quiet — and confirming the swap deletes the payload those members are still seeding. auditorr now refuses an incomplete listing rather than acting on it. (qBittorrent already behaved this way; the two backends disagreed.) - **A qui scan that hit its five-minute per-instance ceiling saved the partial answer as truth** — it warned to the log and nothing else, so nothing in the app or the debug report said the scan had only seen part of your client. The unanswered torrents are now located on disk like any other failed listing, and the incompleteness is recorded where the scan can act on it. - **A failed tracker lookup threw away a perfectly good file listing with it** — both calls shared one catch-all, so a tracker request timing out on an otherwise healthy torrent discarded its file list as well, and its files read as orphans on the strength of an entirely unrelated failure. - **Removing a torrent could delete files a second qui instance was still seeding** — the same torrent can be added to two qui instances. auditorr's live view of your client kept only the first one it saw and dropped the other without a word, so when you removed the torrent from one instance in Triage, nothing told the removal that the other instance was still using the files, and they were deleted with it. Each instance's copy of a torrent is now its own registration: the other one keeps the files, and a request that doesn't say which instance it means is refused rather than guessed. - **A drive that wasn't mounted could be saved as an empty library** — auditorr decides what is orphaned, and what is unseeded, by joining your torrent client's answer to what it finds in your torrent and media folders. A folder that wasn't there walked as an empty one, with a log line and nothing else: a missing media folder saved **every torrent as not imported** — filling Triage, dropping the health score and writing all of it into the record — and a missing torrent folder saved your **whole library as unseeded**, filling Backfill. The realistic way in is a container that starts before the array or a network share has finished mounting. A folder that is missing, or one whose category or release folders can't be read, now refuses the scan instead, and says which of the two it is and what to fix; a folder that is there but has suddenly lost most of its files is refused the same way a torrent client that forgets its torrents already was. A folder that is simply empty on a fresh install still scans — there is nothing to protect yet. At startup, a missing folder is retried at one, two and five minutes before it is recorded as a failure. - **A Sonarr that answered for most of your library looked like one that answered for all of it** — Sonarr needs one request per series, and a series that times out is deliberately skipped so a single failure can't discard the instance. But the survivors came back as a plain list, so forty missing series out of four hundred were indistinguishable from four hundred series with no files, and every episode underneath read as *Import Pending* or *Not in Library* — inviting a rescan of files that are fine, or worse. Partial reads are now reported wherever a full failure is: Triage, Backfill, and the Test button in Config, which no longer prints a file count it knows to be short. #### Files deleted that something still needed - **Removing a torrent from Triage could delete files a cross-seed was still seeding** — **Remove from client** deletes each torrent's files unless it finds another torrent still using them, and it "found none" in three situations where it had never looked: when your client didn't return the torrent's own file list, when it didn't return a neighbouring torrent's, and when the neighbour was a different size — a cross-seed carrying one extra `.nfo` is a few bytes larger, and only torrents of *exactly* the same size were checked. That last shape is worst on a **Dead Registration** row, whose files belong by definition to a working cross-seed: removing the dead registration could delete the file the cross-seed was seeding. Files are now deleted only where auditorr has established that nothing staying in your client uses them. Torrents are compared by the files they hold, not by size, and a cross-seed of a cross-seed counts. Wherever auditorr couldn't check, the torrent is removed and **its files are kept**, and the dialog says so before you confirm; the worst that leaves is an orphaned file, which Cleanup checks against your client again before it will delete anything. - **Trumped could delete a payload out from under a cross-seed it had missed** — a trump removes every torrent standing on the old release *with its files*, and the set it removes is worked out for you: you confirm the torrent you recognise, and auditorr adds every cross-seed sharing its files. Three things could quietly leave one out, and a left-out cross-seed stays registered on top of files that no longer exist. A cross-seed whose file list timed out was simply skipped, and the page showed a smaller group as if it were complete. A cross-seed carrying one extra file — a tracker-required `.nfo`, a different sample — was ruled out before its files were even looked at, because only torrents of *exactly* the same size were considered. And a torrent sharing files with a cross-seed, but not with the torrent you picked, was never considered at all. Membership is now decided by shared files alone, followed all the way through; a group auditorr couldn't fully check says so, and removing it needs you to acknowledge that first. A torrent client instance that doesn't answer still stops the swap outright, because a cross-seed living there is invisible rather than merely uncounted. - **Trumped now checks whether your library still holds a copy before it deletes anything** — the confirm step said *"your library hardlinks survive"*, and that sentence was the whole reason deleting files was safe; nothing checked it. It is false whenever the trumped release was never imported, was imported by copying, or has since been upgraded — on the reference setup, one torrent file in thirty. Every torrent in the group now shows whether a link to its files exists outside the group (normally your library copy), **could not check** where auditorr couldn't see the file, or **only copy** — in which case removing it asks again, in a dialog that names how much data will be destroyed. The server enforces the same rule, so a stale page can't skip it. - **A Trumped swap re-checks the group at the moment it deletes** — the list of torrents to delete was whatever the page had resolved, possibly many minutes earlier. A cross-seed script adding a registration while the page sat open, or a torrent rechecked into a different folder, still went into a delete with files. auditorr now resolves the group again immediately before deleting, and if anything has changed — a torrent gone, one no longer sharing the files, or a new one joined — nothing is removed and you're sent back to confirm the group again. - **Triage's remove dialog had stopped finding cross-seeds** — since the previous release it failed whenever a selected torrent was in your client, printed *"no cross-seeds"* under every row, and still let you remove, with files deleted by the rule above. It works again, and if it can't check, it says so instead of implying there's nothing to find. - **What the remove dialog shows is what happens** — auditorr checks the cross-seed groups again the moment you confirm. If anything moved — a cross-seed appeared or went, or a torrent shown keeping its files would now have them deleted — nothing is removed and the dialog shows you the new answer. A change towards keeping files goes ahead, and the result says so. - **Triage said "Not in Library" when it had simply failed to ask Sonarr/Radarr** — that verdict means *no arr has ever heard of this title*, and its description ended *"junk can be deleted"*. It was reachable when no arr answered at all: a failed library fetch came back as an empty list, "Sonarr/Radarr configured" is read off your settings rather than off the request, so no warning fired, and every row on the page fell to the one bucket that invites a delete — on files that, being not-imported, are the only copy you have. A new **Could Not Check** bucket now takes those rows instead, the instances that didn't answer are named at the top of the page, and the verdict that means "nothing knows this" is unreachable while something couldn't be asked. Rows that *did* match are unaffected: this suppresses a conclusion drawn from silence, not evidence auditorr actually has. - **A Cleanup delete script now checks your torrent client again before it is built** — "orphaned" means *no torrent in your client claims this file*, and that was decided by the last scan, which can be hours old (and is deliberately older while you work through a workflow page). In that gap a cross-seed tool can inject a torrent for exactly those files, you can re-add and recheck a torrent, or a torrent added while the scan was walking your disk can land — and the script would delete its data. The script can't check this itself: it runs on your host, usually without your client's credentials. So when you click **Generate Delete Script**, auditorr asks your client about the selection right then. Any file a torrent claims now is left out, and both the dialog and the script's header say how many. If your client — or any qui instance — doesn't answer, no script is built at all. The script is stamped with the time it was checked and warns you if you run it more than a day later. - **Cleanup could present the last copy of your data as safe, and couldn't see half of a cross-seed** — a file hardlinked at two places in your torrent folder, both torrents gone, is still the *only* copy of that data; deleting both paths destroys it. Cleanup listed just one of the two paths (the other could never be cleaned up through the page at all), and its only safety marker meant "the media library has a link", so a link anywhere else — a manual hardlink, a snapshot — was invisible. Every file now carries a state read off the disk at scan time — **library copy**, **linked elsewhere**, **only copy**, or **could not check** — and a cross-seeded file is one row listing all its paths, counted once. - **The delete script reported files it failed to delete as deleted** — `rm` wasn't checked, so a read-only mount, a permission error or an immutable flag printed `✓ Deleted`, counted the space as freed, and then blamed "concurrent activity" when the disk hadn't changed. Failures are now their own **FAILED** count, never counted as freed, and the script exits non-zero. A file whose name starts with `-` is also deleted rather than read by `rm` as an option. #### The wrong file replaced or linked - **Backfilling one episode could replace the whole season and orphan every torrent behind it** — Backfill searched every TV candidate as a **season pack**, however much of that season you were already seeding. Pick one unseeded episode out of ten, grab the result, and the whole season downloaded; because a backfill is rarely an upgrade, Sonarr parked the import, and auditorr then forced it through over *all ten* episodes. Nine library files hardlinked to torrents you were seeding were replaced, all nine of those torrents became orphans, and your health score fell on two counts at once — the direct result of running the workflow whose whole point is the opposite, with nothing having gone wrong. A season is now searched as a pack only when every episode file Sonarr holds in it is unseeded, which is the case a pack is genuinely right for: nothing it replaces was being seeded. Otherwise each episode is searched on its own, and an episode row never offers a season pack or a multi-episode release, even though an episode search can turn one up. The rows say which they are: `S01 season pack · 10 ep`, or `S01E05`. - **Backfilling a two-episode file could delete one of its episodes, and an episode row could be offered another season's episode** — Backfill offered an episode's row any release covering *some* of the file's episodes, so a file holding S01E01 and S01E02 was offered an S01E01 release, and the import that followed was allowed through too. Sonarr replaces the whole existing file when it imports an upgrade, so grabbing it deleted the file behind E02 and left the torrent seeding it orphaned. Separately, Sonarr describes a release's episodes by number with the season kept apart, and auditorr read the numbers alone, so an S02E05 release, or the special S00E05, was offered for S01E05, and an S02 season pack for an S01 row. A release must now cover exactly the episodes of the file it replaces, in the same season, and an import that names only part of a library file's episodes is never forced in. Found by assessing the 2026-09-10 outside review's first design amendment against the shipped season-pack rule. - **Backfill's forced import could move files out from under seeding torrents, or re-import the file it was replacing** — when Sonarr or Radarr parked a backfill's import, auditorr forced it through with no limit on what it imported, in the arr's default mode — which *moves* the files whenever the arr isn't tracking the download itself. And when the arr didn't say where the download was, it fell back to your library folder, whose only file is the one being replaced. A forced import now only ever touches the episodes that row was for, always copies (a hardlink, when your arr has hardlinks turned on), and stops and tells you when it can't tell where the download is or which episodes it covers, rather than guessing. The same copy-not-move rule now applies wherever auditorr forces an import from a folder. - **Dedupe could hardlink two half-finished downloads together and corrupt both** — auditorr knew what your client was *doing* with each torrent (seeding, downloading, paused) but never whether the payload was actually finished, and the two are not the same question: a **paused** torrent at 40% and a paused torrent at 100% are the same word. So an unfinished file was a duplicate candidate like any other. qBittorrent doesn't pad files out as it goes — it writes **sparse** files, which report their full final size while the parts not yet written read as zeros. Two unfinished files of the same size whose written parts don't overlap therefore look identical: same size, same quick fingerprint, and they **pass the `cmp` check in the generated script**, because at that moment both really are zeros in those places. The clean case is two torrents queued at 0%, which are genuinely byte-identical. The script hardlinks them, two torrents write into one file, both are ruined, and there is nothing to recover from. `cmp` is the last line of defence for every other way Dedupe can be wrong, and it is no help here — so nothing your client hasn't confirmed as finished is a duplicate candidate any more, including anything it gave no straight answer about. A missed reclaim costs disk space; the alternative costs two torrents. - **Dedupe could trade two files' paths instead of linking them** — when one copy of a duplicate was hardlinked into your library, the pair showed up as two groups, and the script linked each copy onto the other: the first copy's torrent path ended up on the second copy's data, and the second's on the first's. Nothing was freed, and both torrents stopped reading as imported, so your health score fell. A group is now one set of identical files — every path of each file listed together — and nothing is ever linked in two directions. (The redundant groups were first reported by essentrix83, in their fork, as reciprocal aliases.) - **A failed link could delete the file it was meant to replace** — the script used `ln -f`, which removes the destination before it finds out the link can't be made. That can happen on a pooled filesystem that refuses a link between two of its drives, which all report the same disk: the copy was gone, and nothing replaced it. The script now makes the new link under a temporary name beside the file, checks that it really is a hardlink of the kept copy — a pooled filesystem can answer a link with a copy or a symlink — and only then renames it over the original, so nothing is removed before its replacement is in place and no path is ever missing. Found by essentrix83 (issue #17 and their fork). - **Relinking part of a file broke its import** — a file hardlinked at more paths than auditorr lists (a cross-seed's second path, a snapshot) had only the listed path replaced. That frees nothing and splits the file, which for a torrent file and its library copy means the torrent stops reading as imported. The script now counts each file's hardlinks before touching it and leaves alone any file it can't replace completely. Space counts as freed only when a file's last link is replaced, and a run that stops part-way through a file finishes it the next time. - **Dedupe could change who can read a file** — every path of a hardlinked file shares one owner and one set of permissions, so linking a copy gave its path the kept copy's. Where qBittorrent, Sonarr/Radarr and Plex run as different users, that could lock one of them out of a file it used to read. A copy whose owner or permissions differ is now reported and left alone. - **A double-submitted trump grabbed the replacement twice, and a failed grab left you with nothing** — Trumped removed the old torrents with their files first and grabbed the replacement second, so a grab that failed after the removal reported success at removing and left you with neither the old release nor a new one. It now grabs first and removes only once Sonarr/Radarr has accepted the grab; a failed grab removes nothing, and a removal that fails after a successful grab tells you the replacement is on its way and the old torrents are still in your client. The group is also re-checked between the two, because a replacement saved onto the same files joins the group — removing it with its files would delete the replacement. And every execute is now one operation: clicking twice, or retrying a request that never answered, runs it once and replays the first answer, where the second click used to be a second download (the queue check in front of it only sees a download the arr has already queued). - **"Imported successfully" for an import that never happened** — after a grab, auditorr follows the download into Sonarr/Radarr. The follow-up reported success for five different things, because each of them ended as an empty answer: a queue it couldn't read at all, a download that never appeared, a download that **failed** in the arr, one that was removed, and a genuine import. Backfill rows and the import panel showed a green tick for all of them. Success now means one thing — the arr's file for that movie, or for the episodes that row was for, actually changed — and every other ending says what it was instead. As part of this, a download **still in progress** is never force-imported: after the two-hour wait auditorr used to force the import of a file that was still being written, three times. And where your indexer supplies an info hash, it is used to tell this download apart from another download of the same series in the queue, which could previously stand in for it. #### Files hidden from auditorr's own view - **One click in Cleanup could hide your entire media library from auditorr** — Cleanup groups orphaned files by release folder, and a single-file torrent saved straight into a category directory (`movies/Film.2020.1080p.mkv` — what qBittorrent does by default for single-file torrents) has no release folder. Every such file in the category collapsed into one group called `movies`, with a header checkbox that selected all of them, and *Exclude* then wrote the rule `movies/`. A trailing slash means "this subtree", and the match runs against both of auditorr's walks — so that rule excluded the **media library** as well as the torrent folder. Everything under it stopped being scored, the health score jumped, Backfill emptied, and File Explorer hid it, while the toast read *Added 1 exclusion rule*. A group that isn't a release folder now says so (**category folder**) and only ever produces one rule per file. (Recoverable if it happened to you: the rule is visible and editable in Config → Excluded Files & Folders.) - **Excluding a Cleanup folder could hide a torrent that was still seeding** — Cleanup offered to exclude a whole release folder whenever the folder was deep enough, and never checked what else lived in it. One stray file inside a live torrent's folder — a sample a repack left behind, an old `.nfo` — made a one-file group whose **Exclude** wrote a rule for the whole folder, taking the seeding torrent's files out of scoring, the hardlink figure and Backfill. auditorr now offers a folder rule only where every file under the folder is orphaned — including a live cross-seed's second hardlink, which sits on no file record at all — and the folder isn't a category directory shared with your media library. Where it isn't safe, the group says why and excluding writes one rule per file. - **A Sonarr/Radarr outage could be forgotten between two requests** — Triage's *Could Not Check* bucket exists so that "no arr answered" can never read as *Not in Library*, whose files are the only copy. The failure and the library it described were stored separately, and with several requests in flight a successful refresh landing between the two left one request holding an empty library and no failure — and *Not in Library* back. The two now travel together. Trumped reads them the same way. - **A file caught halfway through a move was reported as a duplicate, an orphan, and 3.5 GB you could reclaim** — delete a file that a process still has open and the filesystem can't free it, so it renames it out of the way and drops that name when the last handle closes: FUSE (which is what Unraid's user shares are) uses `.fuse_hiddenXXXXXXXX`, NFS uses `.nfsXXXX`. **The file has already been deleted; what's left is the filesystem's note that it hasn't finished.** On the reference setup Unraid's mover copied a 3.5 GB episode from cache to the array and couldn't unlink the source because qBittorrent had it open — so one file mid-move appeared as two, on two different disks, and every workflow with an action offered to do something about it. **Dedupe** picked the *tombstone* as the copy to keep and would have replaced a real file with a hardlink to a name being removed (`cmp` can't catch that — the two are the same file). **Cleanup** offered it as an orphan worth 3.5 GB, where deleting frees nothing, because the handle owns the file rather than the name. **Triage** lost the one-rule exclusion for the release folder it sat in. And it counted against your health score and your Sentinel streak. Not one of those actions would have helped: a tombstone goes when the process closes the handle, and nothing auditorr generates can hurry that. They are now ignored everywhere, on every install, without configuration. They remain visible in File Explorer marked as excluded, and in Cleanup's Excluded count, so nothing is hidden from you — they're just no longer treated as media. #### A half-finished picture saved as the truth - **An interrupted scan could leave half of one audit and half of another** — a scan saved its results in sixteen separate steps: the file lists, the health score, the change log, the upload history, your Rounds progress and more, each on its own. If saving failed part-way (a full or read-only data volume), everything written before the failure stayed. Your torrent list could then belong to one scan and your media list to the one before it, so a file read as **unseeded in Backfill and not imported in Triage at the same time**; and because the upload history and the change log were written *first*, the next scan measured itself against entries for a scan that never finished saving — a spike in the upload chart, or every file reported as coming back. A scan now saves everything in one step: it all lands, or none of it does and the last complete scan is left whole, with the run recorded as an error that says so. - **A workflow page could combine two scans** — Dedupe builds its groups by reading your torrent list and your media list and joining them, and the Config page's health-score refresh reads both and **saves the result back**. A scan landing between those two reads handed the page one list from each: Dedupe could show a group that never existed, and saving Config could store a health score built from two different scans until the next one ran. Pages that read more than one list now read them all from the same scan. Cleanup and Triage read theirs the same way. #### Generated scripts - **A file name could run a command inside a generated Dedupe script** — the same hole Cleanup's script had: file names, and the configured folder path, were written into `#` comment lines raw, and a newline ends a comment. Nothing path- or config-derived reaches a comment unescaped any more. (Reported by the 2026-09-10 outside review as S10.) - **A file name could run a command inside a generated delete script** — the script wrote each file's name into a `#` comment line, and a newline ends a comment, so a file whose name contained one turned the rest of its name into a command in a script you were told to run. Nothing path-derived reaches a comment unescaped any more. - **An empty selection generated a script for every duplicate group** — posting no groups, or opening the script's address directly, built a script for all of them. A Dedupe script is now only built for groups you selected, as Cleanup's already was. ### Improvements - The debug report gains a **source health** section: how completely your client could be asked on the last scan, and why a scan refused to save its results if one did. It now also counts the torrents your client says aren't finished, and any it gave no usable completion figure for. - **A page that hits a bug shows the error instead of going blank** — an error while drawing any page took the whole app down and left only the background, with nothing on screen to say why and nothing in the logs. The page now shows the error and which part of it failed, the sidebar keeps working so you can go somewhere else, and *Try again* redraws it (it also tries again by itself after the next scan). The error is written to the server log too, so it shows up in `docker logs` and in the debug report. An error outside a page, in the sidebar or a dialog, shows the same message over the whole window. - **Dedupe reclaims cross-seeded copies instead of leaving them alone** — the script replaces every path of a file or none of them, and it counted a file's hardlinks before touching it precisely so it could refuse the ones it couldn't replace whole. But auditorr only recorded *one* path per file in each folder tree, so a copy cross-seeded into two tracker directories looked like a file with a hardlink auditorr had never seen, and every group where more than one copy was cross-seeded was left alone: correct, and nothing freed. Each scan now records every path a file has in your torrent and media folders, so those groups link and the space is actually reclaimed. A copy that really does have a link auditorr can't see — a snapshot, a backup tree, or a path hidden by an exclusion rule — is still left alone, exactly as before. **Found and fixed independently by [Joloxx9](https://github.com/Joloxx9) ([#24](https://github.com/thrill-burn/auditorr/pull/24)), against the previous release, where the same gap split the file and claimed the bytes anyway.** - **Text on the workflow pages now follows one type scale** — Backfill, Cleanup, Triage, Dedupe and Trumped (and the script and Import Jobs panels that open over them) sized their text with hardcoded numbers, most of which weren't on the scale the rest of the app uses; half of them sat exactly between two of its sizes. Every piece of text is now sized by what it *is*, so the same kind of text is the same size on every page: section headers agree with each other, the paragraph under a page title matches the prose beneath it, and readouts like sizes, counts and ages are one size rather than four. The most visible change is **status tags and badges — the quality, HDR, match, tracker and verdict chips — which were 9, 10, 11 and 12px and are now all 11px**, so the smallest text on the busiest pages is easier to read. A few release names in Trumped's lists now wrap onto one more line as a result; they have always wrapped rather than truncate, because the release name is the thing you're checking. - **Warnings, alerts and errors outside the workflows render as boxes again** — the same dropped-colour bug was on the rest of the app: the error banner across the top of every page, the dashboard's threshold alerts and their *View* buttons, Audit History's load error, Config's "every weight is zero" warning and the Sidebar's scan-error box were all drawn as loose coloured text with no box around them. Selected options and search fields that hold a value get their faint highlight back, in Config, the file browsers, Audit History, Trackers and the setup wizard, and the score chart finally draws its line under the point you're hovering. Row tags (*orphaned*, *seeding*), the *Open in Sonarr/Radarr* actions, the sidebar's counts and Config's run badges stay as coloured text rather than becoming filled pills: that is how they have always looked, and a pill on every row of a long list is a lot of colour. A test now fails the build if the broken colour spelling comes back anywhere in the frontend. - **The workflow pages share one set of buttons and one heading style** — every workflow had grown its own buttons: seven different paddings, five corner radii, two weights, and white text on orange in one place where everywhere else used dark. None of them responded to hover. They are now one button in three sizes — page-level actions, small controls, and the chips inside a row — with a hover state, and Rounds' *Open …* buttons are the same button. Headings follow Triage's: a section heading is bold, a sub-heading semi-bold, and an item's title medium, so Cleanup's and Dedupe's folder and file names no longer outweigh the heading above them and Backfill's section labels stand apart from their sub-labels. Also tidied: the results screen of a Backfill search centred its "Workflows" label while everything under it was left-aligned; Trumped's *Confirm & find cross-seeds* stretched into a full-width orange bar; and the empty-state 🎉 is now a simple check icon. With no candidates, Backfill's Search Depth *All* choice no longer offers 999. - **Every filter in auditorr is one control, and every button one of two heights** — with the colours drawing properly, a pill and a button looked equally clickable, and there were fifteen shapes doing five jobs: File Explorer alone had Status as pills, View and Sort as bordered pairs doing the same job four controls along, a third shape for Duplicates and Excluded, and controls at four heights in one toolbar. Every filter — File Explorer's Status, Import, View and Sort, Backfill's indexer, folder and quality rows, Audit History's categories, the Dashboard and Trackers date ranges and metric tabs, Config's toggles and presets, the setup wizard's source choice, the changes panel — is now the one inset track the date ranges already used, whether it takes one choice or several. So are Backfill's Release Ranking, Priority and Search Depth choices. They were taller cards with every name in bold and an orange edge on the chosen one, and now each option's short description sits beside its name. File Explorer's per-file info and copy-path buttons are proper icons: the copy button was a faint symbol you could barely see, especially on the light theme, and copying now confirms that it worked. Backfill's title filter, File Explorer's filename search and Audit History's path search are now the same search box. Config's Health Score boxes match the other fields and show when you're typing in one. The theme switch uses icons instead of emoji. The link between Cleanup and Triage is a sentence with a small button instead of a dashed orange box. File Explorer's size filter has one MB/GB/TB switch for the whole range instead of two browser dropdowns, and its boxes take decimals, so 500 MB to 2 GB is 0.5 to 2 GB. Audit History's Trigger and Type columns, and the changes panel's categories, are plain text and a coloured dot, so they no longer look like buttons you can't click. Rounds' secondary text (streak notes, rung readouts, prize names and the section notes) was drawn in a grey so faint that on the light theme it was barely visible. It now uses the same grey as the workflow pages, as does the folder line under each file in File Explorer's flat view. Rounds no longer says "Only when a tracker asks" twice, and Cleanup and Dedupe describe the most a script can free in the same words. Rounds' workflow cards are shorter. A card that wants something no longer repeats what the workflow does, since its page says that, or explains its streak twice: both are now hover text on the name and on the streak line. Each card's state reads as coloured text instead of an outlined badge, and Rounds' headings match the workflow pages'. The subtitles on Cleanup, Triage, Dedupe and Trumped are each one line, and Cleanup's piles explain themselves in one sentence each. On the dark theme a selected option now stands a shade lighter than its track, with a faint edge, as it always has on the light theme — it used to be a shade *darker*, the same colour as the panel behind it, so it looked like a gap in the row rather than a button that was pressed. Pointing at an option you haven't selected now shades it faintly, the way the buttons already respond, so each one shows it can be clicked. Actions such as *Copy Paths*, *Export CSV*, *Test Connection* and every ✕ that clears a search are the workflow pages' button, and pills are left only for things you can't click. Controls in a toolbar share one height — 28px in File Explorer, 34px everywhere else — so the small and standard buttons on the workflow pages grow by 3px and 1px to match. Every close button is one icon, the Config and setup-wizard fold-outs use the same chevron as Rounds, and the rest of the app joins the workflow pages on the type scale: the summary numbers at the top of File Explorer and Audit History drop from 22 to 20px, the smallest text rises from 10 to 11px, and the Trackers chart no longer clips its last date. - **Rounds sits above Workflows in the sidebar** — it is the summary of which workflow needs you and in what order, so it now comes before the five pages it points to rather than after them, where the expanded Workflows group pushed it five rows down. - **Config's disc-rip and media-server presets have an *All* option** — with every preset in a group switched on, the row was one even shade, and on the dark theme that looked the same as every preset switched off. *All* now lights up on its own when the whole group is on, the same way *All* reads on the filter rows elsewhere, so an evenly shaded row always means none. Click it again to switch the group off, or click one preset to keep only that one. What each preset excludes is unchanged. - **File and release names as row titles are a size smaller** — Cleanup's folder names, Dedupe's group names, Backfill's release titles and Trumped's torrent names are set in the monospaced face, which runs wider than the rest of the app's text, so at the same size as a sans title they read a step larger and towered over the 11px paths beneath them. They are now 12px, down from 13. - **The Dedupe badge counts the groups the page lists** — the sidebar showed how many duplicate *files* there were beside a page of *groups*, so a page of three groups could carry a badge of seven. It now counts groups, including ones changed since the scan. The duplicate figure behind your health score, the change log and Rounds is unchanged. - **The Dedupe page no longer loads your whole library** — the page and its script read your full torrent list and your full media list to use the small part of them that has duplicates or is excluded. Each scan now saves that part on its own. On a synthetic 650,000-file library the page's memory peak fell from about 1.8 GB to about 140 MB, and it opened in under two seconds instead of eight (more if you exclude a lot: excluded files still ride along). The first visit after upgrading still reads the full lists, until the next scan. - **A Backfill candidate you've grabbed isn't offered again** — it used to stay on the list, and in the next search, until a scan landed. It now leaves the page as soon as Sonarr/Radarr accepts the grab, and comes back only if the next scan still finds it unseeded. A grab that failed, or was refused because it was already queued, keeps its row. - **Backfill can filter for SD and DVD releases** — a **480p / SD** resolution chip and a **DVD** source chip. DVD and 480p releases were already ranked, but no chip could select them. Radarr reports a DVD release with no resolution at all, so the SD chip also goes by the quality name. - **Backfill shows an unmatched video beside Sonarr/Radarr's own file of the same name** — when videos in your library don't match your Sonarr/Radarr library, Backfill now shows one of them next to the path the arr has for a file of the same name, which usually makes a path-mapping mismatch obvious at a glance. Nothing is shown where no arr file shares the name. - **Trumped says when two Sonarr/Radarr instances hold the release** — with a second instance (a 4K Radarr, say) holding the same files, step 4 now says which instance the search and the grab go to and which other one holds the files, as Triage already does, instead of picking one silently. - **Config warns about an exclusion rule that can't match** — a path you typed by hand containing `[` (common in anime and scene names) is read as a pattern, where `[SubsPlease]` means "any one of those letters", so the rule silently excluded nothing. Saving now warns about it and suggests `literal:`, which matches the path exactly. The rule is still saved as you wrote it. - **Published images are tested first** — the Docker image for `main`, `experimental` and releases is only built once the backend test suite passes, on the same Python the container runs. - **A torrent on two qui instances is two rows, and each is checked on its own** — Triage lists each instance's registration separately, Trumped shows both in a group with the instance beside each, and removing one is confirmed against that instance. It used to report *removal unconfirmed* for a removal that had worked, because the other instance still held the torrent. Where a request can't tell the two apart, the page says which instances hold it and does nothing. Upload totals now count each instance's upload, and seeding size counts files on disk once where two instances share them and twice where each has its own copy. None of this changes anything on a single qBittorrent or a single qui instance. - **The import panel says what actually happened** — a grab you follow now ends in one of several answers rather than Done or Failed: **Imported** (auditorr saw the arr's file change), **Download failed**, **Never queued**, **No new file**, **Could not check**, **Still downloading** or **Unconfirmed**, each with the reason beside it. Only a confirmed import is green. - **A scan that refuses to save says what to fix** — the message used to end "run a scan manually to accept it" whatever had gone wrong, including the cases where a manual scan changes nothing. It now tells you to fix the instance, the path mapping, the mount or the permissions, and only offers acceptance where accepting is the right answer. The debug report gained the reference counts and, per folder, how many files were walked and how many folders couldn't be listed. - **Dedupe links what it can across drives** — a group whose copies sat on different disks was skipped whole. Worse, on an Unraid share or a mergerfs pool every drive reports the same disk, so such a group was never recognised as spanning drives at all, and the script tried links the filesystem could refuse (essentrix83's observation, in issue #17 — measured on the reference setup as one disk reported for three drives). The script now checks the disk itself when it runs and links the copies that share one; where a link fails between the drives of a pooled share, it tries the copies that failed against each other before leaving any alone. On an Unraid share those links generally just work — the share makes each one on the kept copy's drive — so the page shows such groups like any other. - **Dedupe says what linking a group does, and never promises the bytes** — the page led with **Recoverable**, a figure that could be double the truth for a single group, and selected every group for you. Each group now says whether linking it **repairs a missing hardlink** — a torrent file and a library file holding the same bytes that were never linked (an import that copied, or a copy made by hand), where linking makes the torrent read as imported as well as freeing the space — or removes **duplicate copies**. The headline says how many files would share a copy and how much that frees **at most**, with each file counted once however many paths it has, and nothing is selected for you: which copy is kept is decided by the script when it runs. A group carries a label only when something needs saying — **different disks**, **could not check** (a file auditorr couldn't read, or a copy outside the folder the script runs from), or **changed since the scan** — as a dot and a word, and a group that has changed since the scan can't be selected. - **The Dedupe script has a dry run, can be run twice, and says what it did** — `bash dedupe.sh --dry-run` checks and compares everything and changes nothing, and a second run reports everything as already linked. Every file gets an outcome — linked, already linked, left alone with the reason, or FAILED, which makes the script exit non-zero — and the summary reports the space actually freed. It says up front how much `cmp` will read (both copies of every file, which on a spinning array can take hours), warns when it was built more than a day before you run it, and refuses to start without GNU `stat` rather than misread a BSD one. - **Triage rows say more about what they are** — a torrent whose files earned different verdicts says so (`18 files · 3 verdicts`) and takes the verdict whose action deletes least, where it used to take whatever its largest file earned. A row that lists only part of its torrent — a partly imported season shows the episodes that didn't import — says `10 of 18 files · 40 GB of 72 GB`, and the size beside it, and in the selection bar, is the whole torrent's, since that is what removing it removes. When two Sonarr or Radarr instances hold the title (a 1080p and a 4K library, say), the row names both and says which one a rescan or force import goes to — the one whose file matches the torrent's quality — and such a row never pre-selects a whole cross-seed group for removal. Every row shows when your client added the torrent, and **Dead Registration** rows say where the data is still alive: your library, a seeding cross-seed, or both. - **Triage's live check lists each qui instance once per page** — verification runs in batches, and every batch fetched the full torrent list of every instance involved: a large page on a large qui instance fetched it seven times. It is now fetched once and reused for a minute, and forgotten whenever auditorr removes a torrent. - **Cleanup leads with what survives a delete, not with how much it frees** — Cleanup is the only workflow whose result can't be undone, and the page sorted by size with **Freed If Deleted** in green: the irreversible deletions came first and greenest, and the one safety fact was a small grey chip. Orphans are now split into **Your library keeps a copy** (lossless — deleting frees nothing and loses nothing) and **This is the only copy** (permanent), oldest first within each, with each file's state beside it and size as a plain readout. Files the last scan couldn't fully check sit under **Could not check**, visible but not selectable. The totals lead with the same split, **Freed at most** is labelled as the upper bound it is, and nothing is ever selected for you. A group that can't take a folder rule says why. - **Cleanup opens without reading your entire torrent list, and always shows file ages** — the page parsed every torrent file record to show the orphaned few (the same cost Triage shed in v1.7.0), then looked up every orphan's age on disk on every visit, and above 5,000 orphans quietly showed no ages at all. The scan now saves Cleanup's working set separately, with each file's age and link count read once during the scan, so opening the page touches neither the full list nor the disk. A file whose age couldn't be read says **age unavailable** instead of showing nothing. The page falls back to the old way until your next scan. - **The delete script is safe to run twice, has a dry run, and tidies up after itself** — running a finished script again used to stop with "Cannot find files. Are you in the correct torrent directory?"; a second run now reports everything as already gone and exits cleanly (a genuinely wrong directory is still an error). `bash orphaned_torrents_delete.sh --dry-run` prints what it would do and touches nothing. After deleting a release folder's last file the script removes the folder if it's empty — never a category folder, and never a folder with anything left in it — so emptied release folders stop piling up where no scan will ever mention them. Byte counts no longer depend on `bc`, which Git Bash and some NAS systems lack. - **Cleanup can exclude release folders saved without a category directory** — a torrent saved straight into your torrent folder has its release folder one level down, and Cleanup refused to write a folder rule for it (the same depth test Triage dropped in an earlier fix). It now uses the same checks as Triage: nothing else lives in the folder, and it isn't shared with your media library. - **Trumped puts the replacement to grab front and centre** — step 4 used to be a flat list. It now leads with one card, **Grab this one**: the exact replacement on the tracker that sent the PM, pre-selected. If that tracker hasn't listed it yet, the card shows the same release from another tracker and says you'd be cross-seeding it there. Everything else sits under **Other releases** for the cases that need it, and a near match is never selected for you. - **Trumped follows the replacement into your library** — the replacement was grabbed and then forgotten, even though the old files were already gone. It now gets the same follow-through as Backfill: it appears in the import panel at the bottom right (now **Import Jobs**), a stalled import is pushed through for exactly the episodes that were trumped, and the page shows the download's progress. Trumped also finds the Sonarr/Radarr entry from the files the trumped release was imported as, rather than by title alone — and when the files and the new release's name point at different entries, it asks you which rather than picking one. - **Trumped can grab the replacement without deleting anything** — with *Workflow torrent deletion* off (the default) you could work through every step, watch auditorr find the exact release, and then be refused at the last one. The grab isn't destructive and no longer needs the setting: **Grab only** grabs the replacement and leaves the old torrents for you to remove in your client. A replacement that's already in Sonarr/Radarr's queue — a double click, or the arr having grabbed it by itself — isn't grabbed twice unless you say so. - **Trumped's confirm step shows everything a delete should** — each torrent now shows its hash, the client instance it lives on, its tracker, whether that tracker has already dropped it (which is your tracker confirming the trump), how long it has seeded, what it uploaded, and the library-copy column. It also explains why rows can't be deselected, and what to do if you want to keep one. The total now says the payload is counted once, since cross-seeds share it. - **Triage and Trumped point at each other** — a dead seed in Triage is often a trump whose PM you missed. Dead-seed rows now carry **Trumped? ↗**, which opens the swap with the release filled in, and the two pages link to each other. - **Finding matching torrents for a season-pack trump is many times faster** — each trumped episode was compared against every torrent in your client from scratch. Names are now parsed once per request: 20 titles against 5,000 torrents went from about 5 seconds to a third of one. - **Backfill can offer the release you already have instead of the biggest one** — releases are ranked the way Sonarr and Radarr rank an interactive search: custom format score, then quality, then seeders. That answers "what is the best copy of this?", and it stays the default — it's the order your quality profile already tunes. But it isn't the only question a backfill asks, and the page was already showing everything needed to answer the other one: **Closest to my file** puts first an exact size match, then one within 1% (a scene torrent's `.nfo` and sample make it a little bigger than the video alone), then the same quality, then the same HDR, then seeders. That's the release your file came from, so grabbing it gets you a seed without changing what your library holds. Whichever order you pick, every release now shows its size against your file (`= exact`, `+1.2 GB`) and whether size, quality and HDR agree. - **A Backfill search survives leaving the page** — the search's only handle lived in the page, so navigating away lost it for good while it carried on querying your indexers for results nobody could see, and starting a search in a second tab silently stopped the first. Backfill now picks a running search back up when you return in the same tab; starting a search while one is running shows you that one instead; a search nobody is watching stops itself after ten minutes; and a finished one is kept for an hour. The progress poll also sends only the rows that finished since the last one, where it used to resend every release for every candidate found so far, every two seconds, for as long as the search ran. - **Backfill says which unmatched files are worth your attention** — "Of 2,612 unseeded files, 15 matched your Sonarr/Radarr library" couldn't tell a pile of subtitles and posters (normal — no arr indexes them) from two hundred video files Sonarr and Radarr don't recognise, which is a real problem. Search Depth now counts the unmatched **video files** on their own and says what they probably mean: a path-mapping mismatch when little of your library matched, or files Sonarr/Radarr don't track at those paths when most of it did. - **Backfill's Root Folders are your actual root folders** — the chips were the first folder below your media path, which merged roots that sit more than one level down and had nothing to do with what you configured in Sonarr and Radarr. They're now the root folders those apps report, with anything outside all of them under **Other**, and the instance named when two instances use the same path. A folder selection saved before this update is ignored rather than quietly filtering your search down to nothing. - **Exclusions show you the exact rule before they are written** — *Exclude* on Cleanup and Triage used to post immediately and report a count, so the one thing you could not see was the thing being saved. Both now open a confirmation listing the rules verbatim, with what they were built from and where they will live. Triage's one-click suggestion chips are unchanged: those already show their rule (`ext:sfv`, `contains:sample`) on the chip itself. ### Bug Fixes - **Warning boxes, danger buttons and coloured chips on the workflow pages were drawn without their borders** — auditorr tinted colours in a way browsers silently reject, so the rule was thrown away whole rather than drawn faintly. Every yellow warning and red error on Backfill, Cleanup, Triage, Dedupe and Trumped was loose coloured text with no box around it; the destructive buttons — *Generate Delete Script*, *Remove from qui*, *Remove group* — had no outline, so they looked less like buttons than the harmless one beside them; and the coloured chips on a row (*Grab*, *radarr ↗*, *Dedupe ↗*, *Trumped? ↗*, *Failed ↺*) and Backfill's *← New Search* read as coloured words. All of them now draw as designed, and a test keeps the broken spelling from coming back. (The rest of the app had the same bug; see *Warnings, alerts and errors outside the workflows render as boxes again* above.) - **Saving Config reset three Rounds tiles to zero until the next scan** — the health score is refreshed from your last scan when you save settings, and that refresh dropped the figures a scan measures but can't rebuild from the file lists: seeding time and your oldest media file. **Atlas**, **Old Faithful** and **Provenance** read 0 until the next scan. Nothing earned was lost — those medals remember their highest point — but the current values vanished. They're kept now. - **Backfill could report a Sonarr/Radarr instance as healthy when its own read of it had failed** — Backfill read the error list and the root folders separately from the library list, so another page refreshing the same data in between could leave Backfill showing a missing instance with no warning, and folder chips from a list it hadn't used. All three now come from one read, as Triage and Trumped's already do. Closes the last part of the 2026-09-10 outside review. - **Groups of more than eleven identical files were cut short** — each file remembers at most ten of its identical siblings, and the page took one file's list as the whole group. Fifteen copies of one file became five overlapping groups of eleven: the headline counted more than three times the space there was, and one file could be the copy kept in one group and a copy replaced in another. Groups are now joined from what every file remembers. - **A duplicate group could change its id between scans** — the id was whichever copy the scan happened to reach first. It is now the group's first path in alphabetical order, which a scan doesn't move. - **Files sharing only a header and trailer were offered as duplicates** — duplicates are found by size and then a quick fingerprint, which read only the start and the end of each file. Two encodes with the same container header and trailer matched, were counted in the headline and the health score, and were only found to differ when the script compared them in full. The fingerprint now reads the middle too. The first scan after updating may drop a few duplicates from the dashboard and the change log for that reason. - **A symlink could be replaced with a hardlink** — a symlinked file is read through to its target, so it appeared as another path of the same file, and the script would replace the link with a hardlink, silently changing what that path is. The script now leaves anything that isn't a regular file alone. - **Triage could confirm an import that never happened** — after **Trigger Rescan**, Triage watches Sonarr/Radarr's own file id and clears the row when it changes. A read that timed out came back looking like a real answer of "no file", which differed from the one before, so the row cleared and the toast said *import confirmed*. A read that fails now counts as "couldn't ask". A Sonarr row also watches **its own episodes** instead of the whole series, so Sonarr importing some other episode of a busy show no longer clears it. - **Removing torrents cleared rows that were still in the client** — every selected row disappeared on any success, whatever the client actually did (on qui, a request that failed half-way lost the answers for the half that worked). auditorr now looks at your client afterwards: rows whose torrent left go, and a torrent that is still listed, or sits on an instance that didn't answer, stays, marked **removal unconfirmed**, until the next scan. - **A torrent removed from your client since the last scan stayed on Triage** — marked as verified, with a delete button that removed nothing. It drops as soon as live verification sees it's gone. On qui, a torrent on an instance that didn't answer is never treated as gone. - **A `.m4v` film was offered as a junk extension** — Triage's *Tidy up* chips treated any video format outside a short list as a sidecar file, one click from excluding it. It now uses the same video list as Backfill. - **Triage's "Showing the 500 largest torrents" banner** — two separate limits applied, one to torrents and one to dead registrations, only one was reported, and the number in the banner was hardcoded. One limit now covers the whole list, and the banner says how many of how many. - **Cleanup's "Select all" could read "Select all" with everything already selected** — its label counted every orphan from the scan while the button acted on what was on screen, so after excluding a few rows the two disagreed. Selection is now per pile and per folder, and each checkbox compares against exactly the files it selects. - **A script that couldn't be built was shown as a script** — an error building a Cleanup or Dedupe script appeared inside the code box as `# Error loading script: …`, where it could be copied or downloaded like one. The dialog now shows the reason instead, with nothing to copy. - **Removed: generating a delete script over every orphan at once** — `/api/actions/script/orphaned_torrents_delete` without a selection built a script for your entire orphan list, and a second script type, `delete_selected`, did much the same for any paths posted to it. Neither had a button anywhere in auditorr, and both were a one-request "delete everything". The Cleanup script now requires a selection, and `delete_selected` is gone. - **Trumped could pre-select the release that had just been trumped** — when a release is replaced by its own REPACK, the old and new names differ only by `REPACK`, which the matcher treats as a quality word rather than part of the name. With the PM's tracker chosen, the old release — often still listed there — ranked above its replacement. Only an exact name match can now lead the list or be pre-selected. - **A trump PM without a "Reason:" line produced an empty list** — auditorr stopped reading the new release name only at a literal `Reason:` line, so a PM without one had its sign-off (or the tracker's own boilerplate) glued onto the name, and nothing matched. A greeting above the release was read as a trumped release too. The name is now one line, and PMs saying *has been trumped by*, *superseded by*, *replaced with* or *has been replaced by* are understood as well as *will be replaced by*. - **Trumped re-audited seconds after deleting** — that scan recorded the moment between removing the old torrents and importing their replacement, so every swap put a dip in the hardlink score, the health chart and the change log. The audit now runs once the replacement has imported. - **Editing Trumped's inputs left the old results on screen** — changing a title, the tracker or the new release name after a step had run kept that step's answer, so the tracker preference could reflect a tracker you'd since changed. Later steps now clear when what they were built from changes. Two identical lines in a PM also no longer select and deselect together. - **Trumped's tracker priority missed trackers named differently between your arrs** — the indexer list is pooled across every Sonarr and Radarr, so one tracker can be `Aither (API) (Prowlarr)` on one and `Aither` on another, and the exact-match check treated those as different trackers. - **A failed Backfill grab could download the release twice** — any failure to grab, including a timeout on a request Sonarr or Radarr had in fact carried out, triggered a fresh search and a second grab without asking. Only the one failure a new search actually fixes — the release having dropped out of the arr's short-lived search cache — is retried now, and only once; anything else is shown to you as it is. A release that is already downloading in the arr's queue isn't grabbed again unless you click **anyway**, which also stops a second search from grabbing the same thing twice. - **Backfill couldn't search episodes of daily or anime shows on their own** — searching a single episode needs Sonarr's id for it, and auditorr expected to find that on the file record Sonarr sends, which never carries one. It fell back to reading `S01E05` out of the filename, which a daily show (`Show - 2024-01-05`) or absolute anime numbering (`Show - 087`) doesn't have, so those rows failed with *Could not determine episode ID*. The episode is now looked up in Sonarr's own episode list, by file, which works whatever the naming. It matters more now that a partly seeded season is searched an episode at a time. - **Excluding a file whose name contains `[`, `*` or `?` either did nothing or hid too much** — exclusion rules built from a real path were matched as glob patterns, and release names are full of glob characters. A path containing brackets — the norm for anime, common in scene releases — became a character class that could not match the very file it was made from, so the row vanished, the toast said success, and the file came back on the next audit with no explanation. A path containing `*` or `?` failed the other way: it matched itself **and its neighbours**, quietly dropping files you never selected out of scoring. Both are closed by a new `literal:` rule that means exactly what it says; every rule auditorr writes for you now uses it. Rules you typed yourself are untouched — if one of them has this problem, it is still there and still visible in Config, because silently rewriting your exclusion list is worse than leaving it. - **Excluding a partly-imported torrent also hid the files that had imported fine** — Triage lists the files of a torrent that are *not* in your library, and a multi-file torrent was excluded by its common folder. For a torrent where some episodes imported and some didn't, that folder is the release folder, which holds both — so excluding the ones that failed took the ones that succeeded out of scoring with them, and the health score dropped accordingly. Those rows now get one rule per file instead. The **Exclude** action is also gone from *Dead Registration* rows: their paths belong to the healthy cross-seed that is still seeding the same data, so hiding the file would not retire the dead registration and would hide a live file to no purpose. - **Excluding a season pack saved without a category folder could fail outright** — auditorr decided whether it could write one rule for a whole release folder by counting path segments, on the assumption that segment one is a category directory (`movies/`, `tv/`) and segment two is the release. A torrent saved straight into your torrent directory has its release folder at segment *one*, so that test refused it and fell back to a rule per file — and on a long scene name each of those runs past the 200-character limit and gets refused too. The result was a season pack you could not exclude at all, and a message telling you to select the whole release folder, which was exactly what had just been declined. auditorr now checks what it actually cares about — does anything other than this torrent live in that folder, and is the folder shared with your media library — rather than how deep the folder is. The same pack that produced nine unusable rules now produces one. - **Clicking Exclude enough times could lock you out of the Config page** — the exclusion list is capped at 100 rules of 200 characters each, and the Config page enforces that on save. The Exclude buttons wrote straight past it. A long path on a nested season pack, or an afternoon of clicking Exclude on a messy library, produced a list the Config page then refused to save — with an error about a rule you never typed, blocking **every other setting on the page** until you found and trimmed it by hand. The buttons now enforce the same limits and say what they refused ("Added 3 of 5 — 2 too long…") instead of reporting a success. - **A download that was still running was reported as junk** — a torrent at 0% has no library file, which is the literal definition of the *Not Imported* pile, so the newest thing in your client arrived in **Triage** at its full final size with a verdict of **Not in Library** — the bucket that invites a delete — plus a *Trigger Rescan* button pointed at files that don't exist yet. It also counted against the Triage badge and the Rounds card. Nothing on the row could have told you otherwise: `status` wasn't even sent to the page. Torrents your client says are unfinished are now left alone until they finish, and the row carries what the client is doing when there's anything worth saying. One case is deliberately *not* hidden: where your client gave no usable completion figure at all, the row still appears, labelled **completion unknown**, because a row you can see and judge is better than one that quietly vanished. - **Live downloads could be listed as orphaned and offered for deletion** — "orphaned" means no torrent in your client claims this path, and auditorr worked that out by matching the client's file list against the disk by exact path. Two optional qBittorrent settings break that match while a download is in flight. With *Append .!qB extension to incomplete files* on, the file on disk is `Foo.mkv.!qB` while the client reports `Foo.mkv`. With a separate incomplete directory, the file isn't at its final path at all yet — and if that directory sits inside your torrent folder, auditorr walks files the client's answer doesn't mention. Either way a file being actively written appeared in **Cleanup** at whatever partial size it had reached, and deleting it makes your client error the torrent and start again. auditorr now claims both spellings and follows the client's own answer for where a torrent's data currently is. Neither setting is in the TRaSH layout, so a by-the-guide install was never affected. - **Release years were being parsed off as file extensions** — a dot-separated release name is the normal case (`Some.Movie.2020`), and auditorr split the year off it the way you would split `.mkv` off a filename, so the year was simply gone. That is the field that stops a 1974 film matching its 2020 remake, and it also affected folder names, which have no extension to take in the first place. Touches every page that reads a torrent path: Backfill, Triage and Trumped. - **A folder named `HDR` or `DV` labelled every file beneath it** — the HDR badge on library files was detected from the whole path rather than the filename, so one path segment applied Dolby Vision to an entire library branch and the Backfill HDR filters then acted on it. - **Episodes numbered past 99 resolved to the wrong episode** — the pattern that reads `S01E120` off a filename stopped at two digits and returned episode **12**, which is a real episode, so the release search ran against it and looked like it had worked. Multi-episode files (`S01E01E02`) now try each of their episodes instead of only the first. - **Backfill merged every oddly-named episode of a show into one candidate** — the season number was read out of the filename, and daily series (`Show - 2024-01-05`), anime absolute numbering (`Show - 087`), `S01.E02` and any non-standard renamer defeated that pattern. Every unparseable episode of a series then collapsed into a single candidate that searched for **one** episode while the row read "N ep". Sonarr reports the season on the very record auditorr already fetches; it now uses it, and the filename is only the fallback. - **Backfill's "instances that couldn't be read" warning described the page, not the run** — the library list is cached for two minutes, so a search started after a longer spell of setting filters ran against a freshly-fetched library that may have lost an instance since. The warning now also appears on the results, reporting what that run actually saw. - **Trumped could search and grab against a same-titled item of the wrong type** — matching a replacement release *preferred* Sonarr for something with a season number and Radarr otherwise, but fell back to the other service when the preferred one had nothing. Content type is now a requirement, at every stage of the match, the same way it already is in Triage. - **Trumped never year-checked a series** — the guard that stops a 1974 film matching its 2020 remake was movies-only, so two series sharing a title resolved on the title alone. It now applies to series too, in the only direction that is sound for TV: nothing can air before the show began. (Long-running and daily series are unaffected — their release names carry air dates, not the year the series started.) Titles that *are* a year — `1923`, `1883`, `2012` — no longer fail their own year check. - **Trumped told you to add a title your arr was already managing** — when a Sonarr or Radarr instance was unreachable, "no arr knows this release" and "no arr could be asked" produced the same empty answer, and the wizard reported the first. It now says which happened, and names the instance. Trumped also reads the **alternate titles** Sonarr and Radarr hold, so a replacement named in its original language resolves the same way Triage already does. - **Triage called foreign-language downloads "Not in Library" and invited you to delete them** — a show released under its original-language name (`No.tengo.miedo.S01E01…`) was compared against the English title Sonarr stores it under ("I'm Not Afraid"), matched nothing, and landed in **Not in Library** — the bucket whose description ended *"junk can be deleted"* — for a series Sonarr was actively managing and had already grabbed. Nothing about the row suggested otherwise, and since a not-imported torrent's files are by definition the only copy, acting on it loses the data; that is exactly how one 33 GB season went and had to be re-downloaded. auditorr now reads the **alternate titles** Sonarr and Radarr already hold, which is where every translated and AKA name lives — the arrs have to consult that list themselves to match the grab in the first place, so auditorr was simply not asking. The season above now reads **Import Pending**, with a working *Trigger Rescan* pointed at the right series. Exact titles still take priority, so nothing that matched before changes. **Not in Library's description is also honest now**: it says these files are the only copy and that deleting loses them, rather than implying they're junk. - **Trumped could delete files out from under torrents that were still seeding** — resolving a trumped release into its cross-seed group starts by asking your client which files the torrent you picked actually holds. If that question failed, the answer came back as an empty list rather than as an error, and an empty list reads identically to "this torrent shares nothing with anything". The group collapsed to the single torrent you selected, the wizard showed you **1 torrent** with no hint that anything had gone wrong, and confirming deleted its files while every cross-seed sibling stayed registered and carried on seeding on top of the hole. It needed nothing unusual to trigger — one timed-out request on a default configuration — and it reported itself as a success. auditorr now stops and tells you it couldn't read the file list, rather than resolving a group it cannot vouch for. - **A Triage rescan never followed through on Sonarr** — auditorr watches Sonarr/Radarr's own file id after a rescan so the row can clear the moment the import actually lands. The check that reads that id has never worked for Sonarr: it returned a value the API layer couldn't serialize, so the request failed outright. Because the failure was at the very end, a selection containing **both** Sonarr and Radarr items lost its Radarr answers too, and the whole thing was silent — you were told "watching for the import" while nothing was watching, and the row sat there until the next scan. Sonarr rows now clear the same way Radarr rows always have. - **A TV episode could be matched to a same-titled film, and imported over it** — when Triage decided whether your library already held something, it *preferred* the service that fits the content type but fell back to the other one when the right service had nothing. So an episode of a series your Sonarr doesn't have would match the same-titled movie in Radarr — Fargo, Hannibal, Dune, Shōgun, Parasite; the collisions are ordinary — and report it as **superseded** at the same quality. Same quality is exactly the condition that offers **Force import**, which is Radarr's *Import Anyway* and deliberately overrides every safety check: one click wrote a TV episode over the film. The content type is now a hard requirement, so an episode is only ever compared against Sonarr and a film only ever against Radarr; anything with no match of the right type falls through to *waiting to import* or *not in library*, as it should have. - **Three of Backfill's five Source filters returned nothing at all** — the *HDTV* chip matched no release on either service, and *WEB-DL* and *WEBRip* matched nothing on any TV candidate. Backfill was comparing against a raw internal field that Sonarr and Radarr spell differently (`web` vs `webdl`, `webRip` vs `webrip`, `television` vs `tv`), so the filter quietly excluded everything. Nothing said so — the candidate simply came back with no release found, which looks exactly like no release existing, so narrowing to WEB-DL on a TV library gave you a page of dashes and every reason to suspect your indexers. The chips now read the quality name both services agree on, which is the same thing the rest of auditorr already uses. - **Trumped stopped auto-matching whenever a release was named without a group tag** — it identifies the encode by the tag after the final hyphen, and took `WEB-DL` and `Blu-Ray` at face value: a torrent named `…AMZN.WEB-DL` was read as belonging to a release group called "DL". When the PM named a real group and your copy of the same payload didn't, the two disagreed and the correct torrent was disqualified outright — no pre-selection, and the one row left carrying a red *group* mark that misstated the reason. The reverse was quieter: two releases with no group at all both reduced to "DL" and were scored as *agreeing* on a group neither one declared. Quality tokens are no longer mistaken for release groups. - **Backfill kept showing pre-scan results while you had it open** — every other workflow page reloads itself when a scan finishes; Backfill loaded once and never again. So its candidate list, folder chips, Search Depth line and Generate count stayed frozen at whatever the last scan said when you opened the page — including across the scan **your own backfill just caused**. A file you had successfully backfilled was still offered as a candidate, and grabbing it again is simply downloading it twice. It now refreshes with the others. ## v1.7.3 — 2026-09-06 ### Features - **Rounds: prizes that still have something to say once your library is tidy** — the *Useless prizes* shelf went quiet for exactly the people who'd done the work. Its cleanliness feats are all earned on the first good scan, and the zombie ladders (Exterminator, Clone Hunter) only pay when a mess *comes back*, so a library that stays clean can never earn another one. Six new ladders and fourteen new feats aim at that: **Conservator** (the largest library you have ever held with nothing whatsoever wrong with it), **Unblemished** (consecutive days with no orphans, no duplicates and nothing unimported), **Full Marks** (consecutive days at a health score of exactly 100), and **Fire Brigade**, which counts messes cleared within a day of appearing — a clean library can't earn kills, but it can be quick. Plus two that reward patience rather than spending: **Atlas** multiplies everything you seed by how long you've seeded it, so 20 TB held for five years outranks 500 TB held for three weeks, and **Old Faithful** tracks your longest-lived single torrent — the one rung a small tidy library can hold that a 500 TB seedbox cannot. Both read your client's own seeding time, so a five-year seedbox arrives mid-ladder on its first scan rather than starting from zero. - **Rounds: the shelf now describes your library, not just its size** — *What you have* was six medallions all reading one number. Hoarder, Packrat, Vault Keeper, Tidiness and Purity are bytes; Archivist is the same bytes counted as files. Every one of them moved when you bought a drive and none of them moved when you improved what was on it. Three new ones ask different questions: **Librarian** counts distinct titles (60 TB of remuxes is not a large collection), **Videophile** counts bytes held at 2160p, and **Provenance** is the age of the oldest thing you still have. - **Rounds: "The record" — everything you have earned, dated and in order** — the prize shelf could tell you *what* you had and nothing about *when* you got it or in what order, because the underlying data was a set of latches with no dates on them. There is now a timeline at the foot of the page: every rung and every feat, newest first, with the day it landed, what it was worth, and a running points total down the right-hand side that ends on the same number as the rank readout at the top of the page. It shows the most recent handful and expands to the lot. Each row also says **what it took**, spelled out: `25.0 TB in the media library`, `30 days without an orphan`, `500 audits by the watchdog`, `72.0 h spent scanning` — and for a feat, the condition it was awarded on. A rung name is a punchline with no setup and a rung number only says where it sits on its ladder; neither of them says 25 TB. **If you are upgrading, it is not empty.** On your first audit after this version auditorr replays your audit log and dates everything that log can actually prove — audits completed, best score ever, days observed, streaks at 90 and 100, scans by watchdog, scheduler and by hand, hours spent scanning, peak RAM. On a long-running install that is typically over a hundred entries stretching back to your first scan. Everything else genuinely can't be dated after the fact: auditorr keeps no day-by-day record of library size or file counts, so there is no honest answer to "when did I pass 25 TB". Those are summed into one line at the foot of the record saying how many rungs and feats you were already holding, and dated properly from here on. ### Improvements - **File Explorer can now say "not" (#23)** — the *Status* row was a single-select control holding three unrelated things: whether a file is seeding or orphaned, whether it has duplicates, and whether it's excluded. Only the first is genuinely a status; the other two are independent flags that any file can carry in any state. Because you could only ever pick one chip, every question you asked left the rest unconstrained — so *orphaned* meant "orphaned, excluded or not", and there was no way to ask for **orphaned but not excluded**. That matters if you use the orphaned media view as a list of releases you could upload: a release you have already seeded still turns up there on the strength of one excluded `.srt` beside it, and in tree view its whole folder comes with it. *Status* now holds just All, Seeding and Orphaned, and **Duplicates** and **Excluded** move to their own `+`/`-` toggles alongside it — `+` for only those files, `-` to drop them, neither for the old behaviour — the same include/exclude buttons the tracker filter has always used. They combine freely with each other and with the status, so *orphaned, not excluded* and *orphaned duplicates* are both one click from where you were. The **Excluded** toggle also carries a count of the excluded files currently mixed into your view, which is the fastest way to notice that's what you're looking at. - **The "hide excluded files" setting is now a default you can overrule in place** — Config → *File explorer visibility* used to be the only control over excluded files in File Explorer, and switching the status filter to *Excluded* was the sole way to see them again, at the cost of whatever else you were filtering by. It now sets where the new **Excluded** toggle starts, and you can move it for a single visit without leaving the page or losing your status selection. The setting takes over again next time you open File Explorer. - **Rounds: every workflow's prize is now the work you did, not the size of your drives** — each of the five rows had a prize attached to it, and on four of them the number being counted had little to do with the workflow. Triage pointed at **Purity** and Backfill at **Lapidary** and **Ratio Alchemist**: bytes seeding, best-ever hardlink share, cross-seed multiplier. All three are library *states*, so they move when you buy a drive and they sit still at their peak precisely when a well-kept library has nothing left to gain — the prize went quiet for exactly the person doing the work. Triage keeps **Shoveler**, which was always a count of items handled, and Backfill gets a new one to match: **Matchmaker**, sixteen rungs from *Blind Date* to *All Bytes Spoken For*, counting the files you actually found a torrent for. A Sonarr season pack counts as its episodes, because the ladder counts files. It is credited when you press Grab: auditorr still follows the download into your library and still tells you how that went, but the score no longer waits on it. Lapidary, Alchemist, Purity, Tidiness and Conservator all stay on the shelf, still tiering, still scoring, still latching — they are simply no longer what a workflow row is asking you to chase. - **Rounds: Cleanup and Dedupe now lead with the streak they are defending, and Dedupe has one at all** — both rows opened on their kill count, so a library that had simply never let a duplicate through was told **"no kills yet"**, which states, wrongly, that what is being measured is how often you have had to fix things. What is being measured is the state you are holding. Both rows now read `Clean for 12 days · back to clean 3×`, and both get the same pair of prizes: a streak and a tally. Cleanup already had **Sentinel** (consecutive days with zero orphans); Dedupe gets **Singleton** (consecutive days with zero duplicates, *One Original Day* through *A Decade of Exactly One*), which it never had — it owned only its kill counter, so a spotless library had nothing on the card. The prize highlighted beside each row is now **pinned to the one its own line talks about** rather than being whichever rung happened to be closest to tipping over, which is how a row clean for a month could end up pointing at something else entirely. - **Rounds now uses the same type scale as the rest of auditorr** — the page had 44 hardcoded font sizes, a good many of them off the app's scale entirely (9.5px, 10.5px, 11.5px, 12.5px, 13.5px), which is why a page built with some care still read as improvised next to the Dashboard. Everything now sizes off the shared tokens, and the whole page comes down to four sizes. The most visible change is the headline on each workflow row, which was 18px bold and shouted over the rank readout beside it — the page had two competing hero numbers and the actual instruction was the smaller one. Each row's explanation also drops from three sentences to two (the third was always a sales pitch) and is capped at a readable line length rather than running to ~110 characters. - **Every workflow row on Rounds is now built the same way** — title, one line of plain English, then a rule with the numbers below it. Previously a row's shape depended on its state and on which section it sat in: a row that needed attention got a bigger title and put its figures directly under the heading, so Backfill loomed over Triage for no reason a reader could name, and three identically-clear rows rendered three different ways. Only the density changes now — a row that wants something carries its figures and its button, a row that is fine is a quiet line — and the state pill says which is which. **Backfill's row now says one thing, on one line, like the rows either side of it.** It used to print its hardlink ratio twice, once under the title and again below; untangling that still left it three lines deep at the foot — the ratio, your best-ever ratio, and a sentence explaining that your best never goes back down — next to Triage's and Trumped's single line each, so the one workflow that by definition never finishes was also the tallest thing on the page. It now reads `37 backfilled so far · 85% hardlinked, 6.1 TB earning nothing` in the same type as its neighbours. ### Bug Fixes - **Backfill grabs could score nothing at all** — Matchmaker credit was awarded by the background job that follows a grab into your library, which meant the points depended on that job surviving the whole download. Restart the container while something is downloading — a version update, say — and the grabs in flight scored zero, even though every one of them imported perfectly a few minutes later. A stalled import you then finished by hand didn't count either. Credit is now recorded when the grab is accepted, which is the part auditorr can actually vouch for; the import watch carries on doing its job and reporting honestly, it just doesn't hold the scoreboard. **Grabs already lost this way can't be recovered** — nothing on disk records that they happened. - **A prize credited while a scan was finishing could vanish** — trump swaps and backfills are counted the moment they happen rather than at the next audit, and the audit wrote the whole progress record back from a copy it had taken moments earlier. Anything credited in that window was overwritten and gone for good, since these counters only ever add. The odds aren't as long as they sound: auditorr scans right after the filesystem change an import causes, so the two events cluster. Both now write through the same lock, and the audit merges rather than overwrites. - **Acting on a workflow row now shows up everywhere, straight away** — you'd delete a torrent in Triage, watch the row disappear, and then notice the sidebar badge hadn't moved; navigate away and back and the row you just dealt with was sitting there again. Every workflow report and the Rounds page are built from the last audit, so until a new one ran the server kept handing back the same answer. Three things were wrong and all three are fixed: pages left open during a scan now update themselves when it finishes, rather than showing pre-scan data until you navigated away; rows you've acted on stay gone if you leave the page and come back; and the actions that change something **auditorr can't see on disk** — removing a torrent while keeping its files, or adding an exclusion — now schedule an audit themselves, where previously nothing did until the next scheduled one hours later. That audit is debounced, so working through twenty rows still costs one scan, and it waits for you to finish rather than scanning while you're mid-session. The **Refresh buttons are gone** from Cleanup, Dedupe and Triage: a refresh button on a page built from the last audit is a stale page with a button, and on Triage it was actively misleading — it re-read the same stored data. - **A Triage rescan looked like it did nothing** — the rescan hands your file to Sonarr/Radarr, which imports it whenever it gets round to it and reports nothing back, so the row just sat there. auditorr now watches the arr's own file id and clears the row the moment the import actually lands, usually within a few seconds. Rows it can't watch — anything with no matching library entry — are marked *rescan sent* instead of looking untouched. - **Three prize ladders were secretly the same ladder** — Packrat, Tidiness and Purity all started from the total size of your torrent directory and differed only by subtracting the orphaned or not-imported bytes. Those are the health score's penalty terms, so on any library in reasonable shape they're a couple of percent, against ladder rungs that sit 2–2.5× apart. The three tiles therefore showed the same value and tiered in lockstep forever: *Geological Layer*, *No Dust Exists Here* and *Twice Distilled* all unlocked at the same instant, and "No Dust Exists Here" was really being awarded for owning 10 TB. Tidiness and Purity now measure how large your torrent directory was **on a day when the relevant pile was actually empty**, so a spotless 5 TB library outranks a filthy 500 TB one until it sweeps. Nothing is taken away: the ladders latch on your best-ever value like every other prize, so any rung you already hold stays held — it simply won't advance again until the library is genuinely clean. - **Adding a second Sonarr/Radarr silently switched off your first one (#22)** — the *Additional Sonarr/Radarr instances* list didn't add to the primary Sonarr and Radarr fields above it, it **replaced** them. Adding a single extra instance — a 4K Radarr, an anime Sonarr — retired your main pair completely, while their fields stayed on the Config page, kept saving, and kept answering "Connected and saved!" when tested. Nothing read them. Everything that depends on Sonarr/Radarr went with them: Backfill could only offer candidates from the extra instances (which is how this was found — a library with four instances showed one root folder), Triage lost the library comparison that separates a superseded torrent from an unknown one, and deep links, indexer lists, rescan and force import all saw a fraction of the library. **The two are now merged**, so if you are affected, upgrading is the whole fix — no configuration change, nothing to re-enter. An instance listed in both places is still only read once. Expect your counts to move on the first scan after upgrading, and if one of those primary fields has been pointing at a dead host for a while, you'll now hear about it. - **Backfill merged shows across two Sonarr instances (#22)** — candidates were grouped by series id and season alone, but series ids are per-instance: both your Sonarrs number their first series `1`. Two unrelated shows that happened to share an id collapsed into a single candidate, and the survivor kept its own instance — so the absorbed show's episodes were never searched, and the release search that did run went to the wrong Sonarr for the wrong series. The grouping key now includes the connection, so each instance's shows stay their own candidates. Movies were never affected (Radarr candidates aren't grouped). - **Backfill's candidate count was divided by a number it has nothing to do with** — the *Search Depth* line read like `15 searchable candidates (2612 total unseeded)`, which invites you to read the first as a fraction of the second. It isn't one, twice over. A candidate is a **group** — one Sonarr season is a single candidate however many episodes it holds — while the total counts individual **files**; and the total counts *every* file, including subtitles, NFOs, artwork and extras, none of which Sonarr or Radarr indexes and none of which could ever match. So a perfectly healthy library reported what looked like a catastrophic gap, which is exactly what happened while diagnosing #22 and cost a round of back-and-forth to rule out. The two are now stated as separate facts, with the noun in front of the numbers: `15 searchable candidates. Of 2612 unseeded files, 412 matched your Sonarr/Radarr library.` That second sentence is the one that actually answers "did auditorr find my library", so it now also appears when **nothing** resolved — previously the whole line was hidden in the one case where you most needed to see it. - **Rounds called Backfill blocked when your arrs were only in the additional-instances list (#22)** — the page asked those connections for a `url` field, which the Config page does not write (it writes `base_url`), and never checked for an API key at all. If your Sonarr and Radarr live only in *Additional Sonarr/Radarr instances*, with the primary fields above left blank, Backfill sat on *Blocked — connect Sonarr or Radarr* against two perfectly good instances, and the *Connect Sonarr* and *Connect Radarr* setup steps never ticked no matter what you did. Rounds now asks the same question every other feature asks, of the same function, so there is one answer to "which arrs are connected" rather than a second one on this page. - **One unreachable series discarded an entire Sonarr library** — Sonarr needs an API call per series where Radarr needs one in total, and a single failure among hundreds aborted the whole instance's file index. To everything downstream that looked identical to a Sonarr managing nothing: its files stopped resolving, its titles vanished from the Backfill folder list, and Triage lost the library comparison that tells a superseded item from an unknown one. A series that can't be read is now skipped and logged, and the rest of the library comes back. - **Library listings could time out on large instances** — the full `/api/v3/movie` and `/api/v3/series` responses were fetched on the 10-second timeout that suits single-item calls. On a big library that is not a comfortable margin, and exceeding it produced no visible error — just an instance that appeared to manage nothing. Those two bulk listings now get 60 seconds; nothing else changed. - **A Sonarr/Radarr instance that failed to load said nothing** — if auditorr couldn't read an instance, its files silently fell out of Backfill, taking their entries in the *Root Folders* list with them, and the page gave no hint that anything was missing. Backfill now names the instances it couldn't read and says plainly that everything they manage is absent from the list below. - **Test Arr Connections withheld the answer it had** — the check already collects a sample library path from each instance, which is the one thing that shows whether auditorr sees your files where your arr says they are, and the page threw it away. Each instance now shows a sample path under its result, along with the reason when one connects but its library can't be read. ### Known issues - The *Root Folders* list on Backfill is derived from the first path segment below your `MEDIA_PATH`, not from the root folders configured in Sonarr/Radarr — auditorr doesn't read those. It is right for the common layout (`/data/media` holding `movies`, `tv`, `anime`), but root folders nested deeper than one level collapse into a single entry, and only folders with a searchable candidate in them appear at all. ## v1.7.2 — 2026-08-27 ### Features - **Rounds — a new page that says what to do next** — a sidebar entry between Workflows and Config, and the index for the five workflows. One row per workflow, each in a state auditorr works out for you: *Needs you* over your configured threshold, *Blocked* on a setup gap, *Could improve* under the threshold but not clean, *Clear* when it is, *On standby* for Trumped (which only starts when a tracker PMs you). The layout is fixed and labelled — **Baseline** (Cleanup, Dedupe: clear once, then watch), **Ongoing** (Triage, then Backfill: these never have a last item), **On demand** (Trumped) — so nothing reshuffles between visits and the order is the same lesson every time. It also never bottoms out: a clean library gets *Clear* rows explaining what to keep an eye on, not an empty page. Below the work sits **Useless prizes** — roughly 500 individually named tiers across 31 ladders plus 87 one-off feats, in the spirit of Progress Quest, where the joke is the specificity (*Geological Layer*, *Server Room Guy*, *Seek Help*) rather than a number with a roman numeral after it. Every prize is monotonic: points accrue for what you have done and are never taken back when your library shrinks or a count climbs again. The whole thing is contained to that one page — no dashboard strip, no toasts, no nagging count badge, nothing that follows you around if you never open it. - **Configurable health-score weighting** — the four score components (Hardlinked Media, Orphaned Torrents, Not Imported, Duplicate Files) were fixed at 70/10/10/10. A donut in Config → Health Score now divides the 100 points however you like: the numbers are relative, so you type what expresses your priorities and the points column re-totals to 100, with a *Reset to defaults* button next to it. Set a component to `0` and it stops scoring — it stays on the dashboard with its size, count and trend, reading **Not scored** instead of points; at least one has to stay above zero. This exists for a specific, common setup: if Sonarr/Radarr or qBittorrent deletes torrents once they meet a seeding requirement, the torrent-folder hardlink goes with them and **Hardlinked Media** — 70 of the 100 points — collapses for a library that is working exactly as intended. auditorr cannot detect that on its own (a deliberately cleaned file and a failed import look identical on disk once the torrent is gone), which is why it's a setting. Note that changing the weighting changes what the number means, so your score history will step on the day you change it; the chart is not recomputed backwards. - **External URLs for reverse-proxy setups (#21)** — auditorr was using one address for two different jobs: the one it connects to a service on, and the one it sends *your browser* to when you click "open in qui ↗" or a Sonarr/Radarr link. On a flat LAN those are the same address, so it went unnoticed; behind a reverse proxy there was no value you could type that was correct for both — internal broke every link, external would have routed every scan through the proxy (and broken outright with any SSO in front of it). Each service now takes an optional **External URL** alongside its existing address, under a collapsed *External URLs (reverse proxy)* section in Config → Torrent Source and Integrations, plus a per-instance field on each additional Sonarr/Radarr connection. The existing field keeps its meaning as the API address; the external one is used **only** to build links and is never fetched by the server. **If your links already open correctly, there is nothing to change** — blank means "same as the address above", which is exactly what auditorr did before. - **Force import in Triage** — a new action on superseded items that replaces the file Sonarr/Radarr already holds, using their own "Import Anyway" mechanism (`ManualImport` with `replaceExistingFiles`). Trigger Rescan can never do this: the arr evaluates its upgrade and revision specs against the existing file and refuses anything not strictly better, which a same-quality trump replacement never is — a non-REPACK over a REPACK is rejected as "not a quality revision upgrade" no matter how many times you rescan. Offered **only** where the torrent scores the same quality as the library file. A genuine downgrade (1080p against a 2160p library file, WEB-DL against a Bluray) simply doesn't get the action — those rows already sit under a "lower than library" heading that explains why, and refusing them is correct behaviour rather than a bug to work around. If you ever do want one of those in your library, Sonarr/Radarr's own Manual Import is the right place for it: one deliberate file at a time, rather than a bulk selection. ### Bug Fixes - **Trumped could offer a release for a completely different title** — matching scored quality agreement (resolution, source, release group, audio) alongside the title, so two unrelated releases that merely shared a format could out-score a real match: a 2019 film was offered as the replacement for a 2026 one because both were 1080p WEB-DLs. The title is now a **gate, not a score** — the two title cores have to genuinely overlap and the year (±1, for premiere-vs-release-year renderings) and season/episode anchor have to agree, or the candidate is dropped entirely. Quality only refines the ranking *within* real title matches, and where nothing genuinely matches the picker now says so and offers manual entry or the Sonarr/Radarr deep link, instead of a confident wrong answer. The same gate now covers the pre-selected torrent match, which had its own ungated path — that one was offering a 2025 release for a 2026 trump notice. Punctuation is also folded before quality tokens are stripped, so `HDR10+` is no longer counted as a word in the title. - **Triage's sidebar badge disagreed with the Triage page** — the badge counted *files* flagged not-imported or dead, which is neither what the page lists nor how it lists it. Dead registrations were invisible to it (a page of 10 items showed a badge of 4), while a not-imported season pack counted once per file — 20 rows for one torrent. The badge and the Rounds Triage card now count items the way the page groups them, per torrent. - **Trigger Rescan reached the wrong folder** — the rescan pointed Sonarr/Radarr at the *parent directory* of the file, but the arrs identify a release by parsing the folder name they are given. For a single-file torrent sitting in its category dir that meant handing over the download root: Radarr parsed "radarr" as the release name, logged `Unknown Movie`, and imported nothing. A nested season pack handed over "Season 1", which parses no better. The scan now targets the **release folder** — the second segment below the torrent root — and falls back to the file itself when there is no release folder, which both arrs accept and parse from its own filename. - **Rescan could move a file out from under a seeding torrent** — the scan command sent no `importMode`, so the arrs used their default of `Auto`, which means *move* whenever no download-client item is attached to the scan. One never is for a Triage rescan, by definition: it exists for payloads the arr never grabbed. The import mode is now sent explicitly as `Copy` — the arr's name for "leave the source in place", and the only mode that consults the *Use Hardlinks instead of Copy* setting, so a successful import costs no extra disk and the torrent keeps seeding. - **Manual-import lookups asked Sonarr/Radarr the wrong question** — auditorr scoped its `/api/v3/manualimport` folder lookups with `movieId`/`seriesId`, which reads like a filter but isn't one: the arr ignores the folder entirely and returns that item's **existing library file** instead of the folder's contents. Every folder-based lookup therefore came back describing the file being replaced rather than the one to import. The item is now forced in the import command itself, where it belongs, and the lookup is left to answer what it was asked. This also fixes the Trumped workflow's folder fallback, which had been silently affected since it shipped — trump only kept working because its download-id lookup answers first. - **Single-file torrents couldn't be identified for import** — a lookup pointed at a torrent's *containing* folder lands on the shared category directory, and Sonarr/Radarr only parse a title when they can anchor on a release folder name: every file in that listing comes back with no movie and quality "Unknown". Lookups now target the release folder when there is one and the file itself otherwise — both accepted — so quality, language and release group are read correctly instead of being imported as Unknown. - **A rescan the arr refused reported success anyway** — Sonarr/Radarr report every scan command as `completed` whether it imported everything or rejected everything, so a refused rescan surfaced as a green "Rescan triggered" toast and looked like a mystery. auditorr now asks `/api/v3/manualimport` what the arr would decide — the same specs, reported per file — and shows the real reason ("Not an upgrade for existing movie file", "Not a quality revision upgrade"). Failing to check is kept distinct from checking and finding no objection, and a file the arr can't identify at all is reported as such rather than passing silently. ### Improvements - **arm64 images** — `ghcr.io/thrill-burn/auditorr` is now published for `linux/arm64` as well as `linux/amd64`, so it runs natively on a Raspberry Pi 5, an ARM NAS, or Apple-silicon Docker without emulation. The Python dependencies are pure Python, so nothing compiles under QEMU during the build; the frontend stage is pinned to the build host's architecture, since Vite's output is identical on every target and building it natively keeps the arm64 image from paying for an emulated `npm` run. ## v1.7.1 — 2026-08-01 A security release. No feature or UI changes — a no-secret LAN install behaves identically after upgrading. ### Security - **Non-local API access is now closed by default (#18)** — previously an empty `AUDITORR_SECRET` disabled authentication entirely, leaving the full administrative API (results, config, workflows, client deletes) open to anyone who could reach the port, including the internet on a port-forwarded install. With no secret configured, auditorr now serves **local clients only** — loopback and private ranges (10/8, 172.16/12, 192.168/16, IPv6 ULA and link-local) — and answers everything else with a generic 401 that doesn't reveal whether a secret exists. Locality is judged by the connection's source address, never `X-Forwarded-For`, which is attacker-controlled and would let a remote client spoof a private address. A configured `AUDITORR_SECRET` is still enforced for every client, local ones included. - **The `?secret=` query-string fallback was removed** — the access key is now accepted **only** via the `X-Auditorr-Secret` header, because query strings leak into reverse-proxy access logs and browser history. The web UI already used the header; any bookmark, script, or `curl` invocation passing `?secret=` must be switched over. - **New — `AUDITORR_TRUSTED_NETWORKS`** — comma-separated CIDRs treated as local in addition to loopback and private ranges, e.g. `100.64.0.0/10` for Tailscale (CGNAT space, not RFC1918, so the built-in private-range check does not cover it). Invalid entries are logged and skipped rather than failing startup. - **New — `AUDITORR_REQUIRE_AUTH=true`** — strict mode: the key is required even from local clients, with no local exemption at all. Set without an `AUDITORR_SECRET`, it fails closed rather than running open: every `/api/*` route returns 503 `auth_not_configured`, and the UI replaces the page with a setup notice that clears itself as soon as a secret is configured and the container restarts. - `/health` and the static SPA shell stay open, so container health checks are unaffected. `docker-compose.yml` and the Unraid CA template (`auditorr.xml`) document the new model; the debug report records `auth_strict` and the trusted-network count (never the networks themselves). ### Upgrade notes - **Nothing to do** for the common case — a LAN install reached from LAN clients, with or without a secret set. - **Set `AUDITORR_SECRET`** (a long random value) if you reach auditorr from outside your LAN — port forward, VPS, or a remote host — or add your tunnel's range to `AUDITORR_TRUSTED_NETWORKS`. Without one of the two, those requests now get 401. - A reverse proxy running on the **same host or LAN** as the container passes the local check, because the proxy's address is what gets evaluated. The local-only default cannot see past a proxy — if the proxy itself is internet-facing, set `AUDITORR_SECRET` or enforce authentication at the proxy. ## v1.7.0 — 2026-07-09 ### Features - **Trumped workflow** — a fifth workflow page (green, PM-driven rather than audit-driven) automating the private-tracker trump swap that was previously a manual multi-step chore. Paste the tracker's trump PM; auditorr parses the old and new release names, resolves the **entire hardlink group live from the client** (every cross-seed of the trumped content — audit records can't enumerate siblings, so it queries the client directly: same-payload-size torrents sharing a content path), removes the whole group with its files, and grabs the exact replacement release through Sonarr/Radarr. Every step is independently confirmed; the removal half is gated by `ALLOW_CLIENT_DELETE`; release matching is exact (release names are unique ids — never fuzzy, seeders break ties), with the arr interactive-search deep link as a manual fallback. auditorr never contacts a tracker — group resolution and deletion go through the client, search and grab through the arrs. A re-audit runs automatically afterward. - **Triage exclusion suggestions** — Triage now notices when torrents linger only because of files Sonarr/Radarr never import and gently offers one-click filters, built from the actual items: non-video sidecars (`.sfv`/`.nfo`/checksums → `ext:` rules), scene sample clips (`contains:sample`), and full-disc rip folders (`BDMV`/`VIDEO_TS`, matching the disc-rip preset). Clicking a suggestion writes a real exclusion (visible and editable in Config → Excluded Files) and clears the affected rows — exclusions remain the only filtering mechanism; nothing is hidden silently. Subtitles and Extras/Featurettes are deliberately never suggested. - **Dead Seeds in Triage** — both source backends now classify every torrent's tracker entries during the audit (on the same per-torrent trackers call that already collects host names — zero extra API cost) and store `tracker_health` / `tracker_msg` on each torrent file record. A new top Triage section, **Dead Seeds — imported**, surfaces torrents that are tracker-dead (trumped / deleted / nuked) but already imported: the library hardlink keeps the data, so deleting them via the client is lossless. The flag is live re-verified on page load — torrents the tracker answers for again are dropped as recovered. The Triage sidebar badge includes the dead-seed count. - **Dead Registration bucket** — a second dead-torrent section for the opposite case: the tracker has dropped this specific torrent registration, but its payload is still alive via a working cross-seed sibling and/or the hardlinked library copy. Built from `dead_siblings` retained on file records (the unregistered claimants discarded when a cross-seeded path's healthiest torrent wins the stored record), live re-verified like Dead Seeds. Client deletion is now topology-aware to match: `delete_files` accepts `'auto'` (the new UI default), which deletes a torrent's files only when no *surviving* torrent shares that exact path — a shared-path cross-seed keeps its file, a distinct-hardlink cross-seed drops only its own link, and no orphan is left behind either way. - **Client-side torrent deletion (opt-in)** — new "Workflow torrent deletion" toggle in Config → Torrent Source (default **Disallowed**). When allowed, Triage's action bar gains a "Delete from qBittorrent/qui" button that removes the selected torrents **and their files** through the client API (`torrents/delete` with `deleteFiles` for qBittorrent; the bulk-action endpoint with `deleteWithFiles` for qui, verified against a live instance's OpenAPI spec). A confirmation dialog lists exactly what will be sent and touched — per-torrent hash, tracker, seeding time, size, and every file path — with Escape/backdrop close and a "Continue in background" option while the request runs. This **replaces** Triage's old Generate Delete Script action, which was actively harmful there: deleting files under an active torrent just triggers a recheck and redownload. (Cleanup keeps its script — orphans have no torrent.) - **Superseded quality sub-groups** — superseded torrents are now bucketed by how their quality compares to the library file: **higher / same / lower than library / comparison unavailable**, each with its own select-all and suggested action. Resolution decides; source (Remux > Bluray > WEB-DL > WEBRip > HDTV > DVD) breaks ties; a missing field on either side lands in "unavailable" rather than guessing. DVD-source releases with no resolution token ("DVDRip", arr quality "DVD") are treated as SD instead of unknown. - **Seeding time in Triage** — each row shows total seeding time (the hit-and-run tiebreaker) in place of per-torrent ratio, which is meaningless per-file on private trackers. Also shown per torrent in the delete confirmation dialog. - **qui deep links** — the qui chip on Triage rows and both File Explorer views now opens `/instances/{id}?torrent={hash}`, selecting the exact torrent with its details pane. qBittorrent's WebUI reads no URL parameters, so its chip copies a parsed, searchable title (release folder name for nested files) and opens the UI for a one-paste search. - **Cleanup ↔ Triage cross-links** — each page links to its sibling with a live count. The boundary: torrents the client knows about belong to Triage; files with no torrent belong to Cleanup. - **Triage loads instantly, then verifies live in the background** — `GET /api/workflows/triage` now answers entirely from a compact audit-time snapshot with zero torrent-client calls, so the page renders immediately regardless of library size. The client then confirms every item's live tracker health in sequential batches against `/api/workflows/triage/verify`, updating rows from a pulsing placeholder to their real status as each batch answers. A failed verify shows an honest "verification failed — showing audit-time data" state with Retry, rather than guessing. - **Background scans defer to active workflow sessions** — any real Triage/Cleanup/Dedupe/Backfill/Trumped request now marks a short-lived "workflow active" signal; the watchdog re-arms its debounce (capped at 10 minutes) and the scheduled audit waits for the session to go quiet before scanning. Keeps a multi-hundred-MB background scan from stacking on top of an in-progress workflow session's own memory use, and coalesces a session's deletions into one post-session audit instead of one per action. Manual and startup scans are unaffected — explicit intent always wins. - **Trackers page comparison view** — replaced the per-tracker card grid with a single overlaid multi-line chart (one color per tracker; click a legend row to solo or toggle it) plus a sortable comparison table, so trends across every tracker are visible on one chart instead of switching between cards one at a time. Same five metrics as before: Seeding, Uploaded, Yield, Orphaned, Not Imported. ### Bug Fixes - **Exclude no longer nukes category directories** — Triage's Exclude button excluded the deepest common *directory*, so excluding a single file sitting in `tv-sonarr/` excluded the entire category and silently hid every torrent in it from Triage (including dead seeds). Single-file torrents now get an exact file-path rule; multi-file torrents get their release folder only when it sits at least two segments deep — a category dir can never become an exclusion rule. - **Same-title remakes no longer cross-match** — release names now carry a parsed year, and radarr candidates with a different year (±1) are rejected, so "The Smashing Machine" 2002 DVDRip matches the 2002 film instead of the 2025 remake. The title index also previously kept only **one** arr entry per normalized title — both same-title films can now coexist and resolve correctly. Years are displayed on Triage rows and the "vs library" label so a wrong match is visible at a glance. - **Diacritic title matching** — arr titles keep their diacritics ("Žižek!") while release names are ASCII ("zizek"); title matching now indexes an ASCII-folded variant of every key (NFKD + combining-mark strip), fixing ž/é/ü/ñ matches across Triage and Backfill. - **Year-titled films parse correctly** — the title parser stripped everything from the first year-like token, so "2046", "1917", and "2012" parsed to an empty title (backfilled with the folder name, e.g. "radarr"). It now only strips a *plausible* year (1900..now+2) that isn't the leading token — "Blade Runner 2049" keeps its number too. - **qBittorrent 5.x tracker statuses** — tracker classification only recognized the classic status 4 as "not working"; qBittorrent 5.x adds 5 (TrackerError) and 6 (Unreachable), which now classify correctly. Message-based unregistered detection always took priority and is unchanged. - **Cross-seed delete safety** — audit records are per file path, and when several torrents claim the same path (cross-seeds) the first one seen won the stored hash/health — nondeterministically. A file with one trumped and one healthy torrent could be presented as a "lossless" dead-seed delete that would have destroyed the live cross-seed's data. Records now keep the *healthiest* claimant (working > not working > unknown > unregistered), with the stored hash/instance following, so a path with any live torrent can never be classified as a dead seed. - **Delete confirmation rendered off-screen** — the dialog used `position: fixed` inside the page container, whose fade-in animation leaves a persistent transform; the overlay covered the scrollable page and the dialog centered thousands of pixels outside the viewport, leaving only a dimmed screen. It now renders through a portal to `document.body`. - **Workflow page text wrapped early** — header blurbs and Triage section descriptions were capped at 640px; widened to 960px. - **Import detection no longer treats cross-seed hardlink count as "imported" (#15)** — a torrent was considered imported if its inode's link count was > 1 *or* it had a media-library path. Cross-seeding hardlinks the same payload across multiple torrent-client save paths, which also raises `nlink` above 1 for files that were never actually imported — inflating the imported count whenever cross-seeded content also happened to be un-imported. Import status is now based solely on whether a hardlink into the media library exists. - **Trumped matching on near-year titles** — a stray season token or extra scene noise adjacent to the year (e.g. a PM rendering "… Rides Again S01") could throw off year-based disambiguation. The year is now isolated from surrounding tokens before comparing, so the ±1-year tolerance no longer misfires on titles that merely contain adjacent digits or tokens. ### Performance - **Memory ratchet mitigations** — transient multi-GB peaks (large scans, workflow-page full deserializes) previously became permanent resident memory, because glibc's allocator never returns freed arenas to the OS on its own. `debug.malloc_trim()` now runs after every scan and after each heavy endpoint request; `MALLOC_ARENA_MAX=2` is set in the Docker image to cap arena count. Dedupe no longer dict-copies both full file lists to build its script inputs — only excluded/duplicate-bearing records are tagged. The debug report's memory timeline now also samples `sys.getallocatedblocks()`, so a genuine leak (climbing blocks) is distinguishable from allocator ratchet (flat blocks at high RSS). ### Improvements - **Triage row layout** — size + tracker column moved left; uploaded + seeded rightmost, so seeding time aligns column-like across rows. - **FileExplorer tracker and seed-count filters** — tracker include/exclude is now a per-tracker choice button (green include / red exclude) instead of a single dropdown, and a new seed-count menu filters rows by exact cross-seed count (1x/2x/3x/4x/5x+). - **Design consistency pass** — sidebar active state, buttons, chips, form inputs, and the library health dial were restyled around one consistent accent/radius/color-token system; decorative color dots were removed from cards and lists in favor of hue reserved for meaning (status, trend direction) rather than decoration. - **Trumped workflow spec** — `prompts/TRUMP.md` rewritten on top of this release's infrastructure (client delete, release search/grab, confirmation modal, workflow scaffold); remaining work is PM parsing, live hardlink-group resolution, and the wizard page. ## v1.6.1 — 2026-06-10 ### Features - **`/api/debug/report` diagnostic endpoint** — returns a privacy-scrubbed JSON dump designed to be pasted publicly when reporting issues: runtime info (current/peak RSS, container memory limit from cgroups), sanitized configuration, library and database size stats, scan state with per-phase memory history, crash evidence, the last 20 audit runs, and the last ~400 log lines. Credentials are never included; hostnames, IPs, and API tokens are redacted; media file and folder names are replaced with stable 6-char hashes (`~a1b2c3.mkv`) so files can be correlated across the report without being readable. Generic structure (`/data/torrents`, `Season 01`, real file extensions) is preserved for debuggability. - **Aborted-scan detection** — the audit now writes a scan marker at start, updates it at every phase with current memory usage, and removes it on any normal exit. If the process dies mid-scan (OOM-kill, container restart), the next startup finds the marker and records an **aborted** entry in Audit History stating exactly which phase the scan died in and how much memory it was using. Previously a killed scan left no trace at all — the audit history stayed empty and the scan silently restarted from the top. - **OOM kill attribution** — an OOM kill is a SIGKILL, so nothing can be logged at the moment of death; instead the kernel's per-cgroup `oom_kill` counter (which survives worker restarts) is checked at boot. If it incremented since the last boot, the aborted-scan entry says "confirmed out-of-memory" rather than "probably". A memory sampler thread also runs during scans: it persists an RSS reading into the scan marker every 20 s (so a killed scan shows memory from seconds before death, not just the last phase transition) and logs warnings while there is still time to read them — at 85% and 95% of the container memory limit, or when host available memory drops below 400 MB with no limit set. Python-level `MemoryError` is now caught explicitly: the scan structures are freed first so the error itself can be logged and persisted with clear out-of-memory wording. - **Background-thread crash logging** — unhandled exceptions in background threads (audit, watchdog, workflows) previously printed to stderr only; they are now routed through logging so they appear in the debug report's log buffer. `faulthandler` is enabled so native crashes (SIGSEGV) dump Python tracebacks to the container log. Boot logs a one-line memory summary (RSS, container limit, host available, cgroup oom_kill count), and scan completion logs file/tracker counts, duration, and final RSS. ### Bug Fixes - **Crash-restart scan loop on very large libraries** — on 500K+ file libraries the scan could be killed mid-run (out-of-memory or gunicorn worker timeout), which left no audit history entry and immediately re-triggered the startup audit when the worker respawned — appearing in the UI as the scan endlessly starting over even with the watchdog disabled. Three fixes combine to address it: - **Duplicate detection memory blowup** — for a group of *k* identically-sized identical files, every file stored all *k−1* sibling paths: *k²* path strings total. Disc-based libraries (BDMV/DVD rips) contain thousands of identically-sized structural files (`.bdmv`, `.clpi`, `.mpls`, plus the `BACKUP` copy inside every disc), producing groups large enough to consume many GB of RAM and inflate the stored `files_json` — likely also the root cause of the earlier "string or blob too big" failures. Size groups larger than 200 files are now skipped (real media duplicates come in groups of 2–5), each file stores at most 10 sibling paths, and **excluded files are skipped entirely** — previously an excluded disc directory was still hashed and cross-referenced, so exclusions did not reduce duplicate-detection cost at all. - **Diff no longer loads previous file lists** — computing the change log deserialized both previous full file lists (several GB of Python objects at 650K files) while the current lists were also in memory. Each audit now saves a compact `{path: bitmask}` signature map per tab (~2% of the size) and the next audit diffs against that. Diff lists are capped during collection, so a mass rename can't transiently allocate one entry per file. The first audit after upgrading skips the change log entry once while signatures are established. - **Crash-loop breaker** — after 2 consecutive scans killed mid-run, the startup audit and watchdog are paused and a clear warning is shown instead of hammering the disk for hours on every container restart. A completed manual scan re-enables automatic scanning. - **gunicorn worker killed by slow requests** — the Docker image now runs gunicorn with the `gthread` worker (8 threads) instead of the default sync worker. Previously a single slow request (e.g. serving a very large `/api/files` response) could exceed the 300 s timeout and get the worker SIGKILLed — taking any in-progress audit down with it — and blocked all other requests (including progress polls) while it ran. - **Generated scripts now respect Excluded Files & Folders** (#14) — excluded files are invisible to script generation: the orphaned-cleanup script no longer emits `rm` commands for excluded orphans, and the dedupe script skips excluded files both as the canonical copy and as duplicate partners (a group whose only partners are excluded is dropped entirely, so the script never hardlinks over an excluded file). Script headers report how many files were skipped due to exclusions. Previously users had to hand-edit generated scripts to remove excluded entries — and the delete script would have removed intentionally-kept files (e.g. DVD rip metadata). ### Performance - **Dedupe script verifies with `cmp` instead of `md5sum`** — `cmp -s` stops at the first differing byte, while `md5sum` reads (and hashes) both files in full even when they differ immediately. For a false-positive pair of large remuxes that's a few KB of reads instead of the combined file sizes; identical pairs cost the same I/O minus the hashing CPU. Also matches the relink workflow, which already verified with `cmp`. - **`/api/files` streams stored JSON** — the endpoint previously parsed the stored file list (`json.loads`) and re-serialized it (`jsonify`); at 650K files that costs minutes of CPU and several GB of RAM per request. The stored JSON is now streamed straight through in decompressed chunks without ever being parsed. - **Config save no longer deserializes huge libraries** — saving settings recomputes health metrics from stored file lists so threshold changes apply instantly; for libraries above 200K files this is now skipped (new thresholds apply on the next audit) instead of loading both full file lists into memory on every save. - **Granular scan phases with memory telemetry** — "Computing health metrics" previously covered six internal steps including diff computation and database saves. Each step now reports its own status message and logs process RSS at every transition, so slow or crashing scans can be pinpointed to an exact step. ## v1.6.0 — 2026-06-02 ### Features - **Backfill workflow** — new Workflows section in the sidebar (expandable group, ready for future workflows). The Backfill workflow surfaces unseeded media files from your library that match a Sonarr/Radarr entry and have no active torrent. Configure an indexer strategy (download-from and must-also-be-seeding-on indexer filters), root folder scope, quality filters, sort order, and search depth, then generate a grab list that queries Sonarr/Radarr's interactive release search for each candidate one by one. - **Quality filtering** — Backfill release results can be filtered by Resolution (2160p / 1080p / 720p), Source (Remux / Bluray / WEB-DL / WEBRip / HDTV), and HDR format (Dolby Vision / HDR10+ / HDR10 / HDR / HLG / SDR), matching Sonarr/Radarr quality profile terminology. All three filters are multi-select chips; empty = no restriction. Filters are persisted in `localStorage` and restored on every page load. - **Interactive release table** — search results show a per-candidate release list with columns matching Sonarr/Radarr interactive search: Title, Tracker, Size, Peers, Quality, Score, HDR. HDR format is auto-detected from the release title (DV/HDR10+/HDR10/HDR/HLG) and shown as a coloured badge (purple → blue → cyan → green → teal). Releases are sorted by custom format score → quality weight → seeders, exactly as Sonarr/Radarr ranks them. - **One-click grab** — each release row has a Grab button that POSTs directly to the Sonarr/Radarr `/api/v3/release` endpoint, equivalent to clicking Grab in the interactive search UI. Shows grabbing → grabbed / failed states with retry. - **Season-pack grouping** — Sonarr episode files from the same series and season are collapsed into a single candidate row and searched as a season pack (using Sonarr's `?seriesId=&seasonNumber=` endpoint), reducing redundant searches and favouring complete-season releases. - **Sort options** — Largest (biggest files first), Smallest (quickest wins first), Random (OS-entropy shuffle via `SystemRandom`), A → Z. - **Search depth** — 5 / 15 / All candidates, with estimated wall-clock time per option. "All" resolves to the full available candidate count. ### Bug Fixes - **Watchdog loop on large libraries** — for libraries with 650K+ files (e.g. full Blu-ray BDMV rips), the audit scan takes several minutes. Filesystem events from the active torrent client continuously re-armed the watchdog debounce timer during the scan. When the scan completed, the pending timer fired ~60s later and immediately started another scan, creating an infinite loop. The watchdog now enforces a post-scan suppression window of `max(cooldown, last_scan_duration)` seconds — events queued during a scan can no longer re-trigger an immediate rescan. - **"string or blob too big" audit failure on large libraries** — users with 16K+ torrents (especially season-pack-heavy or anime libraries) could hit SQLite's ~1 GB text limit when storing `files_json` in the `file_results` table. Raw JSON for large libraries can reach 500 MB–1 GB; `db_save_file_results` now compresses with `zlib` (level 1) before writing, reducing typical payloads to ~5–10% of original size. `db_load_file_results` decompresses BLOB rows and falls back to plain JSON for rows written by older versions — no migration required. The failure appeared in the UI as "Audit error: string or blob too big" with the media page loading but all other pages stuck on the loading animation. - **Sonarr/Radarr open link broken** — the "open in Sonarr/Radarr" badge link was building URLs using the numeric internal ID (e.g. `/series/123`) because `titleSlug` was not being extracted from the Sonarr `/api/v3/episodefile` and Radarr `/api/v3/movie` responses. The slug is now stored alongside each media record and used to build correct deep links (e.g. `/series/breaking-bad`). - **Quality filters not applied on first search** — `handleGenerate` was wrapped in `useCallback` with filter values in its dependency array; on the first click the initial closure (with empty filter arrays) could fire before React had swapped in the updated callback. Converted to a plain `async` function defined in the render body, which always closes over the current render's filter state. - **Best release selected by seeder count** — the auto-selected "best" release was chosen with `max(filtered, key=seeders)`, ignoring Sonarr/Radarr's own quality scoring. Changed to `filtered[0]` after sorting by `(customFormatScore, qualityWeight, seeders)` desc, matching the order shown in interactive search. ### Improvements - **Backfill filter persistence** — Resolution, Source, HDR, sort order, search count, and selected folders are all saved to `localStorage` under `auditorr_backfill_prefs` and restored on page load. Indexer strategy (download-from / seeding-on) continues to be persisted server-side via the config API. - **Sidebar Workflows group** — "Workflows" is now an expandable nav group with a chevron. Clicking it expands and navigates to the Backfill workflow. Future workflows can be added as additional children without sidebar restructuring. - **Candidate count diagnostic** — the Search Depth section now shows both the searchable candidate count (files matched to the Sonarr/Radarr library) and the total unseeded file count, making path-mapping gaps visible (e.g. "194 searchable candidates (820 total unseeded)"). ## v1.5.1 — 2026-05-29 ### Features - **Arr metadata relink workflow** — new "Relink" button in the dashboard for media files that are missing hardlinks. auditorr cross-references each unlinked media file against Sonarr/Radarr metadata and unlinked torrent files, scores each candidate by size match, title token overlap, year, service/category, and seeding status, then generates a reviewed bash script (`ln` with pre-flight `cmp` verification). The script never executes automatically — you review and run it manually. Supports multi-instance Arr connections; per-connection path mapping is applied before matching. - **Multi-Arr support** — Config now supports multiple Sonarr and Radarr instances with editable connection rows (label, service toggle, URL, API key, per-instance path mapping) instead of a raw JSON textarea. Instance rows live in the Sonarr/Radarr card alongside the primary connection fields. Connections with a saved API key show a "stored key" badge; renaming a connection ID while a stored key is in place shows an inline warning to re-enter the key before saving. - **Media server exclusion presets** — new "Media Server" section in Config → Exclusion Patterns. Select one or more presets (Plex, Jellyfin, Emby, Kodi, UMS) to automatically exclude all common sidecar files for that server: `.nfo` files, artwork images (`poster.jpg`, `folder.jpg`, `fanart*.jpg`, `*-backdrop.png`, etc.), and server-specific metadata files (`.plexmatch`, `extrafanart`). Presets are applied on top of any custom exclusion patterns and are deduplicated before the scan. ### Bug Fixes - **qui/qBittorrent connection race on server reboot** — startup audit now retries once after a 60-second delay when the first attempt fails with a source connection error (e.g. qBittorrent or qui not yet ready on container start). The first transient failure is not persisted to audit history; if the retry succeeds, only the successful audit appears in history. If the retry also fails, that final failure is recorded. Manual, watchdog, and scheduled scan errors are unchanged. - **Tracker chart y-axis byte formatting** — seeding-size y-axis in TrackerCard and the upload activity chart now use a shared `formatBytesCompact` helper (e.g. `28.4G`, `1.2T`) instead of duplicated inline ternary expressions, fixing unit mismatches when values straddle a size boundary. ### Improvements - **Dedupe script covers all file pairs** — previously only searched for duplicates within the torrent directory; it now compares across all scanned files. Duplicate pairs can be torrent↔torrent, media↔media, or torrent↔media — any combination is detected and emitted as a hardlink command. The deduplication guard (`covered_paths`) prevents the same pair appearing twice when both sides list each other as a duplicate. - **Relink workflow replaced with candidate preview** — "Generate Relink Script" is replaced by "View Relink Candidates", a modal that lists each unlinked media file alongside its fuzzy-matched torrent candidate with file size and confidence score. Since the dedupe script now covers torrent↔media identical pairs, a separate relink script is redundant; an "Open Dedupe Script" button in the modal footer links directly to the fix. - **Config responsive layout** — form grids throughout Config now use `auto-fit / minmax` columns instead of fixed 2- or 3-column grids, scaling gracefully at narrow panel widths. ### Performance - **Precompiled exclusion patterns** — `compile_exclusions(patterns)` pre-classifies every pattern into typed buckets at audit startup: O(1) extension set (`ext:` rules and pure `*.` globs), O(1) exact-name set (barewords and `name:` rules), precomputed subtree prefix tuples (`/**` and trailing-`/` patterns), and precomputed variant tuples for path globs and plain-path prefix rules. The compiled matcher's `match()` does only the per-file work. For a 100-pattern list including all media-server presets, this eliminates ~100 `_norm`/`_variants` calls per file, reducing exclusion checking from the dominant scan cost to near-zero. - **Relink candidate scan scalability** — `find_relink_candidates` now pre-builds the eligible torrent list (filtering excluded/linked/orphaned torrents and precomputing `_token_text` and the token set once per torrent) before the media loop, eliminating the O(media × torrents) redundant work. Added a `RELINK_MAX_PAIRS` guard (default 25,000,000): if the eligible pair count exceeds the cap, the scan logs a warning and returns an empty list rather than pinning the single worker. Below the cap, output is identical. - **Parallel Sonarr episode-file fetching** — `_fetch_sonarr_media` now fans the per-series `/api/v3/episodefile?seriesId=` requests over a bounded thread pool (≤ 8 workers) instead of issuing them sequentially. For Sonarr libraries with hundreds of series this eliminates the N+1 sequential API call pattern and cuts fetch time roughly proportional to series count. ## v1.5.0 — 2026-05-22 ### Performance - **Large-library memory usage** — four changes combine to reduce peak and sustained RAM for libraries with hundreds of thousands of files. Users previously reported 8 GB of Docker memory usage after 24 hours; the root causes were all in the backend: - **Early release of scan intermediates** — `torrent_records`, `media_records`, `inode_map`, and `duplicate_map` are explicitly deleted after `_assemble_records()` so Python's GC can collect them before subsequent allocations. For a 500k-file library these four structures together can reach 600–700 MB. - **Early release of raw qBittorrent objects** — `tracker_map` and `files_map` (the raw per-torrent dicts from the qBittorrent API) are released immediately after the normalized `file_map` is built in `sources/_qbit.py`. - **File lists removed from `/api/results`** — `media_files` and `torrent_files` are no longer embedded in the `latest_results` SQLite row or returned by `/api/results`. A new `/api/files?tab=media|torrents` endpoint and `file_results` table serve them on demand. The frontend fetches them lazily only when the user navigates to the File Explorer or Trackers tabs. Dashboard, Sidebar, and Trackers now derive all display values from pre-computed summary stats (`tracker_file_stats`, `not_imported_paths`, `cross_seed_stats`) stored directly in `/api/results`, so a typical page load and dashboard refresh never touches the file lists at all. - **`audit_snapshots` no longer stores file lists** — the per-audit snapshot previously included the full `media_files` and `torrent_files` arrays (300–500 MB of JSON per row on large libraries). At the end of every successful audit these two snapshots were immediately re-read and deserialized to compute the change log diff — a 600 MB–1 GB peak that repeated on every `/api/changes` request. Snapshots now store only `dashboard` stats (a few KB per row). Diff computation uses the in-memory file data *before* overwriting `file_results`, and `/api/changes` reads the pre-computed diff from `change_log` instead of deserializing snapshots. A one-time startup migration strips file lists from any existing rows using SQLite's `json_remove` (no Python deserialization required). ### Bug Fixes - **Media/Torrents blank page after lazy-load refactor** — fixed a React hook ordering violation in the File Explorer that could crash the app after navigating to Media or Torrents. The lazy-loaded pages first rendered a loading skeleton with no file list, then re-rendered when `/api/files` returned data; a `useState` hook below that early skeleton return caused React to see a different hook order between renders. - **Old-result migration for lazy file storage** — if an existing `latest_results` row still contains legacy `media_files` or `torrent_files` arrays, startup/read migration now moves those arrays into the new `file_results` table before stripping them from `/api/results`. This preserves Media/Torrents data across upgrade instead of leaving the lazy file endpoint empty until the next audit. - **Local Torrent Path hint text typo** — corrected "youor" → "your" in the Config path mappings field hint. ## v1.4.4 — 2026-05-19 ### Bug Fixes - **qui seeding size inflated N× in multi-instance setups** — tracker seeding size and upload stats are now deduplicated by torrent hash across all qui instances. In setups where qui's per-instance torrent endpoint returns all managed torrents regardless of which instance is queried, the same hash was being counted once per eligible instance, causing `seeding_size` for a tracker to read as N× the correct value (e.g. 220 TB instead of 28 TB with ~8 instances). - **qui upload and seeding stats verified against live API** — confirmed via the live qui OpenAPI spec and actual API responses that the torrent list does include `uploaded` (cumulative bytes) despite the spec's `Torrent` schema omitting it. The existing `_norm_torrent` field mapping was correct. The seeding size deduplication via `seen_hashes` (shared across all instance calls) is confirmed to correctly prevent N× inflation in multi-instance setups where qui returns all managed torrents from every per-instance endpoint. - **Hardlink/duplicate popover off-screen and mispositioned** — replaced the hover-triggered filename popover with a click-triggered modal. The old implementation added `window.scrollY` to a `position: fixed` coordinate (double-counting scroll offset), causing the tooltip to appear far below its anchor when the page was scrolled. The popover also disappeared when moving the mouse toward it and rendered off-screen for rows near the bottom of the viewport. The new ⓘ button next to the filename opens a centered overlay showing all hardlink and duplicate paths; close with ✕, backdrop click, or Escape. ### Improvements - **Media / Torrents and Change Log pages fill the viewport** — all three pages now use a flex-column layout that fills the available height exactly. The virtualized file list and change log table expand to the bottom of the window rather than stopping at a fixed `calc(100vh - 360px)` offset, eliminating the blank space below the table. The "N files · X TB" footer line on the Media/Torrents pages is preserved as a flex item below the list and cannot be overwritten. - **Scan duration tracked and displayed** — audit wall-clock time is now captured for every scan (success and error) and stored in a new `duration_seconds` column in `audit_runs`. The Config audit history table shows a Duration column (e.g. `45s`, `2m 13s`). The Audit History page annotates each entry's date with the scan time for that audit (e.g. `May 19, 12:00 · 2m 13s`). Existing rows in the database show `—` until the next audit completes. ## v1.4.3 — 2026-05-18 ### Bug Fixes - **Test Sonarr / Test Radarr always failed** — the test endpoints now fall back to the stored API key (and URL) when the frontend submits a blank value. The config GET response masks keys as `__stored__` for security; the form cleared this to an empty string, causing the test to immediately return "URL and API key are required" even with a valid saved key. ## v1.4.2 — 2026-05-18 ### Improvements - **Audit History redesigned as a flat table** — the Changes page now renders all file-level diffs across all scans as a single virtualized table (react-window) instead of per-scan accordion cards. Columns: Date, Trigger, Type, Path, Size. A sticky filter bar provides category chips, date-range presets (24h / 7d / 30d / 90d), a debounced path search input, and a CSV export button for the current filtered view. - **New File / Removed split by side** — "New File" and "Removed" change types are now split into "New Torrent" / "New Media" and "Removed Torrent" / "Removed Media", keyed on the existing `tab` field already present in diffs. Makes it clear when both sides of a hardlink arrived or left together, rather than showing duplicate-looking entries. Applied consistently in both the Audit History table and the dashboard Changes panel. ### Performance - **Virtual scrolling in Media / Torrents explorer** — both flat list and tree view now use `react-window` `FixedSizeList` backed by `AutoSizer`. Only the visible rows (~20–30) are mounted in the DOM regardless of library size, eliminating the primary source of slowness on large libraries. The tree view pre-computes a flat ordered array of visible rows (respecting open/closed folder state) so virtualization applies to the full expanded tree, not just root-level folders. - **Debounced search** — the filename search input now waits 150 ms after the last keystroke before filtering, preventing per-keystroke full-array scans on large libraries. - **Single-pass summary stats** — the Total / Seeding / Orphaned counts and sizes are now computed in one loop instead of four separate filter/reduce passes over the filtered array. - **Stable `itemData` references** — `itemData` objects passed to `react-window` are `useMemo`'d, preventing unnecessary re-renders of all visible rows on unrelated state changes. - **Row state isolation** — virtual list item renderers key inner row components by `node.path`, forcing a clean remount (and resetting Sonarr/Radarr button state) whenever a list slot's file changes after a filter update. ### Bug Fixes - **Duplicate detection false positives** - file identity now uses both device and inode (`st_dev`, `st_ino`) instead of inode alone. This prevents unrelated same-size files on different mounts or filesystems from being grouped together when their raw inode numbers collide, reducing false duplicate flags for small files and cross-device libraries. Dedupe script grouping and duplicate health counts now use the same device-aware identity. ## v1.4.1 — 2026-05-16 ### Features - **Change Log page** — new "Changes" sidebar page with a persistent, browsable history of file-level diffs between consecutive audits. Each entry shows timestamp, trigger, health score, and score delta, with expandable category chips (Became Orphaned / New Duplicates / Newly Imported / Duplicates Resolved / New Files / Removed Files) and file lists. A filter bar lets you narrow to entries containing a specific category. Diffs are stored in a new `change_log` SQLite table, pruned to 90 days, and computed automatically at the end of every successful audit. - **Watchdog enable/disable toggle** — new "Filesystem Watchdog" toggle in Config → Watchdog & Scheduled Audits. Enabled by default; disable for large libraries with frequent downloads to prevent constant rescans. The cooldown and interval fields dim and become non-interactive when the watchdog is disabled. ### Bug Fixes - **Startup audit skipped when unconfigured** — auditorr no longer fires an audit on startup when the configured torrent source has no host set (fresh install or new container). This prevented a spurious `qBittorrent error` from appearing in the UI before the user had completed setup. The first audit now starts correctly after step 2 of the setup wizard is submitted, using the source the user actually selected. - **Scan progress panel dismissible** — the scanning card (bottom-right) can now be dismissed by clicking anywhere outside it or pressing the × button. The panel reappears automatically when the next scan starts. ## v1.4.0 — 2026-05-12 ### Features - **qui torrent source** — auditorr can now connect to [qui](https://github.com/autobrr/qui) as an alternative to a direct qBittorrent connection. qui aggregates multiple qBittorrent instances behind a single API endpoint, making it the right choice for multi-instance setups sharing a common filesystem (e.g. mergerfs). Select the source in Config or the setup wizard; all existing qBittorrent behaviour is unchanged. - **`sources/` package** — torrent-source logic is now isolated in `sources/__init__.py`, `sources/_qbit.py`, and `sources/_qui.py` behind a common `fetch_file_map` / `test_connection` / `connection_info` / `fetch_save_path_hint` interface. Adding future sources requires only a new backend module and a one-line dispatcher update. - **Source-aware deep links in file explorer** — the Torrents tab now shows a `qBit ↗` or `qui ↗` button on each file row that opens the torrent source web UI. For qui, the link navigates directly to the instance that owns that file. - **Source-aware setup wizard** — Step 1 of the setup wizard now has a [qBittorrent] / [qui] segmented toggle with conditional credential fields and post-test info cards for both sources. ### Improvements - **Exclusion patterns: hide from file explorer** — a new "File explorer visibility" toggle in Config → Exclusion Patterns controls whether excluded files appear in the file explorer. Default is Visible (existing behaviour). When set to Hidden, excluded files are filtered out of all views; switching the status filter to "Excluded" still shows them so they remain accessible on demand. ### Bug Fixes - **Upload spike detection via instance count** — each upload snapshot now embeds `_instance_count` (the number of eligible qui instances contributing to that scan, always 1 for qBittorrent). `compute_upload_stats` skips delta pairs where the instance count differs between consecutive snapshots, preventing false upload spikes when a new qui instance is added or when a partial snapshot was captured while one instance was temporarily unreachable. Old snapshots without the field are treated as count 1 for backward compatibility. - **Removed Upload Baseline card from Config** — the retag/delete UI panel exposed destructive database operations with insufficient safeguards. Backend endpoints remain auth-protected and available for direct API access if needed. ### API changes - `/api/source_info` (renamed from `/api/qbit_info`) — returns connection info for whichever source is configured - `/api/source_save_path` (renamed from `/api/qbit_save_path`) — fetches the common save-path prefix from whichever source is configured - `/api/test_connection` — now source-aware; returns `instances`, `eligible_count`, and `skipped` for qui ## v1.3.6 — 2026-05-11 ### Bug Fixes - **Delete script: fix `df` fallback never running** — `FREE_BEFORE`/`FREE_AFTER` were captured via `df --output=avail -B1 | tail -1 || fallback`. Because `tail` exits 0 even on empty input, the `||` fallback never fired when `df --output=avail` wasn't supported, leaving both variables empty and reporting `Actual: 0B`. Fixed by capturing the primary output first and checking emptiness before falling back. - **Delete script: tighten variance threshold** — acceptable variance between expected and actual space freed tightened from 10% to 2%. ### Improvements - **Delete script: hardlink-aware space accounting** — the script now checks each file's inode link count before deletion (`stat -c '%h'` / `stat -f '%l'`). Hardlinked files (inode still referenced by the media directory) are tracked separately and excluded from the expected-vs-actual comparison, since deleting one link doesn't free space until the last reference is gone. The summary shows a breakdown of hardlinked vs standalone files and the correct `✓` confirmation when 0 bytes freed is expected. ## v1.3.5 — 2026-05-07 ### Bug Fixes - **Dedupe script path root** — the generated dedupe script now computes the common ancestor of `LOCAL_PATH` and `MEDIA_PATH` (e.g. `/data` when torrents are under `/data/torrents` and media under `/data/media`) and makes every path in the script relative to that root. Previously, canonical paths were relative to `LOCAL_PATH` and duplicate paths were relative to either `LOCAL_PATH` or `MEDIA_PATH`, so no single `cd` could resolve both — causing the working-directory guard and all `ln -f` commands to fail. The USAGE comment now names the in-container root so users know which host directory to `cd` into before running the script. ## v1.3.4 — 2026-04-30 ### Bug Fixes - **Clear stale qBittorrent error on successful test connection** — after a successful test connection, the persisted results status is reset to `ok` if it was a `qBittorrent error`. Previously, a qBit error from a prior run would persist in the ErrorBanner until the next full audit completed, even after fixing the connection. - **Script modal z-index** — fixed script output modal being obscured by other UI elements. - **Dedupe DONE counter accuracy** — dedupe script progress counter now correctly reflects completed operations. ## v1.3.3 — 2026-04-02 ### Performance - **Per-thread qBittorrent login** — worker threads now log in once per thread via `threading.local()` instead of once per torrent. On large libraries with thousands of torrents this was causing 10–16 minute scan startup times; logins are now capped at 16 (one per worker) regardless of library size. ### Bug Fixes - **Informative test connection errors** — `/api/test_connection` now returns distinct messages for the three real failure modes: wrong credentials (login failed), unreachable host (connection refused / DNS failure), and timeout. Previously all failures returned a raw library exception string. Successful connections now also return the qBittorrent version. ## v1.3.2 — 2026-04-01 ### Performance & Large Library Improvements - **Scan progress card** — floating card (max-width 860px, bottom-right) replaces the small sidebar progress bar during active scans. Shows two phase bars (Torrents / Disk) with live status labels, a file counter, and a status message line. Automatically dismisses when results finish loading. - **Results loading state** — card stays visible after the scan completes while results are being fetched, showing an animated indeterminate bar with an ease-out fill that approaches 85% over ~8 seconds then snaps to 100% on completion. - **Faster progress polling** — poll rate drops to 500ms while a scan is active, returning to 5s at idle. - **Eliminated count_files pre-pass** — file total is now accumulated incrementally during the directory walk rather than via a separate full `os.walk` pre-pass. Removes a redundant full directory traversal on every scan, improving startup time on large libraries. - **Phase tracking** — audit state now includes a `phase` field (`connecting` / `torrents` / `disk` / `post` / `idle`) exposed via `/api/progress`, driving the two-phase card UI. - **Gunicorn worker timeout** — bumped from 120s to 300s to accommodate large result payload serialization on libraries with many files. ## v1.3.1 — 2026-03-31 ### Bug Fixes - **Config test connection** — fixed `InvalidURL: No host supplied` error shown on page reload or first setup before saving config. ## v1.3.0 — 2026-03-28 ### Setup & Onboarding - **Setup wizard** — 3-step wizard shown on first launch when no config exists. Step 1: qBittorrent connection with post-test info card. Step 2: data paths with container filesystem browser and "Fetch from qBittorrent" save-path button. Step 3: Sonarr/Radarr. An early-start button on Step 2 lets users trigger a first audit before completing optional integrations. ### qBittorrent - **Connection info card** — after a successful test connection, shows qBittorrent version, torrent count, and total seeding size inline in the qBit card - **Save path auto-fetch** — "Fetch from qBittorrent" button computes the common prefix of the first 50 torrent save paths and fills the qBit Save Path field - **Thread-safe connection timeout** — `test_connection` route uses `threading.Thread` + `t.join(timeout=10)` so a hanging qBit instance cannot block the gunicorn worker indefinitely; `_fetch_qbit_file_map` wraps the full scan in `socket.setdefaulttimeout(30)` / `finally: socket.setdefaulttimeout(None)` - **`/api/qbit_save_path`** — POST endpoint now returns `version`, `torrent_count`, and `seeding_size` alongside `save_path`, enabling the wizard to populate the info card from a single credentials-in-body call ### Config - **Inline test buttons** — Test Connection moved into the qBittorrent card; Media Path and Local Torrent Path each show per-field ✓/✗ feedback after Test Paths - **Unsaved changes indicator** — amber dot on the Save Settings button appears whenever a field has been modified since last save; clears on successful save - **Path warnings persistence** — path warnings from config save are stored in `localStorage` and survive page reload - **Container filesystem browser** — collapsible `/data` directory browser in the Path Mappings card with click-to-fill for Media Path and Local Torrent Path ### Dashboard & Explorer - **Flat/tree view toggle** — ⊟ Tree / ⊞ Flat segmented control in the file explorer toolbar, persisted to `localStorage`; search and path-reveal force flat mode regardless of toggle state - **Size sort** — Sort: Name | Size inline toggle in flat mode; sorts by file size descending - **Hardlinks/dupes custom popover** — replaces native browser title tooltip with a styled `position: fixed` overlay below the filename, showing HARDLINKS and DUPLICATES sections with horizontal viewport clamping and word-break paths - **Changes panel collapse** — ▶/▼ toggle in the changes panel header collapses the category list; state persisted to `localStorage` as `auditorr_changes_collapsed` ### Bug Fixes - **Title parser** — `\s+` anchor in `_parse_title_from_filename` prevents quality tags from matching mid-word (e.g. `Internal` in "Internal Affairs" is no longer stripped; `INTERNAL` only matches as a standalone token) - **Path boundary substitution** — `arr_rescan` boundary check `abs_path[len(local_path):][:1] in ('/', '')` prevents `/data/media-extra` from being treated as a sub-path of `/data/media` - **Infinite 401 retry loop** — `api.js` `req()`/`reqText()` now pass a `retried` flag to prevent prompting for the secret key on every request after a second failure - **Hook ordering violation** — `computeCrossSeedStats` `useMemo` in `Dashboard.jsx` moved before the `if (!data)` early return - **Notification API guard** — `'Notification' in window` check added in `App.jsx` before all `Notification` calls, fixing crashes in browsers that don't implement the API - **Delete script abort** — `set -euo pipefail` removed from the orphaned torrents delete script; per-file existence checks handle errors without aborting on first missing file ### Tests - **pytest suite** — `tests/` directory with 71 tests covering `compute_diff`, `_parse_title_from_filename`, `process_health_metrics`, `validate_config`, `generate_script`, `_normalize_title`, and `arr_rescan` path boundary substitution. No external dependencies beyond `requirements.txt`; all qBittorrent and SQLite I/O mocked with `unittest.mock`. - **xfail tests** — 2 tests document the known `4K`-in-title regex bug (`strict=True` so they will automatically surface as passes once fixed) # v1.2.1 — 2026-03-25 ### Security - **Mask Sonarr/Radarr API keys** — `SONARR_API_KEY` and `RADARR_API_KEY` are now masked as `__stored__` in the `/api/config` GET response, consistent with existing `QB_PASS` behaviour. Keys are preserved on save when the masked placeholder is submitted. ## v1.2.0 — 2026-03-24 ### Backend - **Modular refactor** — `app.py` split into `audit.py`, `arr.py`, `db.py`, `scripts.py`, `state.py`, and `watchdog_handler.py`. Routes remain in `app.py`. Strict module layering eliminates circular imports. - **SQLite persistence for all data** — `results.json` and `history.json` migrated to SQLite with automatic startup migration. Config migrated from `config.json` to a `config` table; all modules read config directly from the database with no in-memory cache or global lock. - **Config validation** — `validate_config()` in `db.py` enforces integer minimums, float ranges for health thresholds, and list constraints on exclusion patterns. Called before every config save. - **Per-tracker upload snapshots** — each successful audit snapshots cumulative uploaded bytes and seeding size per tracker. Stored in a new `upload_snapshots` table (capped at 1000 rows). Deltas between snapshots produce upload-per-day stats and yield metrics. - **Yield metrics** — `compute_upload_stats()` computes upload deltas and yield per tracker. Yield = total uploaded / seeding size over a rolling window. Counter resets (qBit restarts) are detected and skipped. A lightweight `yield_summary` key is appended to `/api/results` after each audit. - **TOCTOU fix on scan state** — `try_start_scanning(trigger)` atomically checks and sets `is_scanning` in a single lock acquisition, eliminating the race across all trigger paths (manual, watchdog, scheduled, startup). - **Shell injection fix** — all path interpolations in generated bash scripts now use `shlex.quote()`. `echo` with interpolated paths replaced with `printf '%s'`. - **WAL mode and foreign keys** — SQLite connections now enable WAL journal mode and foreign key enforcement on every connection. ### Frontend - **Disk size as primary metric** — dashboard metric cards show total size as the headline value rather than file count. - **Aligned button rows** — action button rows in metric cards use consistent height so buttons align across all four cards regardless of how many are visible. ### Scripts - **Relative paths in generated scripts** — delete and dedupe scripts now use paths relative to the torrent/data directory instead of absolute container paths. Both scripts include a usage header and a working-directory guard that verifies the first file exists before proceeding. ## v1.1.0 ### New Features - **Sonarr/Radarr integration** — configure URLs and API keys in Config. Orphaned media files in the Media explorer show "Open in Sonarr" or "Open in Radarr" pill buttons that deep-link directly to the correct series/movie page for interactive search. Button shown is determined automatically from the file path (tv/television folders → Sonarr, movie folders → Radarr). - **Dashboard action buttons** — each metric card now has inline action buttons. Orphaned Torrents: Generate Delete Script. Not Imported: Trigger Sonarr Rescan / Trigger Radarr Rescan. Duplicate Files: Generate Dedupe Script. Hardlinked Media: View Orphaned Media. - **Delete script** — generates a reviewed bash script for orphaned torrent cleanup with per-file progress output, pre/post disk space measurement, and actual vs expected space freed comparison. - **Dedupe script** — generates a bash script that replaces duplicate files with hardlinks. Runs full md5sum verification on each file pair before hardlinking. Includes progress output and skips cross-filesystem groups that cannot be hardlinked. - **Light mode** — toggle in Config, persisted in localStorage. - **Exclusion patterns** — glob-based patterns in Config exclude files from health scoring while keeping them visible in the explorer with an "excluded" tag. - **Rescan on config save** — saving settings triggers an immediate background audit. - **Sonarr/Radarr remote path config** — separate path translation for Sonarr and Radarr containers that may see torrent paths differently than auditorr. ### Backend - **Parallelized tracker fetching** — tracker API calls now run concurrently with ThreadPoolExecutor (16 workers), eliminating sequential per-torrent qBittorrent API calls and significantly reducing scan time on large libraries. - **Torrent hash stored per file** — hash is now captured from qBittorrent and stored in results, enabling future deep-link and per-torrent features. - **Normalized title matching** — Sonarr/Radarr library search strips punctuation before comparing, fixing matches for titles with colons, dashes, and other special characters. - **Rescan path translation** — Sonarr/Radarr rescan commands translate auditorr-local paths to the correct path as seen inside the arr container. - **set -euo pipefail removed from delete script** — per-file existence checks handle errors gracefully without aborting the entire script on first failure. ### Frontend - **Script modal** — full-screen overlay for reviewing generated bash scripts before running. Shows warning banner, scrollable monospace script, copy to clipboard, and download as .sh. - **Smart Sonarr/Radarr button visibility** — buttons only shown for relevant file types based on path detection, not on every orphaned file. - **Action button rows** — consistent two-row button layout across all dashboard cards, aligned left-to-right. - **Recoverable size** — orphaned torrents card shows total recoverable GB in the same style as duplicate files. ## v1.0.1 ### New Features - **Exclusion patterns** — glob-based exclusions configurable in Config → Exclusion Patterns. One pattern per line. Matched files are excluded from all health scoring (orphaned, duplicate, not-imported) but remain visible in the file explorer with an "excluded" tag. Supports standard globs: `*.srt`, `@eaDir`, `Featurettes`. - **Rescan on config save** — saving settings now automatically triggers a background audit so changes take effect immediately without manually running a scan. ## v1.0.0 — Initial Release ### Features - Library health score (0–100) with color-coded arc dial - Score history chart with smart trend delta (vs yesterday or last week) - Cross-seed effectiveness panel — weighted average seed multiplier, segmented disk bar, tracker leaderboard - File explorer with tree view for media and torrent directories - Filters: status, import state, tracker include/exclude, seed count, filename search, size range - "What changed" panel — diff between last two scans showing newly orphaned, imported, duplicate, and removed files - Threshold alerts on dashboard when categories significantly exceed configured limits - Audit history table in Config — last 50 runs with time, trigger, score, and status - Light and dark mode — toggle in Config, persisted in localStorage - Hash-based URL routing — tabs are bookmarkable, browser back/forward works - Copy Paths button — copies all filtered file paths to clipboard - Per-row path copy icon on every file row - Export CSV for any filtered view ### Backend - Flask + gunicorn (single worker, 120s timeout) - SQLite persistence — every audit stored with full snapshot, survives restarts - Watchdog filesystem observer — debounced inotify-based audit triggering - Scheduled fallback audit — configurable interval, catches missed watchdog events on NFS/bind mounts - O(n) duplicate detection — group by size → group by inode → hash one representative per inode - Atomic file writes with cross-filesystem fallback (handles Docker volume mounts) - Startup lock — prevents duplicate audits on container start - Optional API authentication via AUDITORR_SECRET env var - Health recomputed immediately on config save — no rescan needed to see threshold changes - Config path validation — warns if container paths don't exist without blocking save - /health endpoint for Docker/Traefik healthchecks ### Configuration - qBittorrent connection (host, user, password) - Path mappings with remote→local path translation for split-container setups - Watchdog cooldown and scheduled interval - Per-category health thresholds (orphaned, not imported, duplicates) - Default port: 8677 (t=8, o=6, r=7, r=7 on a phone keypad)