Aligning the Plex trash sweep with zurg's own file status
Purpose of this document
The Plex trash sweep currently decides what to remove from a Plex library using file absence plus an age timer. zurg already knows something far better — whether the content behind that file is repairable or permanently dead — and does not consult it.
This document is the plan for replacing the timer with that knowledge. It exists to be reviewed before any code is written, because the change decides what gets deleted from a media library and the current behaviour is wrong in both directions: it waits two weeks to remove content that is known dead on day one, and it deletes content that zurg is still actively repairing.
It records what was verified in the code and on a live library, what is being proposed, and what is deliberately left alone. Everything stated as fact below was checked against the running system on 2026-08-16, not inferred from naming.
Scope: internal/plexsweep, internal/plexdb, and a new read-only query into
internal/torrent. No change to repair behaviour itself.
1. Background — what shipped, and why it needs revisiting
Four commits landed the sweep:
The sweep's governing invariant is unchanged and stays: nothing that still has a
file is ever removed. Items with a live file are repaired, or left alone with
their trash icon. Shows and seasons with live episodes are never touched. Removal
is per item, never Plex's section-wide emptyTrash.
What is being revisited is only the rule for when a genuinely-absent entry may be removed.
The timer is a proxy for a question zurg can answer directly
plex_trash_sweep_min_age_days waits 14 days before removing an entry, on the
reasoning that the red trash icon is the only notice an operator gets that content
went missing, and purging the entry destroys that notice.
The intent is right; the mechanism is a guess. It is wrong in both directions:
-
A torrent marked
infringing torrentis known dead immediately. Making its Plex entry linger a fortnight helps nobody — waiting cannot bring it back. -
A torrent still being retried may take longer than 14 days to come back. The timer deletes its entry while zurg is still working on it. This is the worse error, because it destroys a library entry for content that then returns.
2. Verified facts
2.1 zurg's state model
Three file states and two torrent states exist (counted across
internal/torrent, excluding tests):
Both are pkg/fsm machines. FSMWithTime records the unix timestamp of each
transition and supports SinceWhen, so "how long has this been broken" is already
answerable without adding anything.
One broken file condemns the whole torrent.
MarkFileBrokenAndEnqueueRepair (internal/torrent/hooks.go:215-234):
changed := file.State.Set("broken_file", torrent.State.GetMutex()) == nil
...
if err := torrent.State.Set("broken_torrent"); err == nil {
t.repairLog.Infof("Torrent state changed: %s [? -> broken_torrent] (has broken file)", ...)
}
The log says "has broken file", singular. One of ten broken and ten of ten broken produce an identical torrent state. Nothing aggregates the file states.
Unfixable aggregates over accounts, not files.
markAsUnrepairable (internal/torrent/unplayable_unrepairable.go:44-71):
if cp != nil {
cp.SetUnrepairableReasonIfEmpty(reason) // one account's copy
entryCondemned = t.repairableCopy(torrent) == nil
}
if entryCondemned {
torrent.SetUnrepairableReasonIfEmpty(reason) // the entry
}
The entry is condemned when no repair-capable copy remains. Deliberately not
"every copy carries a reason" — a copy that could never be repaired anyway (the
Usenet backend has no magnet adder) would otherwise keep a dead release eligible
for repair forever. File carries no unrepairable field at all
(internal/torrent/file_types.go).
So the three levels answer three different questions, and none of them answers scope:
2.2 Live measurements (a production library, 2026-08-16)
Shares below are of the broken torrents, which were about 3% of the library.
Nearly all of the broken ones (98%) carry an Unfixable reason:
The rest (2%) are broken without a reason — still repairable.
But Unfixable is not the same as dead. zurg already sorts these reasons into
permanent and recoverable (internal/torrent/unrepairable_reasons.go:44-70).
permanentReasons holds eight verdicts no retry can change — infringing,
unsupported, unavailable, invalid, too big, not allowed, no repairable files,
invalid file ids — plus any reason prefixed repair failed, download status:.
Everything else, including the two largest buckets above, is explicitly
temporary: stalled download, repair failed, duplicate file IDs,
rar'ed by RD, not cached, the lone cached file is broken,
full torrent repair failed.
repair failed, no seeders was the ninth when the shares above were taken, and
the entries carrying it here are the reason it no longer is: no backend zurg
speaks to reports seeders, so nothing ever measured that verdict — it was
inferred from a repair running out of clock. It is now transient, and
unrecordable besides (verdictClaims), so those entries retire and are
tried again like any other.
Applying that split to the shares above:
An unrecognised reason (provider cannot re-add torrents, one entry here) is in neither
list, and IsPermanentlyUnrepairable returns false for it — the conservative
answer, which is the one we want.
This is the single most important finding for the design: a rule keyed on
"Unfixable is set" would remove Plex entries for two and a half times as many
releases as are actually dead.
Crucially, where those broken torrents sit relative to the mount:
This is the key measurement. It means the sweep only ever meets the second group, and for that group zurg usually already holds a verdict.
Plex side, same host: a backlog of trashed leaves, none of them past the 14-day window (oldest tombstone 2026-08-11). Plus a few empty show shells and a few dozen empty season shells.
3. The gap
broken_torrent means "at least one thing is wrong". That is the wrong
granularity for the sweep.
Concretely: a ten-episode pack where one episode's file is permanently gone, but the torrent stays repairable overall. Asking the torrent gives "still repairable", so the sweep would keep that dead episode's Plex entry forever.
There is no state meaning "this entire release is dead" as distinct from "something in this release is broken".
4. Design
4.1 Do not add a fourth persisted state
The obvious move is a new torrent state — dead_torrent, or similar. Rejected.
Every site that transitions a torrent would have to maintain it, and a stale "all files broken" flag is worse than no flag at all, because it authorises removals. The information already exists in the file states; it simply is not aggregated.
Instead, derive it:
// AllFilesBroken reports whether nothing in this release still reads.
// Derived from the file states rather than stored, so it cannot go stale
// and needs no migration or new transitions.
func (t *Torrent) AllFilesBroken() bool
Cheap, always consistent, and surfaceable in the dashboard without persistence.
Definition to settle during implementation: deleted_file must count as
not-reading alongside broken_file, and a torrent with zero files must return
false rather than vacuously true — an empty release is not evidence of death.
4.2 Ask the file, fall back to the torrent
The sweep resolves a Plex path to a zurg file, then applies:
The permanence test is IsPermanentlyUnrepairable(reason)
(internal/torrent/unrepairable_reasons.go:59), used exactly as ForceRepairTorrent
uses it. Reusing it rather than re-deriving a list matters: a reason added to
permanentReasons later must change the sweep's behaviour automatically, and an
unrecognised reason must stay non-permanent.
AllFilesBroken() does not authorise removal on its own — a wholly broken
release with a recoverable reason is still being worked on. It refines reporting
and the dashboard, and distinguishes "one episode of ten is gone" from "the whole
pack is gone" for a human reading the log.
The last row is why plex_trash_sweep_min_age_days stays. Once a torrent is gone
from the account entirely its .zurgtorrent goes with it, and zurg has no opinion
left to offer. The timer stops being the primary rule and becomes the fallback for
exactly the case where nothing better exists.
This also demotes the setting in the UI, which resolves the separate complaint that its label ("Keep Broken Entries Visible") describes a side effect rather than the action.
4.3 Path → torrent → file lookup
The existing Plex matcher goes the other way: buildPlexDirIndex
(internal/plex/matcher.go:539) indexes Plex path components and looks them up by
torrent key. The sweep needs the inverse, built from the same idea:
-
Build
map[strings.ToLower(GetKey(torrent))] *TorrentfromDirectoryMap.Get(INT_ALL). -
For a Plex path, split into components with the same normalisation as
splitPathComponents(matcher.go:525) and look each up. The release directory is the component that hits. -
Within that torrent, match the remaining path tail against
File.Path(internal/debrid/types.go:133) to reach the specific file.
Reasons this must go through TorrentManager and not string-match
data/*.zurgtorrent: disambiguated names (Some.Release {a1b2c3}), multi-account
copies, and renames all resolve correctly only through GetKey.
A failed lookup is not a licence to delete. Unresolvable → treat as "zurg has no opinion" → age-window fallback.
4.4 What does not change
- The invariant: nothing with a live file is removed.
- Shows and seasons with live descendants are never touched.
- Per-item
DELETE, neveremptyTrash. - Guards: rclone managed and running, mount reads, Plex not scanning.
- Snapshot before any change; caps over repairs and removals.
The new rules only ever make the sweep more conservative or more precise — they never authorise a removal the current code would refuse.
5. Implementation plan
Ordering note: steps 1–3 are pure additions with no behaviour change and can land as one commit; step 5 is the only one that changes what gets deleted.
6. Risks and open questions
-
Wiring order. Confirmed:
startPlexTrashSweepis called atinternal/app.go:268, andTorrentManageris constructed atinternal/app.go:285— after it. The call must move after it, or take a setter. Needs checking that nothing else depends on the current position. -
Lookup ambiguity. Two torrents whose keys collide with the same path component. Resolution: if more than one candidate matches, treat as
StatusUnknownrather than picking one. -
only_show_the_biggest_file. The movies directory hides files, so a Plex path may name a file that is not the one zurg would serve. The lookup must not assume the Plex filename appears inFile.Pathverbatim. -
Cost. Building the key index on every sweep is O(library). At a few thousand torrents that is trivial, and the sweep runs daily. Build per sweep, not per item.
-
DoesResolved: yes.Unfixableever clear?refresh.go:851-857clears it when a torrent verifies clean and returns took_torrent("all links intact, no broken files"), andrepair.go:165-181clears it — entry and every copy — when a repair is forced. So a recovered release does not carry a stale condemnation, and the design does not need to defend against one. -
The broken-but-present torrents. Out of scope here: Plex never tombstones them, so the sweep never sees them. Worth noting that they are invisible to both Plex and this feature until playback fails.
7. How it was implemented (2026-08-16)
Landed as feat: decide plex trash sweep removals from zurg's repair verdicts.
Where the implementation settled a question this document left open, or departed
from §4, the decision and its reason:
-
The lookup matches the path's parent directory exactly against
DirectoryMap[INT_ALL], instead of scanning every path component (§4.3). The mount lays a release out flat —<library dir>/<key>/<visible name>— and Plex recorded the path from the mount, which serves the key verbatim. An exact miss answers unknown, and the key-collision ambiguity (risk 2) disappears: exact keys cannot collide. -
Row 1 of the decision table changed. An
ok_filethat is nonetheless absent from the mount is a file the filters hide, not "not really gone" — keeping it forever would leave a permanent trash icon after a filter change. It falls back to the age window instead. -
deleted_filecounts alongsidebroken_filein every rule, not just inAllFilesBroken()— the table's rows read "notok_file". -
Multi-version items are judged as a unit (the table was per-file, the sweep's unit is a Plex item with parts): one recoverable part keeps the whole item; the item is dead only when every part is; any other mix falls to the age window.
-
A filename that resolves to a torrent but not to a file (rename, archive listing,
only_show_the_biggest_file— risk 3) is dead only when the entry's reason is permanent andAllFilesBroken(); recoverable when the torrent is broken without a permanent verdict; otherwise unknown. An unmatched name is never condemned by association while any file still reads. -
Wiring (risk 1):
startPlexTrashSweepmoved below the manager's construction ininternal/app.go; nothing depended on its old position. The verdicts reach the sweep behindplexsweep.Verdicts, implemented byTorrentManager.FileStatusForMountPath. Before the first refresh populatesINT_ALL, every lookup answers unknown and the sweep behaves exactly as the age-window version did.
8. Explicitly out of scope
-
Empty show and season shells. The sweep removes leaves only, so a fully-dead show leaves a husk (a few shows and a few dozen seasons on the measured library). Real, separate, needs ordering care: remove leaves, re-check the parent, remove only at zero children.
-
Any change to repair behaviour, the repair queue, or when zurg marks something unrepairable.
-
Plex's own
autoEmptyTrash, already handled by the warning in4737b677. -
The measured library's existing backlog, deliberately left for a manual decision.