Compare commits

...
616 Commits
Author SHA1 Message Date
Gmer4Lfe 5fa7750046 Sonarr and Lidarr had the same deadlock and the same missing confirmation as Radarr, so give them the same budget and strikes 2026-08-26 21:20:05 -04:00
Gmer4Lfe 1838eed855 Give AI_ASSIST_CLEANUP the consumer it has never had: it describes the shape of a classification and decides nothing, so switching it off changes no deletion 2026-08-26 18:03:17 -04:00
Gmer4Lfe 96d8a5e3f0 Make a file earn its deletion over consecutive runs, so a partial classification failure too small to trip the tracked-count gate cannot remove anything 2026-08-26 17:58:42 -04:00
Gmer4Lfe 0431e720de A cap that aborts cannot drain a backlog bigger than itself, so make it a per-run budget and let the queue clear over consecutive nights 2026-08-26 17:55:21 -04:00
Gmer4Lfe 7f4921de49 Give the wizard an assistant scoped to the step you are actually stuck on, so a first-run question can say "this" and mean something 2026-08-26 17:31:52 -04:00
Gmer4Lfe d4c19baa32 Folding the Scheduler dock into the shared chat left its scope behind, so the troubleshooter has been diagnosing without the log tail for whatever was open 2026-08-26 17:25:28 -04:00
Gmer4Lfe d444fd8036 A path with an apostrophe broke the remote shell quoting and a big file outran the connect timeout, so intact backups were reported corrupt and absent 2026-08-25 21:30:57 -04:00
Gmer4Lfe d42b1e2dda ffprobe exit 0 with a chapter-track warning is not corruption; guard the delete path against a detector that is wrong at scale 2026-08-25 18:29:43 -04:00
Gmer4Lfe 9492dc4c39 dig names the resolver it could not reach in its error text, so a DNS timeout was scraped as the answer and restarted DDNS over nothing 2026-08-25 18:29:43 -04:00
Gmer4Lfe 172beca3c5 A state key is a file path, not a regex — a release tag like [Bluray-1080p] holds an invalid range, so grep bailed and the tempfile swap wiped every other entry 2026-08-25 18:29:43 -04:00
Gmer4Lfe f0c1289519 Let the file own why the row cap is conditional; the README states that it is 2026-08-25 17:21:20 -04:00
Gmer4Lfe 128172d3a8 The row counts below four columns were the naive division, not what the board does 2026-08-25 17:21:20 -04:00
Gmer4Lfe 820e8325a6 Docs: the row-height cap is conditional now, and the board's narrow-screen behaviour was nowhere in the page help 2026-08-25 17:15:20 -04:00
Gmer4Lfe 74c6a0f5eb Monitor: hold the System card to the Network card's height below eight columns, where the two stop sharing a row 2026-08-25 17:07:18 -04:00
Gmer4Lfe d289a9101c Monitor: cap the System card's Varaverk block so the host figures set the card's height, not the sum of both 2026-08-25 17:01:52 -04:00
Gmer4Lfe 9553ecb16a The two functions the row-overflow hatch added were not in EXPORTS 2026-08-25 17:01:10 -04:00
Gmer4Lfe fb104bf05a Monitor: a rung taller than one screen sizes rows to content, since the page scrolls there anyway and the cap only clipped cards 2026-08-25 16:54:19 -04:00
Gmer4Lfe f4adc31215 Close the last four headers, and say what the destructive tools refuse to do 2026-08-25 16:51:14 -04:00
Gmer4Lfe c6254f2342 The dev checkers documented themselves under headings nothing else uses 2026-08-25 16:45:39 -04:00
Gmer4Lfe d25b147a56 A CLI tool has runtime modes, not a request 2026-08-25 16:42:39 -04:00
Gmer4Lfe 1f9915f074 Fill the single missing section in three scripts that had everything else 2026-08-25 16:41:06 -04:00
Gmer4Lfe c0e7a97a56 Document what the cert and auth sweeps will not do 2026-08-25 16:39:01 -04:00
Gmer4Lfe 68c835f33b Document what the proxy and cert collectors guarantee 2026-08-25 16:37:25 -04:00
Gmer4Lfe 6b9fda76db Say how the spawned tools are actually invoked 2026-08-25 16:34:02 -04:00
Gmer4Lfe 1be259b66d Two AI includes documented themselves under names nothing else uses 2026-08-25 16:31:43 -04:00
Gmer4Lfe 2c3c88d067 Say what the include layer exports and what it reads from conf 2026-08-25 16:29:57 -04:00
Gmer4Lfe 3fc14d16bf A worker that is spawned has arguments, not runtime modes 2026-08-25 16:25:55 -04:00
Gmer4Lfe 4cc50889d6 Document the api layer's operational models, and fix two headers that had gone stale against the code 2026-08-25 16:25:55 -04:00
Gmer4Lfe 63e68740b6 Put the header sections back in the order the other 116 scripts use 2026-08-25 16:19:19 -04:00
Gmer4Lfe f2fddeb87d The reboot path's container stops were unbounded, and stopped a different list than it restored 2026-08-24 20:22:55 -04:00
Gmer4Lfe eda411c0be Give the PHP layer somewhere to record what it swallowed 2026-08-24 18:51:36 -04:00
Gmer4Lfe 492757ce70 Walk appdata once for the space check, and refuse to export when it cannot be measured 2026-08-24 18:46:26 -04:00
Gmer4Lfe b58282b3d6 Read a filesystem once where two probes were answering the same question 2026-08-24 18:46:26 -04:00
Gmer4Lfe 9da2760a42 A localStorage guard was covering the layout restore it wrapped 2026-08-24 18:42:34 -04:00
Gmer4Lfe 049f633667 Bound the watchdogs' docker calls — a hung daemon is what they exist to catch 2026-08-24 18:39:59 -04:00
Gmer4Lfe 84946ed0c6 clear_logs previewed on one size basis and cleared on another 2026-08-24 18:36:29 -04:00
Gmer4Lfe 514e13660c Give the bash side the disk helpers it never had, reporting the same unit as vv_df 2026-08-24 18:36:29 -04:00
Gmer4Lfe 9333334b7b Bound the two tailscale lookups that were missed when the others were 2026-08-24 18:33:40 -04:00
Gmer4Lfe 367d0203b8 Find guards wired onto a helper that can never return non-zero 2026-08-24 18:22:59 -04:00
Gmer4Lfe 37017882c7 Check the header standard now that it is written down rather than implied 2026-08-24 18:22:59 -04:00
Gmer4Lfe f116a1584e Offboard reported a stack cleanup that could not fail and, on one path, had not run 2026-08-24 18:22:59 -04:00
Gmer4Lfe 1a5bacce87 Point the webhook's dependency note at where the handler actually lives 2026-08-24 18:22:59 -04:00
Gmer4Lfe f6e5f7dcd6 Drop the remote-metrics endpoint nothing called and api/monitor.php already served 2026-08-24 18:22:59 -04:00
Gmer4Lfe 0f0c407458 Arrange and folder saves reported failure on every success, and split a move across two writes 2026-08-24 18:22:59 -04:00
Gmer4Lfe 354992c9e7 Keep a script's arguments and trailing comment when it moves between orchestrators 2026-08-24 18:22:59 -04:00
Gmer4Lfe 28987240aa Let a sync window take a whole share in one click
The window editor is path-first, which is exact but slow for the case that is nearly all of them.
The chips only offer whole shares; a subpath already in the window marks its share partial rather
than offering to widen it, and the path box stays the only way to express a subpath or a profile.
2026-08-23 17:16:24 -04:00
Gmer4Lfe fbf2b92167 Drop containers that no longer exist from the conf lists, after three days absent
A removed container leaves its name behind in every list that named it, and those names go on
being acted upon — a watchdog waiting for a container docker has never heard of. Strikes rather
than a single miss, because a rebuild makes a container vanish for a moment; and the lists naming
containers on the REMOTE are excluded, since pruning those against local docker would empty them.
2026-08-23 16:54:53 -04:00
Gmer4Lfe 206a119a4b Show each container's fallback tier on the Monitor board
The row's left border carries it rather than the status dot, which already means running or
stopped.
2026-08-23 16:38:58 -04:00
Gmer4Lfe 671e7ea5a4 Make fallback coverage something that can actually happen, and say so on the page
fallback.sh starts covered containers with docker start and never creates them, so a coverage list
the partner has never been sent is a promise nothing can keep — all twelve were missing. Adds the
push and remove paths, a readiness card that checks rather than infers, and the fallback state the
assistant needs to answer for it.
2026-08-23 16:38:58 -04:00
Gmer4Lfe d5cf3db2ec Close every orchestrator the same way, and make skipped work a visible outcome
A gated-off section left nothing failed, so the weekly could run for hours and report "all
complete" beside "0 shares synced"; skipped is now derived from what was expected rather than
self-reported, and the verdict degrades to PARTIAL instead of flattering.
2026-08-23 16:38:58 -04:00
Gmer4Lfe dc8823724d Let the GPU transform actually run when deploying to a partner
The detection pattern starts with --, so grep parsed it as an option and exited 2; the negation
made the early return always true and every XML was deployed untransformed, keeping the owner's
NVIDIA device UUID on hosts that have no such card.
2026-08-23 16:38:58 -04:00
Gmer4Lfe a392108562 Warn before a transfer when a disk backing the remote share is nearly full
Online is not writable: check_remote_disks confirmed each backing disk was mounted and healthy but
never how full it was, and rsync answers a full destination by stalling rather than failing.
2026-08-23 10:15:54 -04:00
Gmer4Lfe fd1ab58598 Declare the Monitor board in one place, so a card's width and position stop living in eighteen inline styles and four media queries 2026-08-23 00:44:30 -04:00
Gmer4Lfe bd13bdc6e1 Revert the Monitor column ladder for now; the layout work is paused, not abandoned 2026-08-22 23:53:27 -04:00
Gmer4Lfe bdf1bce4be Place the storage and array cards by span, not by column index, so they follow the grid instead of forcing it back to eight 2026-08-22 23:50:13 -04:00
Gmer4Lfe c5c0477653 Derive Monitor's column count from what the cards actually need, so no width forces a sideways scroll 2026-08-22 23:45:02 -04:00
Gmer4Lfe 76faf0f9f8 Let an installation add its own tabs without the repo having to name them 2026-08-22 16:37:37 -04:00
Gmer4Lfe 879109e55d Drain the orphan backlog safest-first instead of aborting, so a run over budget still makes progress 2026-08-22 16:37:31 -04:00
Gmer4Lfe 61b7d2a96a Partnership stacks are the owner's declaration, so a mirror must read them from the owner's conf 2026-08-22 14:03:57 -04:00
Gmer4Lfe 15d802ae3d Preview mode that survives a reboot, and a fallback log that survives one too 2026-08-22 13:52:34 -04:00
Gmer4Lfe ac17be2cd9 Say fallback, not failover — the synonym was silently matching nothing in three renderers, plus retrying probes and a 2-minute handback window 2026-08-22 13:43:30 -04:00
Gmer4Lfe f1ba418aa8 A dry run walked the failover branches and reached notify(), which had no dry-run gate 2026-08-22 09:53:53 -04:00
Gmer4Lfe b17cc3a09d Say on each node card whose containers the tiers are, since they are the other host's 2026-08-22 09:49:43 -04:00
Gmer4Lfe 1c38abe1d0 Fallback coverage: colour the wait, group by tier, and lock the stacks the partner already runs 2026-08-22 09:33:06 -04:00
Gmer4Lfe 5d42a28d34 Each assistant card remembers its own conversation instead of every card resuming the newest one 2026-08-22 08:46:35 -04:00
Gmer4Lfe e5f4ede3f2 Coverage and settings above the chat, tier delays and the probe target alongside them, coverage in three columns 2026-08-22 01:43:07 -04:00
Gmer4Lfe ce613bcf9d Rehome the failover coverage picker on the Fallback tab, five days after it was dropped from Partnership for belonging here 2026-08-22 01:32:08 -04:00
Gmer4Lfe 8ce8f0dfbe The Monitor fallback card described a state file without saying whether anything still writes it 2026-08-22 01:11:00 -04:00
Gmer4Lfe 13c221244e A partner's conf is in the RAM cache, not CONF_DIR, and a commented-out template entry is not configuration 2026-08-22 01:06:24 -04:00
Gmer4Lfe a89b704fa1 Watchdog and Monitor looked the partner up by exact key, and the remote probe passed a quote to cut as a filename 2026-08-22 00:59:24 -04:00
Gmer4Lfe 0f228020ef State-file age is time since the last transition, not staleness, and a preview's state is the one being decided 2026-08-22 00:52:38 -04:00
Gmer4Lfe 8454d3d0a2 A dry run does not refresh live state, so say that rather than showing its age as stale 2026-08-22 00:48:16 -04:00
Gmer4Lfe da15cc7531 Rebuild the Fallback tab around what the daemon is actually doing, not what its last state file said 2026-08-22 00:44:35 -04:00
Gmer4Lfe 5924aae1c0 Fallback read the peer list by exact key, so a partner whose tailnet name differs by one character rendered as UNREACHABLE 2026-08-22 00:34:33 -04:00
Gmer4Lfe 46239012b4 The dry-run cleanup trap replaced the lock-release trap and orphaned fallback.lock 2026-08-22 00:24:06 -04:00
Gmer4Lfe f8119f3786 Give the test harness a stop that cannot strand its iptables rule, and clean up the dry-run state copy 2026-08-22 00:20:40 -04:00
Gmer4Lfe 41c0598220 A fallback dry run wrote real reboot-surviving state, and a failed tier writeback reported itself as a dry run 2026-08-22 00:00:56 -04:00
Gmer4Lfe 387382ec9f Media Stack called every node local, and a host whose watchdogs never ran reported all clear 2026-08-21 22:50:09 -04:00
Gmer4Lfe 20998c8c31 The Streams scope was reset to this host on every page load, on a dashboard that is never reloaded by choice 2026-08-21 21:58:24 -04:00
Gmer4Lfe f5d6edd9d0 The mirrored folder layout never reached the container card, which reads Varaverk's own store and not folder.view3's 2026-08-21 21:50:22 -04:00
Gmer4Lfe 4808a57289 Monitor's partner card read phase flags that only ever describe the mirror, so the owner rendered as not provisioned on the mirror 2026-08-21 21:43:00 -04:00
Gmer4Lfe 96b907acbc Say what Varaverk itself is using on the System card
The card described the machine and said nothing about the thing whose dashboard
it is. Cache size matters most: VV_CACHE_ROOT is under /tmp, which on Unraid is
RAM, so it is shown against the rootfs percentage it counts against. Jobs are
locks with a live process — four lock files were sitting there and one job was
actually running.
2026-08-21 09:39:52 -04:00
Gmer4Lfe d9c37ac763 Add a checker for the two JS faults this plugin has actually shipped
A cross-scope identifier and a fetch chain ending in an empty catch both pass
php -l and node --check, and together they turned a ReferenceError into what
looked like a slow load for hours. --self-test asserts both detectors still
find a known fault, because a checker that silently stops working reports a
confident zero.
2026-08-21 09:32:04 -04:00
Gmer4Lfe 146be2d0ad Report every failed fetch instead of discarding it
Twenty-four fetch chains ended in an empty catch, which is not error handling
but error deletion: the request fails, nothing renders, nothing is logged, and
the surface sits on Loading forever. That is how the mesh chat's ReferenceError
read as a slow load for as long as it did.
2026-08-21 09:23:51 -04:00
Gmer4Lfe 906375dc23 Share the font map with the function that renders it
_vvNcStyle referenced a const declared inside vvNcRender, so with the
formatting toggle on every mesh transcript threw ReferenceError and drew
nothing. The plain-render early return skipped the line, which is why it looked
like a slow load rather than a broken one.
2026-08-21 09:05:21 -04:00
Gmer4Lfe e1cdf03d40 Say why mesh chat failed instead of retrying a minute later
Every fetch ended in an empty catch, so a failed channel list was
indistinguishable from a slow one: nothing rendered, nothing was logged, and
the next attempt was a backoff tick away. Errors now land in the card's status
line and the console, a failure retries in two seconds, and opening the pane
before the list has arrived asks for it rather than waiting.
2026-08-21 09:00:19 -04:00
Gmer4Lfe 8041da5424 Stop starting Unraid's Node CLI on every Partnership render
`unraid-api apikey` costs 1.98s and ran once per render just to answer whether
a key exists. Cached for a minute — still read from the key store, so a
deletion still shows, a minute later rather than instantly. Partner probes are
multiplexed at the same time.
2026-08-21 08:53:29 -04:00
Gmer4Lfe 7c4f881b57 Render an answer in full instead of folding it behind a control
The fold saved a scroll and cost a click on every long answer, and what it hid
was usually the part worth reading. Follow already handles holding your place
while an answer arrives.
2026-08-21 08:44:08 -04:00
Gmer4Lfe 81abc5adee Add a mesh scope to the Streams card
Local stays the default and the cheap path; mesh asks each partner for its own
sessions over SSH, live rather than cached, because a stream is true for
minutes and a cached one would be confidently wrong about the only thing the
card exists to say.
2026-08-21 08:38:37 -04:00
Gmer4Lfe 331b7b6b13 Resolve a peer from tailscale status before falling back to DNS
MagicDNS does not work on this mesh, so `tailscale ip -4` missed and blocked on
a system DNS lookup until it timed out: 5.04s per call against 0.010s for the
status parse, paid by every mesh operation on both hosts. Exact match first, so
no precision is traded for the speed.
2026-08-21 08:38:37 -04:00
Gmer4Lfe fe23c59e0c Stop the process-list pipeline writing broken-pipe noise to stderr 2026-08-21 08:26:28 -04:00
Gmer4Lfe 666e1ebab2 Silence the sampler pipeline's broken-pipe chatter 2026-08-21 08:24:55 -04:00
Gmer4Lfe 3d05276312 Reach the integrated-graphics check on a box with no nvidia-smi 2026-08-21 08:24:20 -04:00
Gmer4Lfe 00fdee5649 Detect Intel integrated graphics, which nvidia-smi cannot see
HOST2 has UHD Graphics 730 and reported no GPU at all. An iGPU has no VRAM,
no sensor of its own and no encode/decode split, so it is drawn from the
per-engine busy figures Intel actually publishes and the fields it lacks are
null rather than a confident zero.
2026-08-21 08:23:14 -04:00
Gmer4Lfe 4a29e7bc99 Read the CPU temperature instead of the alarm threshold beside it
lm-sensors prints high= and crit= on the same line as the reading, so scraping
the line for its largest number reported a flat 100C on Intel while the shell's
last-field parse got a bare ")" and never fired at all. One parse in the
adapter now, preferring Tdie over Tctl since Tctl carries a +27C offset.
2026-08-21 08:17:36 -04:00
Gmer4Lfe a07019e3aa Fix the parity card referencing a variable the previous commit removed 2026-08-21 08:11:19 -04:00
Gmer4Lfe aa16fbaa8b Stop calling an empty parity slot an emulated disk
mdNumDisabled counts a slot that was never populated, so HOST2 reported
"Emulating 1 disk" for a parity2 slot with no disk in it. Emulation is a
data-disk state, and an assigned identity is what separates a slot that lost
its disk from one that never had one.
2026-08-21 08:07:38 -04:00
Gmer4Lfe 9c15df8e22 Report the owner's model and index on a mirror's Monitor card
A mirror was probing an Ollama it does not have and an index it never builds,
so the card read "unreachable / not built" about the wrong machine while the
model it actually uses was up. The card now names whose figures it is showing.
2026-08-21 07:59:50 -04:00
Gmer4Lfe 111c70d6b4 Bill a turn to the node that asked, not the node that generated
Every turn runs on the AI owner now, so the ledger's per-host column had
collapsed to a single name and the fleet breakdown said nothing. The asking
node travels with the request and is validated before it is written, which
also retires ai_token_sync.sh — a partner ledger is empty by construction.
2026-08-20 20:04:08 -04:00
Gmer4Lfe 0621f66889 Share one AI across the mesh instead of confining it to the owner
Curated state copied to every node is state that can disagree, so the index,
the model and the shared memory stay on the owner and each node reaches them
over the SSH trust onboarding already builds. Chats stay on the node that had
them; memory and bug reports stay the owner's to write.
2026-08-20 19:53:28 -04:00
Gmer4Lfe 4accd6b67e Raise the assistant's mark 2026-08-17 18:09:34 -04:00
Gmer4Lfe 63ec110177 Place the assistant's mark with a transform, not a background percentage
The image is wider than the transcript is tall, so as a background it overflowed and percentage positioning ran backwards — nudging it down visibly raised it.
2026-08-17 18:08:27 -04:00
Gmer4Lfe a27bb41b42 Nudge the mark: lower in the assistant, higher in the mesh 2026-08-17 18:06:52 -04:00
Gmer4Lfe eaa78452bc Enter asks, Ctrl+Enter breaks the line — in both chat modes
Almost every message here is one line, so the common action takes the bare key. Shift+Enter also breaks a line, because that is the reflex people arrive with.
2026-08-17 18:04:13 -04:00
Gmer4Lfe 4eb0a9c608 Offer the assistant the same formatting the mesh composer has, and drop the mark lower
Colour is chosen by name and mapped to a fixed value here, so the model never writes a style property — the same closed-set rule the mesh store uses.
2026-08-17 18:01:45 -04:00
Gmer4Lfe 2e646f82b4 Sync expand state between the two chat modes, and soften the mark
To the operator there is one box on screen, so expanding while reading the assistant and finding the mesh collapsed reads as the card forgetting.
2026-08-17 17:56:32 -04:00
Gmer4Lfe 74bd40ba78 Carry expand/collapse to the mesh chat, same corner and same glyphs
Both heights ride on the pane as data attributes so the card's mount options decide how big 'more room' is.
2026-08-17 17:52:57 -04:00
Gmer4Lfe 4f99efcf2c Varaverk mark behind every conversation, assistant and mesh, and larger
On the positioned wrapper rather than the scroller — a background on the transcript is anchored to its padding box and drifts as it scrolls.
2026-08-17 17:49:23 -04:00
Gmer4Lfe be10e78c7a Swap the banner's checkboxes with the mode, and put the mark behind the mesh transcript
The mesh had its own row inside the pane while the banner kept showing the assistant's three, so switching mode left controls on screen that governed nothing visible.
2026-08-17 17:46:29 -04:00
Gmer4Lfe b6a677a710 Toggle reads Mesh Chat 2026-08-17 17:41:53 -04:00
Gmer4Lfe 53d7784d5d Mesh header mirrors the assistant's three checkboxes; composer controls move below and centre
Fixed pane height inherited from the card, so switching mode no longer resizes it.
2026-08-17 17:19:29 -04:00
Gmer4Lfe 10f2a0a8a4 Mesh chat: reader-side formatting toggle, poll backoff when hidden, Partnership always opens on mesh 2026-08-17 17:12:27 -04:00
Gmer4Lfe eab5cc911c One chat card, two modes: assistant and mesh, with a purple unread badge on the switch
Mesh is a mode rather than an AI profile — it has no model, tokens, reasoning or sources, so making it a profile would branch every profile-aware path on the one that has no model.
2026-08-17 17:03:35 -04:00
Gmer4Lfe d88fe381db Mesh chat: autoscroll toggle, hostname/slot toggle, and a formatting row behind one button
Style validation moves to one shared function so the sender and receiver cannot drift on what a style is.
2026-08-17 16:51:17 -04:00
Gmer4Lfe ecded4a8a7 Mesh Chat card on the Partnership page
Channel picker, unread filter, colour and font selectors, emoji, and the Varaverk mark faded behind it.
2026-08-17 16:43:22 -04:00
Gmer4Lfe 772a3bdce2 Mesh chat store: append-replicated log over the existing SSH trust 2026-08-17 16:41:25 -04:00
Gmer4Lfe fbf6a07da9 Measure the link, not rsync: Data Transferred now reads Tailscale per-peer counters
The old card totalled rsync's own logs, so it reported 'no data moved' across a link that had carried hundreds of gigabytes over SSH, the arr APIs, conf pushes and the Unraid API.
2026-08-17 16:17:11 -04:00
Gmer4Lfe 25e055a7ac Merge the partnership settings into one section, arrays and scalars together
They were two sections drawing the same endpoint, split on whether a value happened to be a list — with two Save buttons for one subject.
2026-08-17 16:11:52 -04:00
Gmer4Lfe e024a29157 Conversation list: a collapsible section per AI profile, then a Last N banner over the recents
Profile order comes from the registry so sections keep their places as threads move, and the recents are capped to the same N rather than repeating everything above them.
2026-08-17 16:06:28 -04:00
Gmer4Lfe 83f3b7e226 Monitor conversation list: collapsible section for the card's own profile above the full list
A flat list buries 'where was I in General Chat' under whatever the tabs have been doing, since the busiest profile takes the top regardless of which card you are looking at.
2026-08-17 15:45:14 -04:00
Gmer4Lfe d3d6f30f72 Load Last checkbox in the chat header, remembered per placement
Defaults on and is read before the resume fetch — an unchecked box has to prevent the load, not undo it once the thread is already on screen.
2026-08-17 15:43:50 -04:00
Gmer4Lfe 99c94ffaec Drop Mirror Sync from the Partnership page — rsync gates have one home, on the Rsync page 2026-08-17 15:41:48 -04:00
Gmer4Lfe 1a836dac75 Drop the failover and shared-services pickers from the Partnership page
They describe what the partner runs during an outage, which is the Fallback tab's subject, not this page's.
2026-08-17 15:30:52 -04:00
Gmer4Lfe 0447fa6f86 Remove point-in-time HOST2 status from the codebase
Comments asserting a partner is down, being rebuilt or not yet installed are true on the day they are written and wrong afterwards, and nothing prompts anyone to revisit them. Behaviour is described generically; current state belongs in operator memory, not in tracked files.
2026-08-17 15:20:25 -04:00
Gmer4Lfe e8ee5b052b Failover Coverage and Shared Services pickers on the Partnership page
Coverage edits FALLBACK_<me>_TIER1-4 directly — the array fallback.sh reads during an outage — rather than a parallel list that could drift from it.
2026-08-17 15:10:32 -04:00
Gmer4Lfe d7980da35e vv_docker_containers lives in common.php, not docker.php 2026-08-17 15:09:15 -04:00
Gmer4Lfe 239e100b44 Partnership page: Varaverk assistant profile, row-2 Actions, and the two container list cards 2026-08-17 15:08:43 -04:00
Gmer4Lfe 2216072669 Drop the Host Settings card now the partnership settings card covers HOST1/HOST2
It existed to render two fields that live in master.conf's HOST IDENTITIES group, which the shared renderer now includes — one settings surface for the page rather than two both editing the same file.
2026-08-17 14:52:21 -04:00
Gmer4Lfe 8cf3ff31db Show the partner's API key and whether it is answering on the remote card 2026-08-17 14:46:19 -04:00
Gmer4Lfe b108a6fe62 Report the partner's API key and whether it is actually answering 2026-08-17 14:45:38 -04:00
Gmer4Lfe f58ff68ab3 Mesh partnership leg reads this host's own state file, not one named after the remote 2026-08-17 14:43:03 -04:00
Gmer4Lfe e7e9d9fecf Green mesh banner on each remote card, naming the failing leg when it is not green 2026-08-17 14:42:15 -04:00
Gmer4Lfe d417bd5360 Match the tailnet device name to the conf hostname by unambiguous prefix
master.conf says unRAID-Jayred36 and the tailnet device is unraid-jayred365, so the exact-key lookup found nothing and every remote card rendered with no IP, no online state and no containers.
2026-08-17 14:41:35 -04:00
Gmer4Lfe 90f3b0b7da Put the assistant on the Partnership tab
A partnership question gets asked while looking at this page; sending someone to another tab to ask it loses the context they are looking at.
2026-08-17 14:38:04 -04:00
Gmer4Lfe bce3bf61e9 Partnership page: one settings card instead of three, and a Data Transferred card
Windows with runs but no bytes say 'no data moved' rather than 0.00 GB, and rows before the byte-parser fix are excluded rather than summed into a false total.
2026-08-17 14:36:21 -04:00
Gmer4Lfe 49372996ac Wire the folder-layout mirroring into onboard Step 12
A fresh install reproduced the single lump folder because Step 12 only ever called fallback_folder.php; it now mirrors the owner's layout first and hands the fallback folder just what is left.
2026-08-17 14:13:17 -04:00
Gmer4Lfe e3ed5a53f2 Reproduce the owner's docker folder layout on the mirror instead of one lump fallback folder
Step 12 filed all thirteen deployed containers under <Owner>-Fallback, which says whose they are and nothing about what they do — and they are not failover coverage, they run there continuously.
2026-08-17 14:08:35 -04:00
Gmer4Lfe 1231cd69a8 A host with the VM service off is not a host with a broken API key
The API answered and simply had no vms node, but that was recorded as an API fallback, and the monitor reads any fallback with a key present as 'Unraid API unreachable — check API key in host conf.'
2026-08-17 13:47:19 -04:00
Gmer4Lfe 8e85a67e7b One faint Skip in the corner; the completion card owns the real exits
The wizard had a Skip beside Create API Key and a second link that renamed itself Go to Scheduler/Partnership, so leaving early and finishing looked like the same action.
2026-08-17 13:45:28 -04:00
Gmer4Lfe 1badb93331 Make the onboard-in-progress state survive a page reload
It was a browser flag set on click, and refreshing is the first thing anyone does when a page looks stuck — which put them back on the phase-1 panel the state exists to replace.
2026-08-17 13:06:45 -04:00
Gmer4Lfe e23358ea0c Tell the mirror that Phase 2 is running instead of leaving it on a greyed button
The mirror's own job ends in seconds while Phase 2 runs for minutes on the owner, so the screen sat unchanged on the phase-1 panel — which reads as a hang, and the reasonable response to a hang is to start clicking.
2026-08-17 12:25:16 -04:00
Gmer4Lfe 73bbae14c1 Stop resolve_remote_scripts_dir silently returning the local path as the partner's
SSH_TIMEOUT comes from the calling script, so a caller without it made timeout fail before ssh ran; with stderr discarded the fallback then named this host's SCRIPTS_DIR as the remote's.
2026-08-17 12:13:51 -04:00
Gmer4Lfe 74e3b8b067 Resolve docker_network_connect.sh on the mirror, and give the wizard poll time to outlast Phase 2
Step 1b ran the owner's SCRIPTS_ROOT path over SSH on the mirror, so it failed on every appdata install; the wizard poll expired at 5 minutes against a Phase 2 that takes over 4.
2026-08-17 12:12:10 -04:00
Gmer4Lfe 9a1254048e Match the WebUI template by its XML Name element, and count the final sync on rsync's actual exit
The lookup grepped for a quoted container name that appears in no Unraid template, so every offboard reported four WebUIs it could not reconfigure. The final sync counted a share synced whenever Tier 1 was on, ignoring rsync.sh's status entirely.
2026-08-17 11:57:46 -04:00
Gmer4Lfe 7ae510ed28 Point the WebGUI symlink at the actual install, and report this host's identity rather than HOST1's
The .plg hardcoded the flash path and runs every boot, so an appdata node served a stale copy that never receives pulls — fixes appeared to do nothing, indefinitely.
2026-08-17 11:52:33 -04:00
Gmer4Lfe 620d7eb6f1 Fix conf_populate.sh sourcing load_config.sh three levels up from Deployment/
It resolved outside the repo on both layouts, so every helper was command-not-found and onboard Step 11 could never have succeeded.
2026-08-17 11:50:57 -04:00
Gmer4Lfe cd21fca749 Give the wizard an ending: big checkmark plus a way out, and refresh it when the onboard lands
With nothing blocking but optional keys unfilled the banner rendered an empty string, so a finished onboard showed a blank panel beside a Join button still greyed at Running.
2026-08-17 11:47:01 -04:00
Gmer4Lfe ead5d38d7c Hide the seed strip on the mirror — it describes a push the mirror cannot make 2026-08-17 11:42:32 -04:00
Gmer4Lfe c4bfa6bdfe Show the mirror it is onboarded, and stop offering it owner-side actions
The phase-2 branch of the mirror panel returned the same Join partnership / Onboard block as the not-started state, so a finished onboard read as one that never ran.
2026-08-17 11:42:10 -04:00
Gmer4Lfe d8a77c700b Stop counting a gated-off critical rsync as an unreachable partner, and report the counter in the unit it is stored in
The guard tested Tier 1 only, so onboard Step 1d's posture — Tier 1 open, every Tier 2 closed — made a healthy partner climb toward auto-offboard every 30 minutes. The counter holds intervals, not days, and was rendered raw.
2026-08-17 11:39:19 -04:00
Gmer4Lfe 5fc53948c6 Treat an ACTIVE partnership as phase 2 regardless of the flags
HOST<n>_PHASE*_DONE only ever describe the mirror, so on the mirror the owner's card computed phase 0 and offered to onboard the host that had just onboarded it.
2026-08-17 11:36:00 -04:00
Gmer4Lfe 587440b6bc Push partnership state to the path the partner reads, under both names its page looks for
The scp reused the local absolute path on the remote, so an appdata-mode mirror never received it, and nothing wrote the mirror's own state file at all — a fully onboarded mirror rendered as having no partnership.
2026-08-17 11:34:54 -04:00
Gmer4Lfe fb49eb20e8 chmod authorized_keys after every rewrite, so revoking a key does not disable the file
grep -v > tmp && mv keeps the temp file's umask mode, so an offboard left authorized_keys 0666 and sshd StrictModes silently refused every key in it — including the one the next onboard installs.
2026-08-17 11:06:56 -04:00
Gmer4Lfe 60e87ba518 Clear the mirror from the blocklist before the provisioning sync, not after it
A previous offboard blocklists the mirror and rsync.sh refuses blocklisted hosts, so on a re-onboard Step 1e was being refused and the auth stack would have deployed against empty directories.
2026-08-17 11:04:36 -04:00
Gmer4Lfe ff94ccb343 Make the setup-state push report whether it worked
It was documented 'Always returns 0', so the offboard's new 'Phase flags pushed' check was testing a constant and ticking regardless.
2026-08-17 10:57:56 -04:00
Gmer4Lfe 42711ff9ac Bring the seed docs in line with Phase 3 2026-08-17 10:24:41 -04:00
Gmer4Lfe 7c957d96be Make seeding Phase 3 — its own operator-triggered phase, not the tail of an onboard
Onboard now ends at Phase 2: connected, running, and saying so. Phase 3 sets the seeding gate posture and dispatches the seed. With MEDIA_SEED_ENABLED false there is no Phase 3 and the model is two phases.
2026-08-17 10:23:03 -04:00
Gmer4Lfe 427a31cdff Sync the auth stack's appdata to the mirror before the containers that read it are created
Deploying first meant Authelia, Lldap, NPM and both databases initialised themselves against empty directories. The gate arming moves to Step 1d for the same reason: the provisioning sync is an rsync, and Tier 1 stops every rsync. Tier 2 now ends the onboard off rather than as-found.
2026-08-17 10:12:10 -04:00
Gmer4Lfe 51125fa05a Fix the offboard calling rsync_stop.sh at a path that has never existed, and stop the summary contradicting its own steps
Step 1 printed 'Rsync stopped' over a file-not-found, and the closing block asserted a clean separation and a synced auth config regardless of what happened.
2026-08-17 10:04:08 -04:00
Gmer4Lfe 24e347bb2d Ship MEDIA_SEED_ENABLED off by default so a fresh node never inherits a multi-week transfer
Onboard arms the other Tier 2 gates but not this one — free space on a just-onboarded partner is the thing nobody has checked yet.
2026-08-17 09:55:40 -04:00
Gmer4Lfe 1a47f864f9 Put the media seed behind MEDIA_SEED_ENABLED so a partner filled by other means never starts a multi-week transfer
Tier 2 beside the per-orchestrator gates. Unset reads as on — the toggle postdates the seed.
2026-08-17 07:46:51 -04:00
Gmer4Lfe ad623353bc Stop the media seed from holding the onboard, and therefore the partnership, open for weeks
A first seed is ~28 TB behind a 12.5 MB/s bwlimit, and it ran inline as Step 9d, so the
phase-2 flag every status reader depends on was written only after it finished.
2026-08-17 07:40:00 -04:00
Gmer4Lfe 9d2610a911 Stop the host card claiming it is waiting for a partner after the onboard was cancelled 2026-08-17 07:23:48 -04:00
Gmer4Lfe bc9692f521 Tighten authorized_keys after installing a key, since sshd ignores a world-writable one and says nothing to the installer 2026-08-17 07:19:46 -04:00
Gmer4Lfe d8e3c09b57 Skip the key step when SSH already works, and read the install path live on both sides 2026-08-17 07:04:46 -04:00
Gmer4Lfe 7e7d845f61 Read the scripts dir live for the paste command, because choosing appdata moves it mid-wizard 2026-08-17 06:51:51 -04:00
Gmer4Lfe b83c118c7f Keep the SSH step visible until the partnership exists, not until the owner says its own phase 1 finished 2026-08-17 06:42:21 -04:00
Gmer4Lfe 069815790b Put the join inside the wizard, send a partner to Partnership when it finishes, and say what is left 2026-08-17 06:16:36 -04:00
Gmer4Lfe 366e2a269f Exclude the Redis AOF and MariaDB binlog from the dirty auth sync, which delivered their index without the file it names 2026-08-17 05:53:37 -04:00
Gmer4Lfe bdc2cdbadb Clear the onboard phase flags on offboard, and let the mirror signal the owner before revoking the key it signals with 2026-08-17 05:44:35 -04:00
Gmer4Lfe fe58fb7826 File the owner's containers on the mirror during onboard, with the icon resolved where the Emby key is 2026-08-17 05:33:52 -04:00
Gmer4Lfe 6dbb076a1e Name the partner fallback folder from master.conf and give it the closest Emby user's avatar 2026-08-17 05:27:25 -04:00
Gmer4Lfe 3676526daf Remove the Docker tab and stop overwriting folder.view3's file, which destroyed folders made in its own UI 2026-08-17 05:11:00 -04:00
Gmer4Lfe 6a15a9d99a Push INACTIVE to the mirror before revoking the key that push needs, and stop two steps reporting success they did not have 2026-08-17 04:58:15 -04:00
Gmer4Lfe a46317de18 Make tailscale removal opt-in, since it depends on a key that expires and no-ops silently once it has 2026-08-17 04:49:32 -04:00
Gmer4Lfe 32355e0219 Record the tailscale grace period as a deadline instead of sleeping six hours inside the offboard 2026-08-17 04:46:01 -04:00
Gmer4Lfe 2cbc06a683 Show a launched job's progress, stop a refused rerun from overwriting the live record, and label deployed containers so Unraid owns them 2026-08-17 04:26:32 -04:00
Gmer4Lfe cce1e25c2b Let a non-blocking checklist item be dismissed with a recorded decision, and run discovery on the mirror once there is something to discover 2026-08-16 21:56:00 -04:00
Gmer4Lfe 39dba3f8ab Say that the rsync count file is a cache the live-PID scan overwrites, so a stale value stops reading as a leak 2026-08-16 21:49:58 -04:00
Gmer4Lfe 5bdf3bff60 Report each teardown and setup step from what it did, not from whether it was attempted 2026-08-16 21:46:26 -04:00
Gmer4Lfe 65516ea3ac Report the Tailscale device as removed only when it was, instead of whenever the toggle was on 2026-08-16 21:38:03 -04:00
Gmer4Lfe 27989d066e Disarm on offboard every sync gate onboard arms, with the same helper, so the two are one operation in both directions 2026-08-16 21:36:46 -04:00
Gmer4Lfe 3ae6298656 Let a partner adopt the owner's custom networks from the conf phase 1 already cached, instead of asking for a value it cannot know 2026-08-16 21:20:59 -04:00
Gmer4Lfe 662f4f0d53 Create the network and cache partner confs in phase 1, where SSH already works and no plugin is needed on the far side 2026-08-16 21:13:46 -04:00
Gmer4Lfe a02b918ff9 Make the cancel actually remove the key it says it removed, instead of erroring into a swallowed stderr 2026-08-16 21:03:54 -04:00
Gmer4Lfe 23825a824d Create the networks our own pushed templates name, instead of trusting a fresh mirror to have uncommented them 2026-08-16 20:50:10 -04:00
Gmer4Lfe cf67393db6 Read varaverk.cfg the way bash and the PHP side both read it, so a comment on the SCRIPTS_DIR line cannot disable cron suppression 2026-08-16 20:40:23 -04:00
Gmer4Lfe 7ed0f5c23b Send the webhook setup to the path the partner actually uses, and arm the sync gates before the seed that depends on them 2026-08-16 20:32:14 -04:00
Gmer4Lfe f363c6cd55 Match the trigger failure patterns before the success one, since the failures echo a path back 2026-08-16 20:26:46 -04:00
Gmer4Lfe c8a6f58007 Launch owner-side Phase 2 through run_job.sh so it leaves a job record, and only claim it started once one exists 2026-08-16 20:25:54 -04:00
Gmer4Lfe 93cdbaac57 Give the mirror the terminal step its half of phase 1 actually needs, instead of a button that cannot answer a password prompt 2026-08-16 20:16:17 -04:00
Gmer4Lfe 47ad3b3075 Push the API key to the conf path the partner actually uses, and say so when there is no conf there 2026-08-16 19:59:15 -04:00
Gmer4Lfe 1493cf2dcf Push our conf to partners on the 4-hour cycle too, since the conf-save hook the push half was written for never existed 2026-08-16 19:47:19 -04:00
Gmer4Lfe 35e59d2510 Let the plugin read partner vars from the same RAM cache bash reads, so PHP and bash cannot disagree about a partner 2026-08-16 19:40:49 -04:00
Gmer4Lfe 4f95cc6d13 Deliver master.conf to a node that has no Varaverk on it yet, which is the node that needs it
Phase 1 exists to hand the partner its identity before the install, but the push refused any
host without varaverk.cfg — so the conf could only ever reach a node that no longer needed it
to be told who it was. It now resolves the remote's conf directory across both install layouts
and creates the internal one when neither exists.
2026-08-16 16:14:23 -04:00
Gmer4Lfe f627658567 Read the partner's identity out of master.conf instead of asking for it again 2026-08-16 13:56:09 -04:00
Gmer4Lfe 3c24550b19 Derive the pull target from the install location, so master.conf is safe to share between hosts 2026-08-16 13:54:05 -04:00
Gmer4Lfe 98fab394a1 Resolve the partner through the shared Tailscale resolver, so a shared-tailnet peer is findable 2026-08-16 13:43:02 -04:00
Gmer4Lfe 6fbc2b4125 Stop a missing SSH key making every Tailscale peer look like the Gitea host, and say so when the scripts are absent 2026-08-16 13:31:24 -04:00
Gmer4Lfe 64de77c065 Let the setup checklist create the SSH key it is asking for, instead of linking to a guide 2026-08-16 13:24:26 -04:00
Gmer4Lfe 19ba1e723c Stop a paused rsync counting as an absent partner and auto-offboarding the partnership 2026-08-16 13:13:25 -04:00
Gmer4Lfe 1d4dc44668 Name HOST2 by the hostname the key derivation actually uses, not its Tailscale spelling 2026-08-16 13:07:10 -04:00
Gmer4Lfe 0fe3435033 Let the conf push seed a node that has no master.conf yet, which is the node it exists to seed 2026-08-16 13:05:04 -04:00
Gmer4Lfe 6d9c856514 Open the sync gates when a partnership actually completes, instead of leaving them shut for ever 2026-08-16 12:51:24 -04:00
Gmer4Lfe 9634838f64 Ship a freshly seeded node inert, so it cannot sync before anyone has looked at it 2026-08-16 12:39:56 -04:00
Gmer4Lfe 28273ffc2a Retire domains nothing has probed in 90 days, and notice NPM changes within five minutes 2026-08-16 12:12:08 -04:00
Gmer4Lfe 7b65b64864 Let the uptime history card be folded away, and remember that per browser 2026-08-16 12:06:52 -04:00
Gmer4Lfe b90fd59544 Separate the four uptime periods so they read as four graphs, not one 2026-08-16 12:02:08 -04:00
Gmer4Lfe d5ee2d7ef1 Tell a decommissioned domain apart from a broken one in the uptime history 2026-08-16 11:59:41 -04:00
Gmer4Lfe 73358e6bae Stop Unraid's span.warn stretching the watchdog and queue badges across their row 2026-08-16 11:55:25 -04:00
Gmer4Lfe f2ad41e8e2 Show how each domain has behaved over a day, a week, a month and a year, not just now 2026-08-16 11:55:25 -04:00
Gmer4Lfe 187dc188c2 Namespace the proxy table's fixed-layout class so Unraid Connect's Tailwind layer cannot claim it 2026-08-16 11:27:59 -04:00
Gmer4Lfe ea2d3cf597 Size the proxy columns by proportion, so nothing can outgrow the page 2026-08-16 02:14:38 -04:00
Gmer4Lfe bd30c4e05a Stop the proxy table widening its own column instead of scrolling inside it 2026-08-16 02:10:22 -04:00
Gmer4Lfe 51283a626c Match the Tools mode convention — wrappers execute, php files do not 2026-08-16 01:59:58 -04:00
Gmer4Lfe 01601d210b Watch the auth stack on a schedule, so nobody has to open the tab
Both checks already answered their question on demand and both needed
somebody to press a button on the right row. One host here has returned
nothing but 5xx for months.

Filed as findings, which is the existing answer to a condition that
persists while nobody is looking. Grouped by cause rather than by
hostname: a default policy of bypass produced twenty-two findings that
were one sentence repeated, and they have one fix between them.
2026-08-16 01:59:11 -04:00
Gmer4Lfe 99b58c0c4f Make the Auth tab explain a number instead of only showing it
A low uptime figure, a refused login and a certificate that stopped
renewing all looked the same from the row: a number, with the reason
split across NPM, an Authelia config and the directory.

The why-check goes and looks — TCP to the forward target, HTTP through
the proxy, a second handshake with verification off to tell a broken
certificate from a broken service. Forward hosts are docker names that
only resolve on NPM's network, so an unresolvable one is redirected to
the container address and the substitution is reported; a check that
could not be made must never read as a check that failed.

The access simulator walks the rules the way Authelia does and shows the
ones it stepped over, reading whichever instance the chosen host points
at rather than the one conf names — there are two here.

Cert triage counts runs rather than log lines and orders by rotation
suffix rather than mtime, both of which change the answer.
2026-08-16 01:59:01 -04:00
Gmer4Lfe 1001c25487 Probe every domain every minute, and keep it out of its own traffic numbers
NPM's access log only describes hosts somebody visited; the host most likely to be quietly broken
is the one nobody does. Probes carry a User-Agent npm_access_stats.sh drops — unmarked, this
monitor would be fifty thousand requests a day in the very logs it reports on.
2026-08-15 20:28:30 -04:00
Gmer4Lfe c0ace5a0ca Split requests and errors into their own columns, and label every figure
A host serving thirty-five thousand requests that are all failures read as a busy host when the
two counts sat side by side in one run of numbers.
2026-08-15 19:25:56 -04:00
Gmer4Lfe 1041c4bba8 Show what each proxy host is actually doing, and mark the ones behind Authelia
NPM writes an access log per host and counts nothing, so 475 MB of logs held the only answer to
"is anything using this". Aggregated on a schedule and read from a few kB of JSON; the row also
now says whether an auth_request block is in front of the site, which nothing showed before.
2026-08-15 19:20:11 -04:00
Gmer4Lfe 3db594af86 Stop the proxy dialog wiping Custom Nginx Configuration, and let it be edited
Saving a host sent advanced_config as an empty string and reset http2, both HSTS flags, meta,
locations and enabled — so changing a port removed the auth_request block that puts Authelia in
front of the site. Twenty-five of the thirty-five hosts here carry one.
2026-08-15 19:15:54 -04:00
Gmer4Lfe 71ba0a239f Track what happens to every certificate, and show it on the Certs tab
NPM knows what a certificate is today and nothing about what it was, so ten of them could fail
renewal for months — 1001 certbot runs, zero successes — without anything on any page saying so.
Counts start at zero and are only ever observed; only first_seen is seeded, from NPM's own date.
2026-08-15 18:51:38 -04:00
Gmer4Lfe 13de1dab82 Stop a scalar NPM response killing the request, and add certificate deletion
NPM answers a successful DELETE with the bare literal true, which vv_npm_raw() is typed to
return an array from — so deleting or toggling a proxy host has always thrown a TypeError
rather than doing the thing.
2026-08-15 15:51:29 -04:00
Gmer4Lfe 8157673291 Add AUTH_STACK so the Auth tab follows the stack in force
Authentik is the likely destination and the page had Authelia and lldap wired in at every
level, so the seam goes in now: the panels and every endpoint action route off one conf value,
and a stack that cannot be driven yet says so rather than drawing controls with nothing behind.
2026-08-15 15:27:50 -04:00
Gmer4Lfe 2697b46616 Put groups, password and photo inside the user editor
Opening the editor to fix a name and then having to close it to reach a password or a group
was three dialogs for one sitting; the row buttons stay for one-click access from the list.
2026-08-15 11:54:03 -04:00
Gmer4Lfe 81aeb0619b Reach the rest of what lldap exposes — photos, real names, group rename
lldap lets you edit five things about a user and one about a group; the page reached two of
them, so correcting a surname or a group's name still meant opening the container's own WebUI.
2026-08-15 11:45:23 -04:00
Gmer4Lfe 693ac693a4 Make access-control domains an editable list, and stop saves eating the rule labels
The block is rebuilt from the parsed model on every save and the parser discarded comments,
so one press of Save deleted the five ## lines that are the only thing in the file saying
what each rule is for — which inline editing was about to make far easier to press.
2026-08-15 11:30:36 -04:00
Gmer4Lfe b22d034933 Give each access-control rule its own card, and stop the scalar forms throwing
Most rules carry thirteen or fourteen domains, which in one table cell was a four-hundred
character run that answered no question at a glance; the group is what you are looking for
and it is now the heading.
2026-08-15 00:04:43 -04:00
Gmer4Lfe bb7c112a2d Construct the Auth tab's assistant — the markup was emitted but never wired 2026-08-14 23:53:17 -04:00
Gmer4Lfe 0101aef51a Let a password containing a $ actually save, and say why when a save is refused
The value was written as typed, bash expanded it when the read-back sourced the file, and
the guard rolled the whole write back with nothing on screen but "save failed" — which is
also what an empty value, a trailing space, and a stale API-key check had been doing.
2026-08-14 23:39:12 -04:00
Gmer4Lfe 9eef5b50e6 Tell the three auth failures apart, and give the Auth tab a settings card
Not set, rejected and unreachable all reached the page as one message about checking
credentials, which sends you to a password when the field is simply empty — as both of
HOST1's were, with no card on the page to fill them in from.
2026-08-14 23:14:33 -04:00
Gmer4Lfe 2bda4cdaf8 Declare the AI owner, and let every node borrow its model over the mesh
vv_is_ai_host() was `=== 'host1'`, which made a physical fact — that is where the
GPU is — look like a rule. AI_OWNER_HOST declares it, so the card can move to a
rebuilt host3 or a friend's spare.

The gate was also doing two jobs. Assistant docks and findings strips now ask
whether a model is reachable, so a node without a GPU gets them by borrowing;
the AI tab asks whether this is the owner, because that page carries the bug
reports, the index and the model configuration — the surface where the
vocabulary assumes you built the mesh.

Resolution is local, then owner, then anyone else declaring a model, pinned once
it answers. Pinned rather than re-derived per call: a mesh that re-decides every
request eventually decides differently mid-conversation, and a chat whose second
turn lands on another machine has no history there. Cleared only on a transport
failure, and only when there is somewhere else to go — a single-node mesh whose
model is down should say so, not report AI as unconfigured.
2026-08-14 22:54:26 -04:00
Gmer4Lfe b708a90548 Attribute findings to the host they are about and the host that answered
Identity now includes the host: "Bazarr is on the skip list" is a different fact
on each machine that says it, and without the host in the hash the second node
to report a subject would land on the first node's record — one machine's
dismissal silencing another's live fault.

Nine dismissed findings existed when this changed, so the write path migrates a
legacy id once, at the only moment it matters. Verified: a dismissal survives the
rename rather than resurfacing as new.

acted_by is stamped in apply_action, not set_state — the sweep calls set_state to
move a candidate to needs_operator, and crediting a machine for classifying is
not the same as recording who decided.
2026-08-14 22:39:38 -04:00
Gmer4Lfe 90774f1442 Put the AI toggles and findings on the pages they act on
The five AI_ASSIST_ switches and the findings they produce were reachable only
from the AI tab, which is a long way from the page a finding is about.

Nothing was synchronised because nothing needed to be: findings are one file per
finding, and every surface is a view over that store with actions going to the
same endpoint. Acting on the Watchdog tab shows on the AI tab because they are
not two copies. The strip reloads rather than editing its own row, which is the
only way they could have drifted apart.

Each strip shows one page's kinds. Actions are whatever the server offers for
that row, so Move appears on media findings without this card knowing what a
move is.
2026-08-14 22:32:55 -04:00
Gmer4Lfe a705aa36b7 List each AI enhancement against the script it enhances
Declared in PHP rather than in each bash header, against scriptinfo's usual rule
that the header next to the code is authoritative: it is authoritative about what
the script does, and an enhancement is something else reading its output, gated
by a flag the script has never heard of. A description of PHP inside a file that
cannot enforce it would drift the first time either changed.

Shown with its switch, so "there is an enhancement" and "it is running" are never
the same claim. Discovery is listed even though nothing acts on its output — it
takes the first accessible root folder with no regard for content, and the
classification scan is what notices that night. That relationship explains where
misfiled series come from and was written down nowhere.
2026-08-14 22:25:24 -04:00
Gmer4Lfe 60971f1af4 Let a misfiled series be moved from the finding that reported it
The triage could say Narcos is filed under anime and offer nothing to do about
it. Findings that recorded a destination now carry a Move action, which the AI
tab renders as a button on its own — actions are read from the server, so no UI
knew this existed. Armed like Fix and Dismiss, because it is the only action in
that card that relocates files rather than settings and the undo is another move.
Offered only where the triage said misfiled, never for uncertain: three
narrowings before a file is touched. Re-reads the series from Sonarr first, so a
finding gone stale cannot undo a move made by hand in the meantime.
2026-08-14 22:13:25 -04:00
Gmer4Lfe 00dee37e22 Triage the classification leaks the metadata rule says it cannot judge
The scan's own header calls reverse-anime leaks "genuine judgment calls" and
reports them as a count. Seventeen of them concealed two live-action crime
dramas filed under anime for as long as nobody read the list. The scan now
persists that bucket as its own verdict, and the sweep asks the model to sort
only those eleven — not the eleven hundred it already classifies correctly.
Files findings for misfiled and uncertain only; donghua and anime-adjacent are
answers, not problems, and filing them would rebuild the undifferentiated list
this exists to break up. Moves nothing — --move stays a flag a human types.
2026-08-14 22:03:55 -04:00
Gmer4Lfe 2c16cc7116 Fit all six sync windows on one row
The grid said repeat(5,1fr) against six windows, so Fallback sat alone on a
second row. The count now travels with the cards as --vv-win-n, so adding a
window widens the row instead of quietly starting another one. Grid rather than
flex keeps every card exactly the same width, and it steps 6→3→2 rather than
dropping straight to two — the middle step is the one a 15" panel lands on.
2026-08-14 21:51:59 -04:00
Gmer4Lfe 5ad5c0df7e Rename the Arrs tab to Media Stack, now that it is one
It covers the pipeline rather than only acquisition: the arrs, the jobs that run
over the same files, and the servers that play them. The label moves; the tab id,
file and URL do not, so bookmarks and the cache key keep working.

The settings card widens with it — Play State Sync, Media Permissions, Media
Cleaner, Emby and Jellyfin were reachable only through the Settings catch-all,
which is a long way to go for a setting named after the page you are on. Match
is whole-word, so "Intermediate Sync" is not caught by "media"; section count is
unchanged at 126 with the same 5 gaps, so nothing was taken from another tab.
2026-08-14 21:47:33 -04:00
Gmer4Lfe 00328cbd0a Report network throughput in bits, which is what everything downstream says
/proc/net/dev counts octets; the field is named rx_bps, vvFmtBps renders Kb/s
and Mb/s, and the Monitor card prints it directly under the NIC link speed from
/sys/class/net/*/speed, which genuinely is megabits. So the one figure you would
read against the link understated traffic eightfold — 811 KB/s shown as "0.8
Mb/s" beside a 10 Gb/s link when it was 6.5. Converted at the source so the
field name stops lying; both consumers are bit-labelled or relative.
2026-08-14 21:36:30 -04:00
Gmer4Lfe 7f22bb0612 Run the PHP layer on the host's clock, not UTC
PHP defaults to UTC on Unraid while every bash script stamps local time, and the
two write into the same files — conf_changes.log was four hours out from every
log you would correlate it against. The parsing half was worse: vv_ai_syslog_ts()
reads local-time syslog lines through strtotime() under UTC, landing every event
four hours early, and the repair sweep bounds its scan to "since the last pass"
— so a fault that had just happened could read as four hours old and fall
outside the window. Also fixes date-string comparisons against bandwidth and
cleanup dbs, which bash writes with local dates.
2026-08-14 19:49:48 -04:00
Gmer4Lfe 7ebe8c0891 Judge load on the Watchdog page against the same count the watchdog uses
The page multiplies its core count by the RW_LOAD_* multipliers to colour the
load bar, and resource_watchdog.sh computes its real thresholds from nproc. The
page preferred the API's physical core count — 16 against 32 threads — so it
turned amber at load 32 and red at 48 while the watchdog did not reach soft
pressure until 64 or medium until 96. A page whose whole job is reporting what
the watchdogs think was reporting a crisis about a machine they considered idle.
The remote path already sent the partner's own nproc, so both nodes now agree.
2026-08-14 19:42:14 -04:00
Gmer4Lfe dbc0c8c6d2 Report container CPU as a share of the machine, matching Unraid's Docker page
docker stats sums CPU across every logical processor, so Jellyfin's "227%" is
2.3 threads busy — 7% of a 32-thread host, which is what Unraid shows and what
the operator was reading. The card was printing the raw figure and colouring it
against thresholds meant for a percentage, so ordinary background work looked
like an emergency; I twice flagged Jellyfin as pegged on that basis and was
wrong both times. Divides by nproc, not the watchdog's `cores` — that field is
the physical 16 because it compares load averages, and borrowing it here would
have looked reasonable and doubled every reading.
2026-08-14 19:39:14 -04:00
Gmer4Lfe 04a0dcb39a Let the media server card show the whole mesh, not just this host
A partner's Emby URL is http://localhost:8096 — true there, meaningless here —
so nobody queries a partner's media server directly. Each host reports its own
over the SSH call remote_arr_cache_writer.sh already makes, which keeps its API
keys on it and costs no extra connection. Local is the default: opening the tab
to check your own server should not first make you read past a partner's.
A partner with no cache is stated as not collected rather than omitted, because
an empty mesh view and a partner that is down look identical otherwise.
2026-08-14 19:30:23 -04:00
Gmer4Lfe 24710af156 Add media server cards, and stop re-counting whole libraries every minute
The counts need the entire library downloaded to compute six numbers — 16.0 MB
from Radarr and 5.5 MB from Sonarr, measured — and they were on the same
one-minute clock as queue depth and health, which pulled ~30 GB a day out of the
arrs to re-count records that had not changed. They now cache for fifteen
minutes against how often the numbers actually move: 7.6s to 3.0s per refresh.

Emby and Jellyfin get a card each: version, CPU, memory, uptime, streams, users
and transcodes, with update/restart flags. Two halves of one question — a server
answering happily at 145% CPU is a different situation from one at 8%, and
neither the app nor the container says so alone.
2026-08-14 19:23:10 -04:00
Gmer4Lfe e149046272 Define _dur on the arrs page — it was calling a function that does not exist
The media jobs row I just added formats durations with _dur(), which the rsync
page defines locally and js/varaverk.js does not export at all. The reference
threw inside _render(), the fetch's catch reported it as "Error loading arrs
status", and the whole page went blank — nothing to do with the API, which was
returning valid JSON throughout.
2026-08-14 19:02:31 -04:00
Gmer4Lfe 1381a526ab Surface the three media jobs that appeared in no tab at all
play_state_sync, media_shares_permissions and media_cleaner work on the same
files the arrs manage and were visible only by opening the Scheduler and reading
an orchestrator's log. Readable at all because run_orch_child() now writes a run
record and a per-script log for its children — this could not have been written
yesterday. Each card shows the sentence the script itself ended on rather than a
count re-derived here, since the three word their outcome differently and the
wording is the part worth reading.
2026-08-14 18:51:54 -04:00
Gmer4Lfe fa7af04418 Give each orchestrator child its own log as well as the parent's
Everything a child printed was interleaved into the parent's log with forty
other scripts, separated only by human headings that map to no script id — so
scoped_log could answer for ten orchestrators and nothing else. tee rather than
a redirect, so the parent keeps receiving exactly what it did before and this
only adds a destination; PIPESTATUS[0] preserves the child's exit code through
the pipe. Trimmed to 1000 lines like run_job.sh does, because LOG_DIR is tmpfs
and an untrimmed chatty script spends RAM. Falls back to the original unpiped
call when the log cannot be opened — a child must never be denied a run because
somewhere to write about it could not be created.
2026-08-14 18:35:42 -04:00
Gmer4Lfe 95050850d6 Write a run record for scripts an orchestrator runs, not just the ones cron does
run_job.sh records the ten jobs cron starts; the seventy entries inside the
*_SCRIPTS lists had none, and nothing said so. Consumers quietly got less — arr
cleanup stats fell through to a daily aggregate with no end time, and
troubleshoot answered "I cannot see a run record" for every script that is not
an orchestrator, which is nearly all of them and exactly the ones a question is
about. run_orch_child() already had the start time, exit code and severity
ladder; it just never wrote them down. Not run_job.sh itself: that also locks
and interval-gates, which would let a child skip itself or block on its parent.
2026-08-14 18:29:47 -04:00
Gmer4Lfe 04aba81ab3 Show orphan and junk sizes, and drop a cleanup field nothing could fill
The arr cleanups are entries in DAILY_MAINTENANCE_SCRIPTS, which the orchestrator
invokes with plain bash, so they never get the run record the primary parse needs
— every payload has come from the daily aggregate db. That path read every column
except the byte counts, leaving orphans_sz at its "0B" default, which is
invisible at zero orphans and would have read "12 orphans (0B)" the first time
there were any. 'total' had no source there and no reader anywhere, so it is gone.
Arr Sync now reads "disabled" rather than "never run" when its switch is off.
2026-08-14 18:15:56 -04:00
Gmer4Lfe a11e3d73a7 Escape what the arrs report about themselves, and give the tab an assistant
Health messages go straight from each arr's API into innerHTML and quote things
nobody here controls — indexer names from Prowlarr, import-list names, release
titles. The page used no escaping at all. _sz() was also binary while labelling
its output TB, so /tv read 86.9 TB here and 95.6 TB on every other tab; it has
one caller and that caller is disk capacity, so it now matches the rest.
2026-08-14 17:51:04 -04:00
Gmer4Lfe 793a75b8a2 Remember the last ten manual syncs so a repeat is one click
Retyping two paths and nine checkboxes correctly every time is where the
mistakes come from, and "one-off" described how a transfer is scheduled rather
than how often it is run. Pinned entries are exempt from the rotation — the
command used twice a year is both the most worth keeping and the first that ten
ordinary runs evict. Stored server-side, so the list is there from any screen,
and loading one fills the form and stops rather than running it.
2026-08-14 17:38:48 -04:00
Gmer4Lfe 976fdf6e50 Read the byte count rsync actually prints, not the word "bytes"
--stats prints "Total transferred file size: 1,234,567 bytes", so $NF is the
unit word; stripping non-digits from it left an empty string and ""+0 is 0.
Every transfer ever logged recorded zero. Nothing looked broken — the history
filled with 1,423 correct rows all reading 0, the graph drew a flat line, and
DIGEST_SMART_ON_BANDWIDTH could never fire because 0 is never above 50GB.
Both parse sites now share one program, since fixing either alone would have
left the other silently wrong.
2026-08-14 17:29:30 -04:00
Gmer4Lfe a71723e51f Show the manual sync command it will actually run, and guard --delete
The preview ended in the literal text "…src host:dst", so the one thing worth
checking before pressing Run — the real source and destination, and which of
them has the trailing slash — was the one thing never shown. --delete sat
unmarked among nine checkboxes with no confirmation on a card that can remove
data on the far host. Confirm fires only for --delete without --dry-run, so it
stays worth reading.
2026-08-14 17:24:15 -04:00
Gmer4Lfe 7a3e8dd378 Say when rsync last moved data, and stop colouring successful runs red
last_sync reads the maintenance orchestrators' run records, and those do git
pull, permissions, cleaners and docker updates on schedule whether or not the
global gate is open — so the page reported a healthy 56-minute "daily sync"
while nothing had transferred since 16 July. Both cards also compared status
against 'success' while being fed 'ok', so every clean run rendered in the
failure colour. Adds the assistant, scoped to the tab.
2026-08-14 16:50:28 -04:00
Gmer4Lfe 2cd1384786 Add timed mutes so a temporary problem stops needing a permanent exemption
Every existing exemption lasts until someone remembers to undo it, and nobody
does — Healarr has sat in a pressure list since it was uninstalled and seven
ignore entries name containers that are gone. A mute states when it ends and
then ends, capped by WATCHDOG_MUTE_MAX_HOURS so temporary is enforced rather
than intended. Applied where IGNORE_MAP is built, so all five check sites
inherit it, and shown with its countdown because an invisible suppression is
the thing being fixed.
2026-08-14 16:28:59 -04:00
Gmer4Lfe 9f32644c32 Show when a watchdog cycle last completed
Without it the page renders zeros, all-clear pills and green dots when the
orchestrator has stopped entirely — its healthiest appearance describing a
machine with no watchdogs, against a header promising absence is never drawn as
an all-clear. The heartbeat already existed; overdue is measured against the
cron schedule rather than an assumed fifteen minutes.
2026-08-14 16:18:14 -04:00
Gmer4Lfe a7387c27fb Mark watchdog list entries that name a container which no longer exists
Half the hard-pressure stop list is containers that are not installed, so at
level 3 it sheds half the memory the card implies. Badged on the lists where a
dead entry costs something and deliberately not on Scan ignore, where ignoring
something already gone is the point — a card of dashed pills by design would
teach the eye to skip the badge where it has to be believed.
2026-08-14 16:06:02 -04:00
Gmer4Lfe 2f8b7276a4 Give the watchdog counters a voice in the repair sweep
A fourth candidate source beside the arrs, the system log and container logs:
skip-listed containers, strikes past their limit, repeated restarts, unattended
reboots and sustained pressure become findings. The kind is deliberately not
conf-bound, so it cannot autofix by construction rather than by a switch — and
WATCHDOG_SCAN_IGNORE suppresses it, so a knowingly broken container stays quiet.
First consumer AI_ASSIST_WATCHDOG has ever had.
2026-08-14 12:01:18 -04:00
Gmer4Lfe cbcfbfaaad Stop bash arrays ending at the first ) inside a comment
Both the reader and the writer ran to the first closing paren, which is only the
array's own close if nothing inside contains one. HOST1_WATCHDOG_SCAN_IGNORE has
carried "(exit 127 — bad image)" for weeks: writes spliced into the middle of it
and were refused by bash -n with a bare false, and reads returned the entries
above it — the Rsync tab showed no intermediate scripts at all and 10 of 17
daily. Arrays now close on a ) that starts its own line, as confform already did.
2026-08-14 11:35:18 -04:00
Gmer4Lfe a2debe972f Put Scan ignore on the Watchdog tab, first of the list cards
It is the setting people arrive looking for — the one that takes a container out
of the watchdog's hands rather than tuning what it does with it. Its hint says
so, because it silences a real fault as readily as a noisy one.
2026-08-14 10:59:14 -04:00
Gmer4Lfe 57dad6d706 Edit the watchdog container lists as rows instead of raw bash array bodies
Tier 1 limits, both pressure lists and Required get add, remove and adjust on
the page that shows what they did. Per-entry comments are carried through the
rebuild rather than regenerated — they hold the reason for every number.
2026-08-14 10:53:40 -04:00
Gmer4Lfe 1f4b751fbb Cache the watchdog payload, and stop reading trailing comments as conf values
The tab collected over SSH on every 30s poll — 8.3s a load with HOST2 down.
vv_parse_conf_scalar() captured to end of line, so a commented toggle parsed as
"true   # HOST2 back online": every threshold read right because (int) stops at
the first non-digit, and 38 booleans read wrong. The Fallback tab has been
showing failover disabled while it was on.
2026-08-14 10:45:17 -04:00
Gmer4Lfe 8808cbfc04 Give the Watchdog tab an assistant scoped to it, and Why? on every strike
The page states what struck but never why, and the log that would say is the
orchestrator's, not one per watchdog. Why? opens troubleshoot against it —
read-only, because the page's contract is that it changes nothing.
2026-08-14 10:09:04 -04:00
Gmer4Lfe c6b56d580e Size the Scheduler panel from the height the dock reports, not one read mid-animation
.vv-ai-chat transitions its height, so measuring the dock straight after setting
it returns the height it is animating away from — the view was cut short for a
dock that had already shrunk, leaving the difference as dead panel underneath.
2026-08-14 09:55:41 -04:00
Gmer4Lfe b358dfcf58 Report disk capacity in decimal GB so it matches Unraid and the drive label
The API mixes units per field — memory in bytes, disk size in KiB, fsSize and
fsUsed in kB — and one helper was guessing which from the magnitude of the
number, which also read any memory total under 100 GB as KB.
2026-08-13 18:07:09 -04:00
Gmer4Lfe 57e2b0d670 Let troubleshoot see the machine and answer with a route
It could describe a conf edit but not the switch that makes it, and knew
nothing about containers, pools or the array it was reasoning about.
2026-08-13 17:22:58 -04:00
Gmer4Lfe b7df54e4f3 Resume the dashboard chat from its own profile
It reopened whatever was touched last anywhere, so the card could land in a
Scheduler troubleshooting thread with no picker to get back.
2026-08-13 17:03:45 -04:00
Gmer4Lfe 2ccc93bb14 Send a bug report to one place, on purpose, after reading it
The page built its own markdown and nothing could send it; local and upstream
are different people, so neither falls back to the other.
2026-08-13 16:32:36 -04:00
Gmer4Lfe 2c1fd4c8bf Keep the assistant's reading apart from the log it read
A guess printed beside a quote is remembered as a second quote, and the next
reader inherits it as a finding.
2026-08-13 16:08:23 -04:00
Gmer4Lfe 466081aa66 Redact a bug before it is stored, and give it a report shape
Evidence was kept raw, so a key in a log line reached the page and the store;
and there was no format to send even once it was clean.
2026-08-13 15:58:29 -04:00
Gmer4Lfe be6bb3ffc5 Let a kept memory be taken back
Accepting put a line in every future prompt with no way out but editing the file
by hand, and a fact that is true today stops being true.
2026-08-13 15:13:32 -04:00
Gmer4Lfe 17216a354d Ask what an error means before deciding it is dealt with
The row could open the log or acknowledge it, both of which assume you already
know what the line is telling you.
2026-08-12 20:26:49 -04:00
Gmer4Lfe d1deac1bbb Scroll the tab bar instead of the whole page
Reaching the last tab on a phone meant dragging the entire layout sideways,
every card with it.
2026-08-12 20:11:42 -04:00
Gmer4Lfe 44275a8a04 Draw every card on the same surface
Four pages had already agreed on #161616 under four class names while the shared
card was still the older #1e1e1e, so the plugin had two card looks.
2026-08-12 20:07:00 -04:00
Gmer4Lfe e64d26af6c Put each page's settings on that page
A watchdog threshold was reachable only from the bottom of a list of a hundred
and twenty on another tab.
2026-08-12 19:55:51 -04:00
Gmer4Lfe 8ca52c64b9 Give the rest of the conf a place to be changed
A third of the settings had no control anywhere, so the answer to "where do I
change this" was to open a file over SSH.
2026-08-12 19:41:01 -04:00
Gmer4Lfe ec3084db12 Answer "what is Varaverk" with Varaverk's own README
The PURPOSE intent cannot tell the project from a script inside it, so asking
about the whole promoted every component's summary at once.
2026-08-12 19:23:44 -04:00
Gmer4Lfe faa4a06c42 Stop a banner rule from being read as a section heading
Three # rules open most docs here, so the title became a section too small to
survive and the paragraph defining the project got a chunk with no heading.
2026-08-12 19:11:17 -04:00
Gmer4Lfe 72d0e876c1 Give the settings inputs one set of colours
Nine near-identical fields had drifted a hex digit apart in three directions,
which is invisible side by side and confusing to read in source.
2026-08-12 19:07:57 -04:00
Gmer4Lfe a04368e9fc Notice when the question is about their own machine
The other detector recognises named components, so its complement is unbounded;
this one reads grammar instead, which is not.
2026-08-12 18:08:50 -04:00
Gmer4Lfe a18f0cfc84 Do not let the web note decide what the question was about
The detector answers no to "why is my array so slow", so claiming that meant a
question about the wider world would have answered it from general web pages.
2026-08-12 18:03:54 -04:00
Gmer4Lfe e1110b20df Put the counter-instruction where the refusal cannot outshout it
A paragraph beside the rule did not shift it; the ready-made refusal was simply
the nearest thing to hand, so the correction goes next to the question instead.
2026-08-12 17:53:07 -04:00
Gmer4Lfe 5b2ab93e60 Stop refusing questions about the world while holding the answer
Chat was told it could not look anything up, which is true of this machine and
false the moment a search succeeds, so it deflected while holding six sources.
2026-08-12 17:49:45 -04:00
Gmer4Lfe 533328aa91 Remember whether it may look things up
The reasoning checkbox beside it was remembered and this one was not, so ticking
it, closing the panel and coming back read as a save that had failed.
2026-08-12 17:42:31 -04:00
Gmer4Lfe 6eb84c195a Keep the memory instruction out of the answer it was attached to
Told there was nothing worth remembering, the model wrote a sentence saying so,
which is a footnote about a mechanism the operator cannot see.
2026-08-12 17:36:46 -04:00
Gmer4Lfe f8c4c93f20 Let it look things up rather than putting it in search mode
Results announced themselves as the source, so a question it already knew got
answered by paraphrasing the first hit.
2026-08-12 17:32:41 -04:00
Gmer4Lfe e2b304daad Stop proposing to remember what the UI map already says
The map documents 447 settings, so without this every question about where a
control lives earns a proposal that costs a dismissal and teaches nothing.
2026-08-12 17:26:26 -04:00
Gmer4Lfe c33f3c067b Lead with the route, since that is what was asked
Naming the conf file first got answers that sent people to edit host1.conf and
mentioned the tab afterwards.
2026-08-12 17:19:07 -04:00
Gmer4Lfe 20c17648e6 Require the registries this renderer's callers read
The AI tab included this without confform.php and referenced a constant from
it, which is a fatal, which is a blank page.
2026-08-12 17:09:03 -04:00
Gmer4Lfe 240876e710 Tell the assistant where the settings are, not just what they are called
The index reads tracked files and page markup is not one, so it could name a
conf key and never say there was a button for it.
2026-08-11 22:07:10 -04:00
Gmer4Lfe 999dbcfdf3 Give the pages one switch and one prefix each
Three pages had reimplemented the same sliding toggle, and two both claimed
vv-set-* with disjoint class names, which reads as shared and is not.
2026-08-11 21:59:27 -04:00
Gmer4Lfe 67510b8fad Draw the Scheduler's settings with the same renderer as everything else
It drew its own, so 329 of its 704 fields were text boxes that should have been
switches, numbers or lists, and 48 credentials rendered legibly.
2026-08-11 20:47:49 -04:00
Gmer4Lfe 24b526db93 Report a child's state from the list it was rendered from
Only the array name was taken from the orchestrator; the enabled flag still came
from the first-match map, so every orchestrator showed the first list's state.
2026-08-11 20:11:14 -04:00
Gmer4Lfe dfe4c0d495 Toggle the script in the orchestrator you clicked, not the first one that lists it
docker_update.sh runs bare in daily, --weekly in weekly and --remainder in
monthly, so switching it off in monthly disabled the daily run and reported
success.
2026-08-11 20:08:32 -04:00
Gmer4Lfe 2b933c45dd Give every AI setting a control, and every page one set to draw it with
The AI tab could show three per-turn checkboxes and no configuration at all;
the forty-three keys behind it were editable only by hand.
2026-08-11 19:03:12 -04:00
Gmer4Lfe f90b23ddd9 Initialise the array the write path collects refusals into
An undefined variable reaching a by-reference array parameter is a TypeError
under PHP 8, so every save through this endpoint died before writing anything.
2026-08-11 19:03:12 -04:00
Gmer4Lfe 0eccd7b36e File the repair log settings under the section that documents them
They sat under AI Web Search, which only mattered once the settings card
started grouping by the header above a key.
2026-08-11 18:36:37 -04:00
Gmer4Lfe a95def9139 Let the conf say which control each setting deserves
The comments above a setting already state its choices, units and bounds, so the
form can read them instead of asking for the file to be annotated first.
2026-08-11 18:17:04 -04:00
Gmer4Lfe 76ce424f8f Put every decision the assistant is waiting on in one place
Findings and proposed memory were two cards asking the same kind of question,
so checking one was never enough.
2026-08-11 18:00:44 -04:00
Gmer4Lfe 6855dcb8fd Watch reasoning in the same control you read it in afterwards
A capped window of its own made the block you were watching turn into a
different control the moment the answer arrived.
2026-08-11 17:37:39 -04:00
Gmer4Lfe e3ec54f213 Let each control actually govern the thing it names
Auto Scroll, the learning gate, the fold and the flash label each described a
behaviour they did not enforce.
2026-08-11 17:25:51 -04:00
Gmer4Lfe aa3d92360e Diff a proposed script against the open one and take it a hunk at a time 2026-08-10 22:41:07 -04:00
Gmer4Lfe 66f427dd2d Watch the reasoning as it is written, and hold the view where you put it 2026-08-10 22:33:57 -04:00
Gmer4Lfe 25361fafdb Let a long conversation list be filtered instead of scanned 2026-08-10 22:18:06 -04:00
Gmer4Lfe 081366a682 Fold long answers and offer a way back to the newest line 2026-08-10 22:16:11 -04:00
Gmer4Lfe 2100286dbb Show proposed memory beside findings, so keeping one is a decision and not a default 2026-08-10 22:06:28 -04:00
Gmer4Lfe 29e0daf80d Let the assistant propose what to remember, and the operator decide what is kept 2026-08-10 22:00:42 -04:00
Gmer4Lfe 57f3bf2807 Split memory into assisted and learned, and deny learned the precedence assisted has 2026-08-10 21:50:35 -04:00
Gmer4Lfe 775bdce6e1 Add slash commands, and stop Escape and recall from leaving a stale draft 2026-08-10 21:25:04 -04:00
Gmer4Lfe b9b2a59915 Let the last exchange be retried, edited or dropped, and stop losing drafts to a tab swap 2026-08-10 21:22:06 -04:00
Gmer4Lfe 33affc5a59 Open a cited script in the editor instead of a read-only viewer 2026-08-10 21:15:45 -04:00
Gmer4Lfe 9adf760c8e Give every code block its own copy and insert, and number the long ones 2026-08-10 21:12:15 -04:00
Gmer4Lfe eb24e60b8c Stream the answer as it is written, and let the operator stop it 2026-08-10 20:22:57 -04:00
Gmer4Lfe 9c744b34f8 Offer to drop an assistant's code block straight into the Scheduler editor 2026-08-10 19:59:28 -04:00
Gmer4Lfe 6054f68754 Stop conf_upgrade losing every key that follows a single-line array 2026-08-10 18:42:25 -04:00
Gmer4Lfe 74b579e281 Remove dead PCIe hardware at array start so correctable AER errors stop burying the syslog 2026-08-10 18:22:09 -04:00
Gmer4Lfe c0da7d935d Delimit the subreddit filter with ~ so it actually runs
The pattern used # as its delimiter and excluded a literal # inside the character class, so PCRE
ended the pattern early and the match never fired. It read as the filter doing nothing. The unit
test missed it by asserting against a retyped copy of the regex rather than the one in the file,
which is the only version that matters.
2026-08-09 22:55:29 -04:00
Gmer4Lfe 07f679b2e7 Drop subreddit front pages from degoog results
Its Reddit engine returns a community's landing page for any query at all — a question about ZFS
scrubs came back with Rocket League Esports and Dividend Investing, interleaved one in two, so
half of what reached the model was noise. Threads from that same engine are frequently the best
result there is, so the engine stays and only the URL shape goes: /r/<name> with nothing after it
is a front page by definition. That is a fact about the URL, not an opinion about how well it
matches, and this file does no similarity scoring of its own.
2026-08-09 22:54:42 -04:00
Gmer4Lfe 0c16c6db63 Add degoog as the search provider, and make it the default
The operator already runs it, so this is the one provider that costs nothing to adopt: no key,
no card, no third party, and no new container. It aggregates several engines and returns them
merged, and its /api/search reply is already the shape everything downstream expects — title,
url, snippet, with content as a fallback for the roughly one result in six that has no snippet.

It is also now the only provider whose response has been read from a live service rather than
from documentation, which is why it is the default.
2026-08-09 22:50:47 -04:00
Gmer4Lfe 613634473a Let General Chat search the web, and only General Chat
Search is the one capability that fits the profile holding none: every other capability either
reads this installation or changes it, and chat's whole contract is that it can do neither. The
assistant deliberately does not get it — its contract is that answers come from this install's
own documents, and a web result there is an answer that looks sourced and is not. A chat question
about this machine hands off to the assistant before the search would run, so it never reaches
the internet even with the box ticked.

Off by default, and not because it is dangerous: searching sends the operator's question outside
the house, which is theirs to decide. Asked for per turn as well as enabled in conf.

Provider-agnostic, as asked — searxng, brave, tavily. Only whichever is configured here can be
verified; all three read every field by name, so a shape that changes underneath yields no
results rather than wrong ones.

The explain fixtures asserted chat holds no capabilities at all, which is exactly the guarantee
worth keeping. caps=only: now states the set rather than its emptiness, so the check still fails
the day something else is granted there.
2026-08-09 22:42:43 -04:00
Gmer4Lfe 47861b0dc3 Pin a log-derived finding to what it is, not to what it said
The evidence of a syslog or container finding carries a line count and the timestamp of the
first matching line, so it differs on every pass even when nothing about the fault has changed.
Both the acknowledgement and the announcement were pinned to it, which meant acking one expired
fifteen minutes later and the notification repeated every sweep, forever — worst exactly where
it matters least, on a fault that has been there since the machine was built. Those kinds now
pin to their identity. A fault that genuinely worsens has a different class and so is a
different finding, and still announces on its own.
2026-08-09 22:25:51 -04:00
Gmer4Lfe 426ca2e5c7 Read the system log and the containers, not only Varaverk's own logs
Triage could see what Varaverk wrote about itself and what the arrs said about themselves, and
nothing else — so a disk throwing I/O errors, a filesystem going read-only or a PCIe link
retraining every two minutes was invisible to the thing whose job is noticing. Container state
was already watched; no line any container actually wrote ever was.

Container patterns match the environment rather than the application. Fifty containers are fifty
programs with no shared vocabulary for malfunctioning, but an exact shared one for a full disk or
a corrupt SQLite file, because those strings come from libc and SQLite rather than from the app.

Both halves are checked by Tools/ai_log_check.sh, which is two tests because the failure modes
are opposite: fixtures for recall on faults this host has never had, and a replay of its real
logs for precision — 74,519 syslog lines and 79,193 container lines, matching only the PCIe
errors it genuinely has.

Severity was being graded from a hand-picked three fields, so every one of these would have been
filed as a warning however bad it was, and notified as one.
2026-08-09 22:16:04 -04:00
Gmer4Lfe 91357a2d03 Tell the operator when a finding needs them
A finding nobody is told about is a finding nobody has, and the card added earlier only shows
them to someone who opens the tab. Only needs_operator is announced — an open finding may still
be repaired by the next pass — one notification for all of them, and each is announced once and
stays quiet until the fault changes or gets worse.

vv_notify() hands the message to common.sh's notify() rather than reimplementing the channels,
and calls detect_hosts() explicitly because load_config.sh deliberately does not: without it the
Unraid notification arrives and Discord silently never does. It also reports false when no
channel is switched on at all, since notify() exits 0 either way and a caller believing that
would mark a finding as told and never mention it again.

Notification text is folded to ASCII. Unraid's notifier dropped an em dash outright and left the
double space behind, which was found by sending one and reading what arrived.
2026-08-09 21:59:50 -04:00
Gmer4Lfe e4423200cc Report failures where the operator can still see them
The 55 alert() calls carried the same suppression as the confirms, and go wrong in the worse
direction: a silenced confirm makes a button do nothing, while a silenced alert lets the action
run and says nothing about it failing. vvAlert returns a promise nobody has to await, so these
converted by rename with no caller becoming async. The icon is inferred from the message rather
than asked of fifty call sites, and an explicit type still wins.
2026-08-09 21:47:23 -04:00
Gmer4Lfe 7d865b0a09 Ask with Unraid's own dialog, not the browser's
Every confirm() and prompt() in the plugin could be switched off from inside itself — one tick of
"prevent this page from creating additional dialogs" and all 32 of them returned false while
drawing nothing, across every tab, until a full reload. swal is already global on every webGUI
page and core uses it 370 times without a single confirm(), so this costs no new dependency.

vvConfirmRun() is the one that mattered: it returned a boolean to three callers testing !it, and
an unawaited promise is always truthy, so leaving those alone would have run every job without
asking. The wrapper's callback is a classic function expression on purpose — SweetAlert only
calls back on cancel when the callback's own source declares a parameter, and an arrow would
have hung the promise forever.
2026-08-09 21:44:43 -04:00
Gmer4Lfe 5b48561f36 Confirm a finding in the page instead of with a browser dialog
Both browsers offer "prevent this page from creating additional dialogs" inside the dialog
itself, and once it is ticked every later confirm() returns false without drawing anything — so
Fix and Never a problem silently declined and read as dead buttons. Unraid swaps tabs by AJAX
without reloading, so the suppression outlived leaving the tab and only clearing history got it
back. Both now arm on the first press and act on the second, and say what the press will do
while they wait for it.
2026-08-09 21:33:06 -04:00
Gmer4Lfe a5be719261 Refuse a conf write that widens a path to its own ancestor
Several scripts delete inside a conf path — the orphan cleaner runs rm -rf under a download dir
and rsync runs --delete against a destination — so /mnt/user/Movies becoming /mnt/user is the
edit that turns a cleanup into a sweep. Depth cannot be the test, because /tv and /movies are
real container-internal values here; direction can. Clearing a path, making it relative and
'..' segments go with it, and autofix additionally requires a proposed path to exist, since
every probe it has is a network probe and proves nothing about a directory. Refusals now reach
the caller: a save whose only change was refused answered ok with no explanation.
2026-08-09 21:21:52 -04:00
Gmer4Lfe 77fb1abb85 Put the findings where they can be answered
Seven findings had been sitting in needs_operator with no way to reach the operator: the only
way to read one was the sweep tool over SSH. The card states the gate as well as the list,
because an empty list means nothing found when repair is on and nothing is looking when it is
off. Buttons come from the actions the endpoint returned for that row, so a tab left open
overnight cannot offer a choice the store has moved past.
2026-08-09 21:14:44 -04:00
Gmer4Lfe b20a46ba81 Let an acknowledgement expire on a finding that has no conf key
An ack is supposed to hold only while the thing acknowledged is still true, but a finding with
no key compared '' against '' and so was silenced permanently — dismiss wearing ack's label,
which is every arr health finding on this host. Those pin to the shape of the fault instead.
Dismiss and reopen become real answers rather than states only reachable by editing JSON, and
apply_action now refuses anything the finding did not offer.
2026-08-09 21:14:36 -04:00
Gmer4Lfe 9c150532fc Give repair its own profile, reading what was said before and what fixed it before
Conf writing is granted to this profile alone and it is not offered as a button — making it
selectable would put a write one click from any question. Its prompt leads on what it does
not decide, because the resolver picks the key and the probe picks the value.
2026-08-09 20:49:43 -04:00
Gmer4Lfe 4ab3387db4 Resolve what the operator named, or say which things it could have been
Learned alias, then the literal key, then the keys and script ids whose own words contain
every word said. No similarity scoring anywhere: a near-match fails silently and confidently,
and here it would write to a key nobody named. Several matches is a question, not a ranking.
2026-08-09 20:39:19 -04:00
Gmer4Lfe 92157aa5da Learn the operator's typos on first sighting, not on the third
A term alias decides what gets written to conf and has to be earned by repetition; a spelling
decides how a sentence is read, and the same typo is rarely made three times.
2026-08-09 20:34:29 -04:00
Gmer4Lfe 801288c88c Keep a record of what was asked for and what it was taken to mean
A term that resolved the same way three times stops being an inference and becomes a lookup;
the corrections are the rows worth having, since a term that meant two things is still a
guess and is never promoted.
2026-08-09 20:23:49 -04:00
Gmer4Lfe 6f5c81c9a2 Show a finding by what identifies it, not by a key it may not have 2026-08-09 20:18:18 -04:00
Gmer4Lfe e5edffae4b Take the arrs at their word about their own health
They already publish what they believe is wrong, structured and graded, so there is nothing
to parse and no severity to second-guess. Findings widen from naming a conf key to naming
anything specific — Radarr's import lists being down is actionable in Radarr, not here.
2026-08-09 20:17:36 -04:00
Gmer4Lfe 3fac5751e4 Point the master template at scripts that exist
Nine entries named paths nothing lives at — a directory that was renamed to System_Essentials,
and docker_update_remaining.sh which has never existed — and the API key renewal was absent
from both arrays it runs from on this host. A fresh install would have skipped all of it.
2026-08-09 20:00:58 -04:00
Gmer4Lfe f2d4d2fae1 Stop reparsing both confs, and forking, on every conf lookup
vv_conf_vars() read and regex-parsed ~1,900 lines per call and reached a shell fork through
vv_detect_host(); the repair sweep asks it for a key per log line. Keyed on a hash of the
contents rather than mtime and size, which missed a same-second rewrite to the same length.
2026-08-09 19:46:17 -04:00
Gmer4Lfe e91a74d31f Sweep finished runs for misconfigurations instead of waiting to be told
Nothing in Varaverk fires when a job ends, so this picks up run records that completed
since the last pass rather than adding a hook to forty scripts. Ahead of stability in the
cycle on purpose: a wrong port is not fixed by rebooting the machine.
2026-08-09 19:38:02 -04:00
Gmer4Lfe 955f50b94e Let a finding be answered in words, matched rather than judged
The reply to "shall I fix it" ends in a conf write, so the three actions are matched by
pattern with an ambiguity refusal instead of being inferred — and a phrase that supports
two readings asks again rather than picking one.
2026-08-09 19:30:44 -04:00
Gmer4Lfe a8654280a7 Prove a repair before writing it, and gate writing separately from looking
A model can be confident a port should be 8686; a probe can report that 8686 answered, and
only the second is a fact. Detecting and repairing are also separate things to trust, so
the feature runs read-only until the write switch is turned on under it.
2026-08-09 19:23:57 -04:00
Gmer4Lfe 083b5ec5ad Record what is misconfigured on this host, apart from what is broken in Varaverk
A bug stays open until the code changes and nothing here can close it; a finding closes
itself when the probe that proved the fault starts passing, so the two cannot share a
store. Acknowledging one is scoped to the value it was acknowledged at.
2026-08-09 19:07:45 -04:00
Gmer4Lfe 500f9d92c8 Redact credentials on the way into a stored chat
A stored transcript is replayed into a later prompt when reopened, so a key typed while
changing a setting would be handed back to the model on every subsequent turn.
2026-08-09 19:07:34 -04:00
Gmer4Lfe 67eabdc17c Route every conf writer through the guarded path
Eleven call sites wrote master.conf with tmp+rename and nothing else — no backup, no
parse check, no audit — including the two toggles the UI uses most and the raw editor
that installs a whole hand-edited file.
2026-08-09 19:07:28 -04:00
Gmer4Lfe d9f917ecef Give conf writes a backup, a lock and a read-back before they count
These files are gitignored, so a bad write had nothing to go back to; the escaping order
also stored any value containing a quote truncated but still parseable, which only a
read-back can catch.
2026-08-09 19:07:22 -04:00
Gmer4Lfe eb4512aee3 Report a child that exited 1 as a warning, not an error
run_job.sh already grades runs as ok/warn/error by exit code, so a job that exited 1
recorded a warn while its log showed a red failure line — and the log is the louder of
the two.
2026-08-09 19:06:56 -04:00
Gmer4Lfe 0785a46fe3 Judge watchdog strike keys by their container, not the key itself
The state file is keyed both ways — a bare container name for the container check,
container plus suffix for the HTTP, API, CPU and docker checks — so inspecting the key
asked docker about Emby_http and deleted a live counter every cycle, putting the
two-strike limits out of reach.
2026-08-09 18:33:08 -04:00
Gmer4Lfe 78c9875d92 Let the AI card fill its row on the Monitor
The Assistant spans both rows and grid gives a spanning item's surplus to the tracks it spans, so
at Large the card sat at the top of a row much taller than it needed.
2026-08-09 14:03:01 -04:00
Gmer4Lfe c135d46cbc Re-fit once the chat height has finished moving
The transcript animates, so a page measuring it one frame later sized itself from a box caught
mid-collapse and left a dead strip the height the chat was shrinking from.
2026-08-09 13:57:06 -04:00
Gmer4Lfe 4b6c5fe1cc Bring the docs up to the chat window we actually built
The readmes still described a single expand glyph and a chevron for saved chats, and neither knew
about the banner, the two expanded sizes or the shortcuts list.
2026-08-09 13:45:30 -04:00
Gmer4Lfe 66f9d6f435 Count orchestrators on the board, pin it, and open on the assistant
Counting every step reported a fully scheduled system as 10 of 82, and reopening the last thread
brought its profile with it, so the tab came up as Troubleshoot pointed at nothing.
2026-08-09 13:41:53 -04:00
Gmer4Lfe 4980b62dc2 Pay for the shortcuts list out of the transcript
The toggle joins the other window controls in the banner, and opening the list now costs the
conversation its rows rather than costing the page more height.
2026-08-09 13:28:24 -04:00
Gmer4Lfe d91fa89310 Halve the height of the shut shortcuts strip
A 10px label was inheriting a 1.5 line-height, and its margin duplicated the gap the wrapper
already puts between its children.
2026-08-09 13:19:27 -04:00
Gmer4Lfe d6debc5408 Give the chat shortcuts worth listing, and a place to list them
Four new keys alongside Ctrl+Enter, rendered from the same list the handler reads so the panel
cannot advertise a key that stopped working.
2026-08-09 13:15:58 -04:00
Gmer4Lfe c3ed7262cd Separate whether the chat is expanded from how big expanded is
One is an action taken constantly and the other a preference set once, so the banner names the
size and the composer keeps the toggle.
2026-08-09 12:59:30 -04:00
Gmer4Lfe 455fb55857 Let the Scheduler's views shrink so the assistant can grow into them
Flex items default to min-height:auto, so a view holding a log ignored the height it was given
and pushed the composer out of a panel that clips instead of scrolling.
2026-08-09 12:42:32 -04:00
Gmer4Lfe 1370a29233 Tell the page when the chat changes height
The Scheduler sizes the views above the panel from what the chat leaves, so a chat that resizes
silently grew off the bottom and took its own composer with it.
2026-08-09 12:33:29 -04:00
Gmer4Lfe bb5daad3cc Halve the assistant's share of the Scheduler panel
It was the largest thing there before being asked anything, and the view above it is what the
question is usually about.
2026-08-09 12:28:31 -04:00
Gmer4Lfe 6be765a9bf Say there is one chat component, in the places that claimed otherwise
Several headers argued the Scheduler dock was deliberately separate, and the Scheduler's help
never mentioned the assistant at all — including the fix flow that just changed shape.
2026-08-09 12:22:25 -04:00
Gmer4Lfe 8eeb4c3d6c Make the Scheduler panel an instance, not a second chat
It kept its own bar, send loop and poll, which is why a thread there died on reload while the
other two were saved and why its controls had drifted from the same controls everywhere else.
2026-08-09 12:22:07 -04:00
Gmer4Lfe d0ff0c7d5c Teach the chat component what the Scheduler dock needs
Offers, a settable subject, runtime heights and a profile hook — everything the dock does that
the shared component could not, so it can stop being a second implementation.
2026-08-09 11:50:02 -04:00
Gmer4Lfe c431370a68 Put every chat control on one row, the same row everywhere
The picker sat above the transcript and the buttons below it, so the same component read
differently on each surface; size is the only thing a placement should get to choose.
2026-08-09 11:40:01 -04:00
Gmer4Lfe 4f598f7422 Hold Conversations and Tokens to the Assistant's height
A stretched grid item is measured before it is stretched, so a long card grew the row it was
meant to fit inside and then filled it; the ceiling is taken from the Assistant instead.
2026-08-09 11:09:04 -04:00
Gmer4Lfe 7b1b017d8c Stamp the stylesheet and script with their mtime
A pull changes the files but not their URLs, so a browser kept serving the old stylesheet to new
markup — which is how the Monitor AI row lost its layout.
2026-08-09 11:08:58 -04:00
Gmer4Lfe 1afeaf0e15 Give the token ledger its own card on the dashboard
The AI card answers whether the model is healthy now; what it has spent over a month was a
different question crowding it out one line at a time.
2026-08-09 11:00:00 -04:00
Gmer4Lfe 33e696071e Level the Conversations card with the Assistant beside it
A list reads better against a transcript when the two share a bottom edge, including after the
Assistant is expanded.
2026-08-09 10:47:55 -04:00
Gmer4Lfe 44cf4e7516 Open the dashboard chat collapsed, every time
A size remembered from days ago would push the cards below it down on every load, with nothing
on the page to explain why.
2026-08-09 10:38:21 -04:00
Gmer4Lfe 2aa9302751 Let the Assistant's expand control actually give the transcript room
The AI cards sat in the monitor grid, which caps every row at a quarter of the viewport and
clips the card, so expanding grew the transcript inside a box the row had already sized.
2026-08-09 10:29:50 -04:00
Gmer4Lfe 6cdb0d1eab One profile picker for both surfaces, and open where you left off 2026-08-09 00:56:04 -04:00
Gmer4Lfe d9225d06d9 Route a General Chat question to the profile that fits it 2026-08-09 00:36:13 -04:00
Gmer4Lfe 37784bce6c Offer Troubleshoot as a profile, and say plainly when it has no log 2026-08-09 00:25:52 -04:00
Gmer4Lfe 073352b21e Put the Scheduler dock on the same conversation store as everything else 2026-08-09 00:16:09 -04:00
Gmer4Lfe f4fd17be4d Accept the one retrieval kind the indexer produces and the page offers 2026-08-09 00:08:33 -04:00
Gmer4Lfe a011cd5e3b Give the Monitor tab the help panel the Scheduler already had 2026-08-09 00:06:28 -04:00
Gmer4Lfe 3a45c18555 Document today's restructures, and give data/ a README that ships with it 2026-08-09 00:01:05 -04:00
Gmer4Lfe 38cd3edf99 Scope the migration's busy check to the installation being migrated 2026-08-08 23:53:13 -04:00
Gmer4Lfe e406bd445d Move the data directories too, not only the files inside them 2026-08-08 23:43:57 -04:00
Gmer4Lfe f1603349cc Put everything Varaverk persists under one root, state included 2026-08-08 23:26:46 -04:00
Gmer4Lfe d5c36db531 Put every tmpfs path in one place both layers can read 2026-08-08 22:57:29 -04:00
Gmer4Lfe 0f92609425 Give the AI subsystem one profile table and one collection, read everywhere 2026-08-08 22:35:37 -04:00
Gmer4Lfe 0f4d381ac0 Make PHP agree with the clock the rest of the server keeps 2026-08-08 22:35:31 -04:00
Gmer4Lfe ea0c713c89 Put the assistant on the dashboard, where the questions actually occur to you 2026-08-08 22:01:13 -04:00
Gmer4Lfe 4d00e37ca1 Move the chat into an include, because the second one was going to be a copy 2026-08-08 22:01:06 -04:00
Gmer4Lfe 1e324636e0 Keep conversations, so a useful answer outlives the tab it was asked in 2026-08-08 22:00:58 -04:00
Gmer4Lfe df3c4bb369 Stop signalling on the strength of a stat file alone
A job killed hard never corrects its own record, so 'running' outlives the
process and the pid can since belong to something else; array-start jobs get
setsid too, so Stop can reach past their direct children.
2026-08-08 13:50:01 -04:00
Gmer4Lfe 2fefb56b8c Say which revision the pull restarted into
The restart line named no version, so a log showing one gave no way to tell what
the rest of the run actually executed.
2026-08-08 13:23:16 -04:00
Gmer4Lfe 934c0b56be Make a pull that updates the puller take effect on that pull
git installs the new file by rename, so the running bash finishes on the inode
it started with and every step after the sync used the previous version.
2026-08-08 13:21:14 -04:00
Gmer4Lfe e0cf83c944 Let one host template upgrade any host's conf
The pull substituted only HOSTN_, so bare HOSTN in comments kept tripping
conf_upgrade's own guard and no host conf had upgraded since the guard landed.
2026-08-08 13:08:24 -04:00
Gmer4Lfe 9cabb04395 Resolve Custom script ids where the scripts actually live
The Custom/ prefix is a namespace, not a directory under SCRIPTS_DIR, so every
launcher that resolved it as one could never find the script.
2026-08-08 12:47:26 -04:00
Gmer4Lfe 2ab7d3d98a Stop the Stop button from killing the web server
UI-launched jobs inherited php-fpm's process group, so stopping one that was
actually running group-killed the WebGUI; setsid makes the job its own group
leader and stop.php now refuses to signal any group it does not lead.
2026-08-08 11:09:08 -04:00
Gmer4Lfe 228aae31c9 Ask before running a script, which the page header has always said it did
Run was the only mutating control here that did not confirm, and it is the one that executes
shell as root — the library holds cleaners that delete files, and six Run buttons sit a row apart
in a dense tree.
2026-08-07 10:43:43 -04:00
Gmer4Lfe 86a0ba55fe Include the config the container endpoint now depends on
vv_cache_clear() went in without it, so every start and stop fatalled on the line after the
docker command had already run — the action succeeded and the page was told it failed.
2026-08-07 10:33:08 -04:00
Gmer4Lfe 9e3755f9ca Take the literal script tag out of the comment that broke every tab
Written with real angle brackets inside the inline block, it ended that element early, so the
file rendered as visible text and no page JS ran at all.
2026-08-07 10:29:13 -04:00
Gmer4Lfe ab794db0ab Stop the dashboard polling harder than its data can change
Every poll after the first asked for ?live=1, so the page paid a full collection — partner SSH
timeouts included — every two seconds while the tmpfs cache it was built around went unused.
Polls are now guarded against overlap and stop while the tab is hidden, repeated failures say so
instead of leaving the last good reading on screen, and two functions nothing called are gone.
2026-08-07 10:23:13 -04:00
Gmer4Lfe b445ddc2c8 Make a container action tell the truth about what happened
The response was discarded, so a refused stop looked like a completed one, and the payload the
card redraws from is cached for 300s with no invalidation — the container carried on showing as
running until the once-a-minute writer caught up. Stop now confirms; start still does not.
2026-08-07 10:20:11 -04:00
Gmer4Lfe fb50f94ed8 Escape what the monitor page renders, and put the helpers where every page can reach them
The page interpolated media titles, partner hostnames read over the mesh, and docker folder
names straight into innerHTML — its own header claimed otherwise, and the helpers that would
have fixed it were defined in a page it never loads. Container WebUI values now get a scheme
check before they reach window.open().
2026-08-07 10:17:45 -04:00
Gmer4Lfe b02857cf04 Put the AI master switch on Settings, so the switch that hides the AI tab is reachable without it
Turning AI off takes the AI tools and their conf gear off the Tools card with it, which
left the raw master.conf editor as the only way back on.
2026-08-07 09:49:41 -04:00
Gmer4Lfe be306cf86c Let one gate decide every AI surface, so switching AI off actually removes all of it
The Tools card adopted the AI scripts on the host check alone, and api/ai.php only
tested AI_ENABLED in front of ask, so a disabled subsystem still had rows to run and
an endpoint that answered.
2026-08-07 09:49:31 -04:00
Gmer4Lfe bf7a2cc911 Point scripts at the shared host sections, and fix the two reasons those sections could never be found 2026-08-06 23:16:28 -04:00
Gmer4Lfe 4989486f45 Two thirds of the conf was unreachable from the UI, and an empty Config block said nothing about why 2026-08-06 23:08:49 -04:00
Gmer4Lfe 9be6055113 Adopt the AI tools onto the Tools card, but only where the index and the model actually are 2026-08-06 23:02:22 -04:00
Gmer4Lfe 8b0c2f6d1b Tools had no gear, so nineteen scripts had no Info and Settings at all 2026-08-06 22:57:05 -04:00
Gmer4Lfe 141e2ca37c At full size the dock's own button becomes its collapse, and closing hands the panel back 2026-08-06 22:49:38 -04:00
Gmer4Lfe b803fefebd Give the assistant dock a size toggle, so a long answer can have the panel without hiding what it is about 2026-08-06 22:44:00 -04:00
Gmer4Lfe ba1979a662 zfs_arc_max reads 0 by default, so the fallback never fired and the ARC section divided by zero 2026-08-06 22:39:54 -04:00
Gmer4Lfe 1b46033584 grep -c prints zero and exits one, so every echo-0 fallback produced a two-line count 2026-08-06 22:31:50 -04:00
Gmer4Lfe 7b2f79e3ac grep -c prints zero and exits one, so the echo-0 fallback made VM_COUNT two lines and --status died 2026-08-06 22:26:16 -04:00
Gmer4Lfe 9145a66cef Surface server_reboot.sh on the Tools card without moving it out of the shutdown chain it belongs to 2026-08-06 22:24:30 -04:00
Gmer4Lfe d5944c2443 Sweep every onclick built from data: an unescaped quote silently killed nine handlers 2026-08-06 22:13:10 -04:00
Gmer4Lfe d135dff5cd An unescaped quote ended the onclick attribute early, so clicking an error line did nothing 2026-08-06 22:08:10 -04:00
Gmer4Lfe 62a838ebdd Clicking a recent error opens the log on that line and acks it, so acknowledged means seen 2026-08-06 22:00:44 -04:00
Gmer4Lfe f2d0e18f2c Ask about a run from the row it is on, and stop spending context on health checks when the run was clean 2026-08-06 21:51:55 -04:00
Gmer4Lfe 4347fc5f07 Capture the dock phrasing that worked, so it keeps working 2026-08-06 21:38:41 -04:00
Gmer4Lfe ce0cbd868c Write down the routing every bug so far produced, and check it without asking the model 2026-08-06 21:34:05 -04:00
Gmer4Lfe 2658082607 Explain mode: show what a question would be given and why, without asking the model 2026-08-06 21:27:26 -04:00
Gmer4Lfe 55aa7f968f A phrase list missed one word order, and the assistant answered a log question with a command that re-runs the job 2026-08-06 21:17:57 -04:00
Gmer4Lfe 0882942164 General Chat now hands a Varaverk question up to the Assistant instead of telling the operator to do it 2026-08-06 20:56:47 -04:00
Gmer4Lfe 917ff43795 Profile permissions belong in one table, because scattered they hid a capability chat was never meant to have 2026-08-06 20:51:19 -04:00
Gmer4Lfe d562644581 State the no-citation rule for run evidence in the contract itself, since as an amendment it lost 2026-08-06 18:49:18 -04:00
Gmer4Lfe 02aa73bb8d Chat invented a log inspection when asked how a run went, and the rundown cited passages it had not read 2026-08-06 18:48:01 -04:00
Gmer4Lfe 0fa871ef61 The run-outcome gate ignored the profile, so chat could be handed a log it is promised it never sees 2026-08-06 18:45:55 -04:00
Gmer4Lfe 815ceef276 A question about how a run went should arrive with the run attached, not with directions to the log panel 2026-08-06 18:43:12 -04:00
Gmer4Lfe 10c1705790 An answer sized the assistant dock past the bottom of the page because nothing refit the panel after it landed 2026-08-06 18:36:50 -04:00
Gmer4Lfe e467cdaf23 The scheduler AI dock read the poll envelope as the job, so every answer hung on thinking 2026-08-06 18:28:13 -04:00
Gmer4Lfe 269ef9b2de Verify a bug report's component and evidence before filing it 2026-08-05 21:25:00 -04:00
Gmer4Lfe 657ee435d1 Add a copy-out button that formats a report as a pasteable issue 2026-08-05 21:12:40 -04:00
Gmer4Lfe 2f8af3a1b7 Say the bug was recorded locally, because that is what happened 2026-08-05 20:55:56 -04:00
Gmer4Lfe 2c5e412d91 File AI-detected Varaverk defects as deduped reports, surfaced on the AI tab and counted in the coffee report 2026-08-05 20:48:54 -04:00
Gmer4Lfe 9d4a62fa5e Have the troubleshooter suspect configuration before it suspects Varaverk 2026-08-05 20:33:01 -04:00
Gmer4Lfe 714d97dd49 Add an incident journal the troubleshooter reads back, and thinking for diagnosis 2026-08-05 20:26:56 -04:00
Gmer4Lfe 5a813eb4c8 Add a troubleshooting profile with the scoped log, and a deterministic conf-key lookup 2026-08-05 20:18:05 -04:00
Gmer4Lfe 185abdb442 Add a scope-aware assistant dock to the scheduler right panel 2026-08-05 19:56:02 -04:00
Gmer4Lfe 60994c7667 Stop the array fields soft-wrapping so numbers and stripes stay in register 2026-08-05 18:24:06 -04:00
Gmer4Lfe eacea56d9d Title the info banner for its view, and number and stripe the array fields 2026-08-05 18:13:04 -04:00
Gmer4Lfe 99486b1eaf Open on the condensed list, with a More info button for the full document 2026-08-05 18:03:47 -04:00
Gmer4Lfe aeb8c01370 Show the terse control list when the help block is collapsed 2026-08-05 17:58:29 -04:00
Gmer4Lfe 2ad3a1bfed Put the whole help doc back behind the collapse so the live blocks are not buried 2026-08-05 17:51:12 -04:00
Gmer4Lfe 329bf25b14 Number and stripe the per-script settings so each one reads as its own row 2026-08-05 17:44:12 -04:00
Gmer4Lfe 90eee5674f Guarantee a trailing newline when writing a conf so appends cannot join the last line 2026-08-04 21:20:42 -04:00
Gmer4Lfe 1761087283 Measure the editor chrome instead of assuming 38px, so the last lines are reachable 2026-08-04 21:16:15 -04:00
Gmer4Lfe a13c5541cc Pin the editor highlight overlay to the textarea so long confs scroll to the end 2026-08-04 21:07:27 -04:00
Gmer4Lfe 262cd92300 Cover every control in the reference tables so the collapsed view stays complete 2026-08-04 19:49:44 -04:00
Gmer4Lfe 87fa2693f0 Show the reference tables collapsed and the task guide on expand, from one file 2026-08-04 19:45:23 -04:00
Gmer4Lfe 64a28acd64 Keep wrapped list items in their bullet instead of orphaning the continuation 2026-08-04 19:38:58 -04:00
Gmer4Lfe 3ea80ef944 Single-source the scheduler help from markdown the AI index can read 2026-08-04 19:30:38 -04:00
Gmer4Lfe e1bbecad38 Rename the per-orchestrator expander to Steps so it stops colliding with Advanced mode 2026-08-04 19:19:48 -04:00
Gmer4Lfe 149d98a10b Add a collapsed settings card under the composer 2026-08-04 18:27:57 -04:00
Gmer4Lfe bd583b58dc Put token usage above the system checks on the AI tab 2026-08-04 18:22:09 -04:00
Gmer4Lfe 7d0f7332b9 Probe partner liveness separately so a blink cannot delete a cached ledger 2026-08-04 18:15:47 -04:00
Gmer4Lfe 83508cfb1b Pull partner token ledgers into a RAM cache so fleet totals are fleet-wide 2026-08-04 18:14:38 -04:00
Gmer4Lfe 969a85f303 Record token usage per turn and show daily, weekly and all-time totals by host 2026-08-04 18:04:46 -04:00
Gmer4Lfe 961c57c6f0 Let the system checks reach three columns on a wide monitor 2026-08-04 17:49:20 -04:00
Gmer4Lfe 89d6219e95 Align the System checks card with the third banner stat 2026-08-04 17:44:30 -04:00
Gmer4Lfe 131c9ffc13 Run the AI system checks in two columns to halve the card height 2026-08-04 17:40:50 -04:00
Gmer4Lfe b1fdaced9f Restrict the AI tab to HOST1 — the only node with the GPU, Ollama and the index 2026-08-04 17:25:40 -04:00
Gmer4Lfe 0cf208b80a Remove the Open-WebUI retrieval bridge — Varaverk is the only Ollama client now 2026-08-04 17:11:46 -04:00
Gmer4Lfe 9c8eb8a2bc Refresh the AI index after a pull that changed tracked files
AI_INDEX_ON_PULL was designed, defaulted false, and never read by anything —
so nothing has ever rebuilt the index automatically. A pull is the only thing
that changes tracked files on a server, which makes it the only moment the
index can go stale, and a timer would do nothing 23 times a day while still
drifting from the pull that matters.

Staleness is invisible in the answers: the index keeps returning the old text
with confident, correct-looking citations. Gated on pull success, on
AI_INDEX_ON_PULL and on AI_ENABLED, and never fatal — a git pull must not fail
because an embedding call timed out.
2026-08-03 18:26:13 -04:00
Gmer4Lfe 452bf0e544 Route definitional questions to the narrative docs, and tolerate misspelling
"What is Varaverk" matched the PURPOSE intent, so retrieval returned every
script's one-line purpose and the model answered that the context does not
define the system — while README.md sat in the index unread. A definitional
question with no explicit filter now goes to kind=readme.

Varaverk is a coined word with no spell-check, so it arrives as varavrk,
veraverk, varavek. Edit distance catches those without a pattern that needs
extending per typo. This is search, not identity — a near-miss only widens a
document search, unlike hostname resolution where it must never resolve.
2026-08-03 18:17:56 -04:00
Gmer4Lfe 9d189e9dbf Flag destructive operations in generated scripts
Custom Scripts run as root on a schedule, so the expensive mistake is not
tangled logic — it is a simple script aimed one directory too high. The prompt
now asks for the dry-run form and an explicit line on what gets destroyed, and
a scan of the generated code blocks raises a banner regardless of whether the
model bothered to warn. Scans fenced code only, so prose mentioning rm does not
trip it.
2026-08-03 18:09:47 -04:00
Gmer4Lfe a3098ee67c Tell Code Sketcher to admit an invented flag rather than blame the version
Given its own broken script and the real error, it produced the correct fix but
explained --no-overwrite away as 'introduced in a later version of rsync'. No
rsync version has ever had that flag, and the operator is on current 3.4.4 — so
the repair was right and the diagnosis was a second fabrication that would send
someone hunting a nonexistent upgrade.
2026-08-03 17:48:52 -04:00
Gmer4Lfe 73305a0cd1 Fix the chat-profile detector missing two-segment conf keys
The pattern required three underscore-separated segments, so RSYNC_ENABLED and
AI_ENABLED slipped through — exactly the names most likely to be asked about.
Also dropped the case-insensitive flag, which made the SCREAMING_CASE branch
match ordinary lowercase words containing underscores.
2026-08-03 17:39:50 -04:00
Gmer4Lfe e58f0352a7 Stop General Chat inventing answers about Varaverk internals
Asked what mover_stop.sh does, it invented a description and dressed it in
real memory facts so it read as authoritative — the exact failure the profile
split exists to prevent. A polite instruction in a system prompt is not a
guard. The rule now leads and is absolute, and a deterministic check repeats
it immediately before the message whenever the question names a shell script,
a SCREAMING_CASE conf key, or Varaverk itself. Detection can only make the
model more cautious, so a false positive costs a redirect, not an answer.
2026-08-03 17:38:43 -04:00
Gmer4Lfe 8d393a1e1c Add three chat profiles: Varaverk Assistant, General Chat, Code Sketcher
Explicit buttons rather than an automatic router. Misclassifying a Varaverk
question as chat produces a confident invention about the user's system, which
is exactly what retrieval exists to prevent — with buttons there is no hidden
heuristic to be wrong and the strict profile is the default you land on.

Only Varaverk Assistant retrieves; the other two would be carrying passages
that cannot help write a folder-copy script. Memory goes to all three, since
that is what lets chat know the setup without claiming authority over it.
History depth is per profile and set server-side: retrieval costs ~2500 of
16384, so the profiles that skip it can hold a real conversation. Code
Sketcher is told to flag flags it is unsure of, after it invented
rsync --no-overwrite.
2026-08-03 17:36:24 -04:00
Gmer4Lfe d0b3588f6c Give the assistant a standing memory file
A small operator-written file handed to the model at the start of every
conversation — who you are, how this install is set up, what has already been
decided. Injected ahead of the retrieved passages and marked operator-authored
so it outranks anything they contradict, and never cited as a source.

Deliberately not indexed and deliberately under DATA_DIR: it changes
constantly, vector similarity is the wrong way to retrieve things you were
told to remember, and gitignoring it keeps personal notes out of a pushed
repository. The character cap is a context budget — this text costs its share
of 16k on every single turn.
2026-08-03 17:11:27 -04:00
Gmer4Lfe 7625e923e5 Accept the global RSYNC_ENABLED flag, not only prefixed tiers
The pattern required a tier prefix, so the master gate the rsync page renders
alongside the tier toggles was rejected as an invalid flag name and sprang
back to off. There was no way to enable syncing from the UI. Masked until now
because every POST to this endpoint was being swallowed as multipart.
2026-08-02 18:49:00 -04:00
Gmer4Lfe 76ad744581 Convert every POST off multipart — 21 call sites across 7 tabs
A multipart POST to the plugin API hangs and never completes on this host: no
status code, and no server-side trace of any kind. The correlation was exact —
every page using URLSearchParams worked, every page using FormData hung, which
is why scheduler and partnership appeared fine while docker, rsync, settings,
auth, arrs, fallback and monitor did not. URLSearchParams has the same append
API and fetch sets the urlencoded content type for it, so each site is a
one-token change with the payload logic untouched.
2026-08-02 18:27:25 -04:00
Gmer4Lfe 47110730e6 Send AI POSTs urlencoded instead of multipart
A multipart POST to this endpoint hangs: no status code is ever returned, and
the request never reaches PHP — no CSRF termination, no fatal, no entry log —
despite leaving the browser with a valid token and a correct body. Every POST
on this host that demonstrably works, including Unraid's own, is
x-www-form-urlencoded. Same fields and same $_POST server-side.
2026-08-02 18:20:57 -04:00
Gmer4Lfe 7f01b3040d Log entry into the AI endpoint before any dependency loads
A POST the browser demonstrably sent — correct token, correct body — left no
trace: no CSRF termination, no fatal, and no action log. Rejected-before-PHP
and died-inside-the-include are indistinguishable from outside, so the first
statement now records that execution arrived.
2026-08-02 18:16:58 -04:00
Gmer4Lfe f30f3263bd Fix nested php open tag in the AI page build stamp
The build-stamp edit replaced the header block's closing tag instead of
keeping it, opening a second php block inside the first. Deployed broken
because the lint in that commit printed OK unconditionally rather than
gating on php -l.
2026-08-02 18:06:52 -04:00
Gmer4Lfe ca678b1784 Stamp the AI page build and tear down stale copies of its script
The tab bar uses Unraid's localURL, which swaps content by AJAX without
tearing down the previous page's JavaScript, so old copies keep their timers
and their state. That makes "is the browser running what I deployed"
unanswerable from the server, and explains banner polls arriving far faster
than the 30s timer. The page now stamps its build, says plainly when its own
script is not the one running, and stops the previous copy's timers.
2026-08-02 18:06:10 -04:00
Gmer4Lfe ae7555a423 Stop a stuck in-flight flag from silently swallowing every later question
A turn that ended without finish() left busy=true, so send() returned at line
one for every subsequent click — no fetch, no error, and the original
"starting…" still on screen. That is a hang which produces no request and so
no server-side trace of any kind. It now says what happened, and the pending
indicator carries an elapsed counter so stalled and merely slow look different.
2026-08-02 18:03:21 -04:00
Gmer4Lfe e0925b9d38 Make a failed AI send visible instead of hanging
GET reaches the endpoint and POST does not, with nothing in the request log,
no CSRF termination and no PHP error — so it fails in the browser before the
request goes out, and the only symptom was the pending indicator sitting
there. Reads the response as text before parsing so an empty body reports as
rejected-before-execution rather than a JSON error, wraps the synchronous
path, and surfaces script errors into the transcript.
2026-08-02 18:00:53 -04:00
Gmer4Lfe 563ea0eb78 Log AI requests, and stop hiding a failed job-file write
There is no nginx access log on this host and the CSRF prepend exits with an
empty body, so a request that never arrived and one that arrived and failed
were indistinguishable — the page just sat at "starting…". The job-file write
was also suppressed with @, which would produce exactly that hang: a token
returned for a job that can never report.
2026-08-02 17:57:22 -04:00
Gmer4Lfe 198532e280 Send all health checks as live state, not only the failing ones
Asked what was wrong, it answered "AI_ENABLED=false" — read from the conf
template, which records the shipped default, while the live value was true.
Passing checks are what tell the model the current value of a setting;
without them a documented default fills the silence. The prompt now states
explicitly that live state overrides any documented default.
2026-08-02 17:45:41 -04:00
Gmer4Lfe 69ffb5e1dc Fix two false alarms in the AI health checks
vv_meta uses columns k/v, so the embedder-match check read nothing and
reported "unrecorded" — a silent pass on the one mismatch that returns
confident nonsense instead of an error. And Ollama reports tags fully
qualified while conf carries the bare name, so an installed embedding model
was reported missing. A health panel that cries wolf is worse than none.
2026-08-02 17:42:35 -04:00
Gmer4Lfe 237156c46f Add config-vs-reality health checks, loaded models, and log evidence
The failures this subsystem actually has are configuration drift, so each
check names the setting to change rather than reporting that retrieval
failed. Notably it catches a conf model tag that is no longer installed, and
an index built by a different embedder than the one configured — vectors
from two models are not comparable, and that failure returns confident
nonsense rather than erroring. Diagnostic questions also get recent log
warnings, attached only then because they cost budget the passages need.
2026-08-02 17:40:43 -04:00
Gmer4Lfe 264ba57cbb Add the AI tab — grounded chat over the documentation index
Token and poll rather than SSE, so the api layer keeps one response
convention and reuses the pattern manual_sync already proved. History is
capped at three turns because the model is only fully offloaded at 16384
context and unbounded history would cross that silently. The tab exists
only while AI_ENABLED is true, rejected server-side and not merely hidden.
2026-08-02 17:32:17 -04:00
Gmer4Lfe a537ae4217 Add the Open-WebUI tool that calls the retrieval bridge
Pasted into Open-WebUI rather than installed by a script: tools live in its own
database, and writing there means guessing at the schema and access control of
a live app. The secret is a Valve so this file stays committable.
2026-08-02 17:11:57 -04:00
Gmer4Lfe 8896e05e35 Add a retrieval-only HTTP bridge so Open-WebUI can reach the index
Open-WebUI runs in its own container with no WebGUI session, and nginx applies
auth_request to everything it serves, so it cannot call the plugin API. Same
shape as the arr webhook listener: node outside nginx, shared secret, one
read-only verb. Serves chunks rather than answers because the caller already
has a model loaded.
2026-08-02 17:09:51 -04:00
Gmer4Lfe c617f64b6b Forward section and kind to retrieval on the ask path
Both filters were parsed and honoured under `search` but never passed through
under `ask`, so --section=CONFIGURATION silently retrieved from the whole index
and returned a plausible answer built from the wrong chunks.
2026-08-02 13:10:56 -04:00
Gmer4Lfe eb36641523 Expose --kind on ai_query so definitional questions can reach the prose
The index already stored chunk origin and search.js already filtered on it;
only the wrapper refused the flag. Intent routing boosts PURPOSE for "what
is X", which buried README.md and made the system answer that it had no
definition of itself.
2026-08-02 13:09:17 -04:00
Gmer4Lfe 885cceccae Restore the truncated intro of Notes_AI-Design.md
A partial write at 11:41 left the status paragraphs cut off mid-line, and the
previous commit swept it up. Content recovered from e02dc1b.
2026-08-02 12:53:55 -04:00
Gmer4Lfe 2d07c7d80e Refuse a conf_upgrade that would keep nothing
Merging the raw host.conf.template against a live host conf matched HOSTN_
against HOST1_, classified all 140 real keys as deprecated and would have
removed every credential on the host. Also points the Ollama model default
at a tag that still exists.
2026-08-02 12:52:49 -04:00
Gmer4Lfe e02dc1be15 Keep the monitor page header inside its PHP block
Its first line closed PHP before the header comment started, so the whole
block rendered as text at the top of the tab.
2026-08-02 10:37:34 -04:00
Gmer4Lfe c34224effa Carry the CSRF token on fetch requests and put mutations behind POST
Unraid already enforces CSRF on every POST via auto_prepend, but its
injector is jQuery-only — the plugin's native fetch() calls carried no
token and were being terminated before the endpoint ran, silently,
because csrf_terminate exits with an empty body that r.json() swallows.
2026-08-02 10:28:53 -04:00
Gmer4Lfe 987313e7dc Document the PHP api layer and fix what documenting it exposed
Writing down what each endpoint actually guarantees made the places it
didn't obvious — shell arguments reaching a crontab or a bash -c
unescaped, master.conf written without tmp+rename, and conf edits that
could be saved without ever being parsed.
2026-08-02 10:11:39 -04:00
Gmer4Lfe 6a959fb5e4 Add AI entry points, conf schema, and folder docs
ai_index.sh and ai_query.sh follow the usual conventions — fail-closed gate,
root check, lock, dry-run, status — with Node doing only the vector maths and
SQLite blobs, the same split api_cache_writer.sh uses for PHP.

AI_* and HOST*_OLLAMA_* land in both confs and both templates in this pass.
Everything ships off: AI_ENABLED false, every AI_ASSIST_* false, conf writes
disabled with an empty whitelist. Nothing in the ecosystem consults it.
2026-08-02 01:21:24 -04:00
Gmer4Lfe b00688bad1 Add RAG retrieval core, and correct six stale default values in headers
Chunker splits on the header sections the audit standardised, then sub-splits
named-paragraph safeguards — without that a specific question about one of
rsync.sh's fourteen safeguards scored below unrelated chunks, because the other
thirteen dominated the vector. Index is SQLite with raw float32 blobs and is
incremental on mtime; a no-op re-index takes 66ms.

The stale defaults were found by asking the system a question and checking its
answer: it correctly reported what mover_stop.sh's header claimed, and the
header was wrong.
2026-08-02 01:13:01 -04:00
Gmer4Lfe ccc6c742ee Add structured headers to the PHP pages layer
Documents each tab's purpose, what it renders, and which endpoints it polls.
Pages that start with markup get the header in a <?php ?> block so it never
reaches the browser.

Also corrects the layer diagram in README-unraid.md: eight of eleven pages hold
no PHP logic and poll api/ for everything — only auth, monitor and scheduler
require an include/ file directly.
2026-08-02 00:51:41 -04:00
Gmer4Lfe 0b4ff27be4 Prune stale container strike counts, not just skip-list entries
The 2026-07-19 prune covered the skip list and intentional stops but not the
per-container strike counts, which have the same failure: set_strikes 0 only
fires when a container is seen running again, so an uninstalled one keeps its
count forever and every consumer reads the host as unhealthy. Reserved daemon_*
keys are excluded.
2026-08-02 00:45:29 -04:00
Gmer4Lfe 43b5443b30 Add structured headers to the PHP include layer, fix monitor state paths
All 16 include/ files now carry PURPOSE / DESIGN PRINCIPLES / OPERATIONAL
SAFEGUARDS / EXPORTS / CONFIGURATION, keeping the first three section names
identical to the bash headers so retrieval can route across both languages.

monitor.php read six watchdog state files from /tmp while the watchdogs write
to STATE_DIR, so every strike set came back empty and the summary reported
healthy unconditionally. docs.php gained path containment before it is wired
to a page.
2026-08-02 00:38:22 -04:00
Gmer4Lfe 76c4ca5ccf Correct api_key_renew schedule in System_Essentials README
It is listed in WATCHDOG_ORCHESTRATOR_SCRIPTS as well as ARRAY_START_SCRIPTS,
so the claim that nothing in this folder runs on a timer was wrong.
2026-08-02 00:17:41 -04:00
Gmer4Lfe e2e76560a2 Fix Authelia config path and stale schedule comments in conf templates
The Authelia config default pointed at /mnt/user/appdata/Authelia, which does
not exist — the auth stack lives in the Critical-Data share so it gets synced.
Because conf_populate only fills empty fields, that wrong non-empty default
blocked auto-detection permanently and left the auth page's rules panel dead.

Also corrects three schedule comments that no longer matched varaverk.cron and
two references to scripts that have since been renamed or split.
2026-08-02 00:13:53 -04:00
Gmer4Lfe ce806ae854 Record the RAG corpus shape while the audit context is fresh
The header audit and per-folder docs pass produced a corpus with properties
worth capturing before they are forgotten: deterministic chunk boundaries,
section type as a retrieval filter, and an index that cannot leak credentials
because the conf files were never tracked.
2026-08-01 23:56:34 -04:00
Gmer4Lfe c54039392d Rewrite README and Manual front pages in project voice
The repo front page read like generic documentation rather than a record of
why any of this exists. Adds the origin story, the current honest state, real
failure history, and fixes drift in platform_*() naming, the fallback tier
variable pattern, and the Plugin/unraid script locations.
2026-08-01 23:47:50 -04:00
Gmer4Lfe 671bf2352b Give the README a voice and bring it current
It is the front page and it read like a spec sheet. Adds the incidents the safeguards came
from, an honest note about what is actually switched on, and the layers built since.
2026-08-01 23:34:34 -04:00
Gmer4Lfe 1ed6b92118 Document the Unraid platform layer
The folder that translates the OS into Varaverk's vocabulary had no docs of its own, so the
adapter contract and the three-layer web UI were only discoverable by reading the code.
2026-08-01 23:20:52 -04:00
Gmer4Lfe ab169a6f48 Document the Deployment folder
It was the only folder without docs, and it holds the only versioned record of what
configuration exists — the confs themselves are gitignored.
2026-08-01 23:12:18 -04:00
Gmer4Lfe 7a8fbbe06a Correct stale references across the remaining folder docs
Three renamed or dissolved names were still cited, and three scheduled scripts had no
entry in their folder's table.
2026-08-01 23:07:50 -04:00
Gmer4Lfe 53aa72d38c Correct Fallback tier variable naming and document rsync merge-run
The fallback docs used an older COVERS naming in the wrong conf file, and rsync's merge-run
mode — the only path that carries --delete — was undocumented entirely.
2026-08-01 23:05:30 -04:00
Gmer4Lfe 8a2707ee37 Correct and extend folder docs for Docker Essentials, Watchdogs, Media and Arrs Stack
The docs had drifted from the scripts — a script that no longer exists, three wrong variable
names, a reversed run order, and seven scheduled scripts that were never documented at all.
2026-08-01 22:59:07 -04:00
Gmer4Lfe c377ddfcca Complete the header template across Partnership, Kernel, Deployment and Plugin
Finishes the pass: every script now documents its safeguards, and the deliberate absences
in the sourced libraries are recorded so they are not "corrected" later.
2026-08-01 22:44:23 -04:00
Gmer4Lfe 5c4f8db497 Flock the offline counter in partnership --check
It is a read-modify-write, so overlapping cycles lost increments and pushed the
auto-offboard threshold past its configured window.
2026-08-01 22:44:23 -04:00
Gmer4Lfe 132a657f00 Refuse to auto-detect a container when the prefix is ambiguous
On this host "authelia" matches both Authelia and Authelia-Secondary, and taking the first
match writes the wrong instance into the conf every other script then trusts.
2026-08-01 22:44:23 -04:00
Gmer4Lfe 7669cd75b8 Install upgraded confs by atomic rename
A copy truncates the live conf and writes into it, so anything sourcing load_config.sh
during that window reads a partial file with empty path variables.
2026-08-01 22:44:23 -04:00
Gmer4Lfe 3813884287 Match duplicate candidates literally, not as a regex
Artist and title strings are full of regex metacharacters, so a new candidate could match
an unrelated history entry and be silently skipped as already seen.
2026-08-01 22:44:23 -04:00
Gmer4Lfe b80cf04cdc Record measured Ollama tuning results in the AI design notes
The estimates in the original note were wrong in both directions, and Unraid's Apply does
not reliably recreate a container, which is worth knowing before the next tuning pass.
2026-08-01 21:56:02 -04:00
Gmer4Lfe 986353a0f0 Add AI integration design notes
Captures the reasoning behind a not-yet-built feature so the constraints survive the
session, chiefly that AI stays enhancement-only and never load-bearing.
2026-08-01 21:30:08 -04:00
Gmer4Lfe e8b114094a Bring script headers onto the template and close safeguard gaps
Headers claimed protections the code never had, and several destructive paths had no
guard against a collapsed config value.
2026-08-01 20:37:59 -04:00
Gmer4Lfe cdce877601 Add a second GPU card to the monitor page
The 3080 was invisible because the stats parser only ever described GPU 0.
2026-08-01 20:37:58 -04:00
Gmer4Lfe 77b24e9823 Resolve network watchdog host config after detect_hosts
MY_ID is not exported and is unset until detect_hosts runs, so the DDNS and NPM checks
were silently resolving empty config and skipping themselves every cycle.
2026-08-01 20:37:58 -04:00
Gmer4Lfe 3f5ad22632 Only apply --delete on merge-run when the pull pass completed
A capped or failed pull leaves the remote holding content the local never received, so
deleting against it destroys the only copy.
2026-08-01 20:37:58 -04:00
Gmer4Lfe ce580d9935 Remove dead rc.emhttp mapping from adapter.sh
No such rc.d service exists on Unraid (emhttpd isn't managed via a
standalone rc.emhttp script) and nothing in the codebase calls
platform_restart_service/platform_stop_service with 'emhttp' — confirmed
during a post-7.3.2-upgrade audit of all platform-specific paths.
2026-08-01 11:49:05 -04:00
Gmer4Lfe 3ae24939cc Fix array-stop event hook folder name (disks_unmounting -> unmounting_disks)
Unraid's real event name is unmounting_disks, not disks_unmounting — the
mismatch meant emhttp_event never found this hook, so array_stopping.sh
never ran on real array stop.
2026-08-01 11:41:39 -04:00
Gmer4Lfe 7162f16c1a Correct docs that placed lidarr_missing_art in the 4-hour tier when it runs daily 2026-07-28 18:51:13 -04:00
Gmer4Lfe 19a9f6410d Cache fanart.tv misses so the nightly art run stops re-querying artwork upstream has never had 2026-07-28 18:51:06 -04:00
Gmer4Lfe a1db8f7b2d Restore executable bit on the three arr cleanup scripts 2026-07-27 18:38:46 -04:00
Gmer4Lfe d680bd0549 Gate arr orphan deletion on ctime and stop the nightly permissions pass from restamping it — imports preserve the release's original mtime, so the age gate never actually fired for real content 2026-07-27 18:38:18 -04:00
Gmer4Lfe 3084546b32 Delete download orphans the arr cannot match instead of holding them forever — past the age gate an unnameable entry will never import, and clearing it lets a monitored title search for a copy the arr can parse 2026-07-27 17:38:49 -04:00
Gmer4Lfe 74ea1a5218 Run the download orphan cleaner daily — a weekly batch of newly-eligible orphans can exceed the delete cap, which aborts the whole pass and rolls the backlog into an even larger next run 2026-07-27 17:24:40 -04:00
Gmer4Lfe 7505bffcee Play state sync: replace per-item date-fork and PLAY_SYNC_DAYS window with jq epoch parsing and a response-hash change probe that skips the full comparison when nothing changed since the last run 2026-07-26 19:10:02 -04:00
Gmer4Lfe 0061ddef72 Update rsync docs: emby-fallback profile removed, Emby watch state now synced by play_state_sync.sh 2026-07-26 11:10:35 -04:00
Gmer4Lfe 19036621dc Remove dangling emby-fallback example from host critical-sync template 2026-07-26 10:57:09 -04:00
Gmer4Lfe d96ddc8649 Condense conf redundancy: drop dead emby-fallback profile, no-op PROFILE_RETRY_COUNT, and default-valued profile rows; compose MEDIA patterns and Radarr protected patterns by reference 2026-07-26 10:51:31 -04:00
Gmer4Lfe 895347f20f Add weekly download orphan cleaner — nothing covered the SAB Completed folders and 755G of orphans accumulated since 2022 2026-07-26 09:55:17 -04:00
Gmer4Lfe 5e92d9920a Add Radarr/Movies coverage to arr_corruption_scan.sh
Extends the Sonarr-only corruption scan into a generic per-arr loop (same
pattern as arr_full_rescan.sh) instead of a second script, since the
scan/strike/remediate logic is identical and only the API shape differs.
Radarr's moviefile list is fetched batched to include secondary/alternate-
edition files, not just each movie's primary file.

Also fixes two bugs found while testing: build_arr_path_map()'s internal
non-local `for key in ...` loop was clobbering the per-arr API key
variable, and arr_api()'s error output (stdout, not stderr) was getting
appended into the batch fetch file on any single failed call, corrupting
jq's parse of the whole batch and silently zeroing out that arr's results.
2026-07-21 18:05:26 -04:00
Gmer4Lfe b2d6e1bcfd Fix Emby/critical-data never restarting in weekly sync window
stop_local_containers() reads LOCAL_CRITICAL_CONTAINER_NAMES, which was
never set here — only rsync.sh sets it, in a child process that can't
propagate back. Local stop always no-op'd, so the rebuild-on-new-image
step never had anything to act on.
2026-07-21 16:50:38 -04:00
Gmer4Lfe 35f870c650 Auto-prune skip-list/intentional-stop entries for uninstalled containers
Neither remove_from_skip_list() nor clear_intentional_stop() can ever
fire for a container that's been fully uninstalled — both only trigger
on seeing it running again. Confirmed live 2026-07-19: Healarr sat on
the skip list nagging "manual intervention needed" every single cycle
for weeks after being removed. Now checks docker inspect for each
entry every cycle and drops it automatically if the container no
longer exists at all.
2026-07-19 17:22:35 -04:00
Gmer4Lfe de12c1fada Widen unraid-api key lookup timeout to stop false "renewed" alarms
Measured live: the apikey --name lookup takes 2.2-2.7s baseline with
the system idle, against a 5s timeout that left almost no margin.
Load spikes (confirmed correlated with resource_watchdog pressure
events in the same log window) pushed it over 5s, and the script
couldn't distinguish "timed out" from "genuinely missing" — it fell
through to the create path and logged a false renewal every time,
even though the on-disk key file's timestamp never actually changed.
Bumped to 15s for real headroom.
2026-07-19 17:20:09 -04:00
Gmer4Lfe ec518d4758 Prune stale chronic failure counts once an item stops being a problem
FAILURE_COUNTS persisted across runs but was never reset on success —
confirmed live 2026-07-19 that Sekirei S06E04 sat permanently flagged
chronic at count 4 despite already having hasFile=true. "Consecutive
failures" was really being measured as cumulative-for-all-time. Now
prunes any media_id's count at the end of each arr's pass if it no
longer appears in that run's problem-item set, so a resolved item's
history doesn't linger and falsely trip the circuit breaker later.
2026-07-19 17:00:15 -04:00
Gmer4Lfe 9425d16c19 Skip redundant restart for containers docker_update.sh already rebuilt
docker_update.sh rebuilds (stop+recreate) any container whose image
changed, in every mode — but for daily/weekly that was always followed
by the restart script's own unconditional pass, stopping and starting
the same container twice back to back. docker_update.sh now records
which containers it rebuilt this run to a file; docker_daily_restart.sh
and docker_weekly_restart.sh read it and skip those specifically,
still restarting everything else as before. A file older than
DOCKER_UPDATE_REBUILT_STALE_HOURS (default 12) is discarded rather
than trusted, so a missed or failed update run can't suppress a
restart indefinitely.
2026-07-19 16:09:55 -04:00
Gmer4Lfe 7252d4aad3 Fix try_smart_import() silently failing every ManualImport it fires
The candidate objects from /manualimport only nest ids under .series.id
and .movie.id, but Sonarr/Radarr's ManualImport command requires them
flattened to top-level seriesId/episodeIds or movieId — every call was
failing instantly with "Series/Movie with ID 0 does not exist" while the
caller only checked for HTTP 201 (accepted), so every "smart-imported"
this script has ever logged was actually a silent no-op. Now flattens
the ids before submitting and briefly polls the command afterward so a
fast failure falls through to the normal blocklist+research path instead
of being reported as success.
2026-07-19 10:36:37 -04:00
Gmer4Lfe 54919db13f Sync Sonarr/Radarr PROTECTED_PATTERNS template with live config
Template was missing theme.mkv/theme.mp4 and all trailer patterns,
including trailer-*.* for Trailarr's hyphenated TV trailer naming
(trailer-2.mkv) — a fresh install would hit the same false-positive
orphan bug just fixed live on prod.
2026-07-19 10:22:12 -04:00
Gmer4Lfe 2ce368abba Fix radarr_cleanup.sh missing secondary edition files as false-positive orphans
Radarr 6+ supports multiple tracked files per movie (alternate editions/extras),
but the movie list's embedded movieFile.path only ever reflects the primary one.
Relying on it alone flagged legitimately-tracked secondary edition files as
orphans. Now batch-fetches /moviefile?movieId=X across all tracked movies to
catch every file, not just the primary.
2026-07-19 10:04:47 -04:00
Gmer4Lfe 2a09529e75 Document expanded FFPROBE_PATH_MAP coverage pattern in host.conf.template 2026-07-18 23:28:09 -04:00
Gmer4Lfe efa2f2edf8 Schedule arr_corruption_scan.sh --remediate weekly in master.conf.template 2026-07-18 22:54:22 -04:00
Gmer4Lfe 004bde2c62 Add strike system to arr_corruption_scan.sh — require repeat corrupt detections before remediating 2026-07-18 22:50:19 -04:00
Gmer4Lfe e528d23c68 Add media-server auto-detection — pick Emby or Jellyfin by actual running state 2026-07-18 22:26:07 -04:00
Gmer4Lfe 43716a2c74 Fix trailers/ dir ownership+mode in trailer_folder_migration.sh 2026-07-18 21:43:21 -04:00
Gmer4Lfe a7bf45faaf Add trailer_folder_migration.sh — one-off tool to migrate TV trailers into trailers/ convention 2026-07-18 21:41:39 -04:00
Gmer4Lfe 77845829ad Add --filter to arr_corruption_scan.sh for targeted testing
Full-library scans are slow to reach any one show by chance — this lets a test
target a specific known-corrupt file/show without waiting on scan order.
2026-07-18 16:50:33 -04:00
Gmer4Lfe ab35fcd84d Add arr_corruption_scan.sh — sequential ffprobe-based corruption scan for Sonarr
Healarr does the same job but crashes on a Go concurrency bug (unsynchronized
map access) whenever multiple corruption events land close together — confirmed
via its own crash log, not fixable from our side. Processing one file at a time
here sidesteps the whole bug class instead of trying to work around it.

Delete + explicit EpisodeSearch rather than relying on Sonarr's own background
missing-search cycle, since that skips unmonitored episodes and this shouldn't.
2026-07-18 16:48:32 -04:00
Gmer4Lfe d5d1403ebf Raise Radarr cleanup safety threshold to 30GB
15GB was tripping on legitimate quality-upgrade orphan batches.
2026-07-18 12:05:21 -04:00
Gmer4Lfe 7bf47be106 Poll MoveMovie command to completion instead of trusting DB fields
Mirrors the Sonarr fix — same MoveMovieService one-at-a-time drain architecture,
never confirmed live on the Radarr side but the DB-instant/move-async split
is identical, so the same batch-verification race applies.
2026-07-17 23:00:08 -04:00
Gmer4Lfe 4f0cd1083a Poll MoveSeries command to completion instead of trusting DB fields
Confirmed live: DB fields flip instantly on the moveFiles=true PUT but the
physical move drains async, one command at a time — a batch could report
every series moved while most were still sitting at the old path.
2026-07-17 22:54:44 -04:00
Gmer4Lfe 1e1129687f Move arr_cache_prefill to intermediate tier, add classification scans to daily
arr_cache_prefill moved from critical (30min) to intermediate (4h) —
nothing in the critical tier reads this cache, and every actual
consumer already tolerates staleness well past 4h. Classification
scans run in daily, after the cleanup scripts (not before) — cleanup
frees disk space from orphans/junk first, so the searches --move
triggers for undownloaded relocated entries have headroom for the
resulting grabs.
2026-07-17 13:13:30 -04:00
Gmer4Lfe 79059c20fa Add --move mode to sonarr_classification_scan.sh, matching Radarr
Acts on forward misplacements and reverse-kids-leak (adult content in
the kids root — not a judgment call, same reasoning as the Radarr
side), moving to the new SONARR_GENERAL_ROOT config var. Reverse-anime
-leak stays report-only — deliberate style placements like Castlevania
are common and valid there. episodeFileCount is Sonarr's hasFile
equivalent: series with 0 files get relocated + an immediate
SeriesSearch instead of a file move. One series at a time, verified
after each — matches the race condition found doing this by hand for
the Fate/Zero and Fate/Stay Night moves earlier in the session.
2026-07-17 13:02:22 -04:00
Gmer4Lfe 9c05692a24 Include reverse-kids-leak in --move — it's not actually a judgment call
Unlike reverse-anime-leak (deliberate style placements like Castlevania
are common and valid there), reverse-kids-leak's signal is specifically
"adult certification with zero Family/Animation genre" — confirmed live
the 4 titles it catches (Addams Family, Saving Mr. Banks, Dark Shadows,
The DUFF) are all genuinely non-kids content. Moves them to the new
RADARR_GENERAL_ROOT config var (container-path literal for the general
Movies root, needed since RADARR_MOVIES_ROOT is a filesystem path).
2026-07-17 12:20:23 -04:00
Gmer4Lfe e688500cf5 Add --remove-junk mode: delete + import-exclude bad-metadata entries
Junk entries have no release to blocklist and no file to delete —
only a bad monitored record with a thin/wrong TMDb match. Removes the
record and adds it to Radarr's import exclusion list, same mechanism
radarr_tmdb_removed.sh already uses, so the same bad match can't get
re-added by a future Overseerr request or list sync.
2026-07-17 12:15:55 -04:00
Gmer4Lfe 6f7bb8a6aa Relocate + trigger search for hasFile=false forward-misplacements
Instead of skipping monitored-but-undownloaded titles entirely,
correct their DB pointer (rootFolderPath/path) now so Radarr saves
under the right root whenever it grabs a release, and fire an
immediate MoviesSearch rather than waiting for the next scheduled
one. Junk entries stay excluded — nothing to search for there.
2026-07-17 12:11:30 -04:00
Gmer4Lfe 1777c56efa Refresh shared tracked-data cache after --move writes to avoid a stale-cache window for other scripts 2026-07-17 12:08:10 -04:00
Gmer4Lfe ca6f16bafb Exclude hasFile=false entries from move targets — nothing to physically move 2026-07-17 12:02:57 -04:00
Gmer4Lfe 46ddc55345 Add --move mode to radarr_classification_scan.sh
Acts only on forward misplacements (clear-cut anime/kids classified
content sitting outside its dedicated root) — reverse leaks and junk
entries stay report-only, since those need human judgment or a
different action (removal, not a move). One movie at a time with a
real re-fetch-and-verify after each, matching the race condition found
doing this by hand for Sonarr earlier in the session.
2026-07-17 11:58:28 -04:00
Gmer4Lfe 10d6f372b3 Widen Sonarr reverse-kids-leak to non-US adult certification scales 2026-07-17 11:53:39 -04:00
Gmer4Lfe de57f87b82 Fix reverse_kids_leak losing genre data after object field reduction in jq 2026-07-17 11:51:30 -04:00
Gmer4Lfe 9dbcdc0bd7 Add anime/kids content misclassification detection for Radarr and Sonarr
Overseerr lets users request content into the wrong root folder; these
new report-only scans classify every tracked movie/series (anime, kids,
regular) from metadata alone and flag mismatches against the actual
root folder, in both directions. Rules were validated against real
library data before being adopted — see the header comments in each
script and the master.conf notes above the curated lists.
2026-07-17 11:49:23 -04:00
Gmer4Lfe 6dc5c91f60 Use find -printf instead of per-file stat/basename in transcode_cleanup.sh
Same fix as the arr cleanup scripts today: find -printf gets file size
directly from find's own stat() during the walk (mtime already handled
by -mmin), and the dry-run log line's basename call is replaced with
parameter expansion. Header notes this can matter under real load --
"thousands of HLS segment files" per the existing lsof design principle
this mirrors, even though the ramdisk is empty right now (no active
transcode session to benchmark against directly).
2026-07-17 02:13:07 -04:00
Gmer4Lfe 587fae1539 Replace external basename with parameter expansion in matches_pattern_list
This turned out to be the actual dominant cost left in the cleanup
scripts' classification loop, hidden behind the stat/dirname forks fixed
earlier today -- called once per non-tracked file (most files in a
media library, since every protected sidecar counts). Measured: 47.3s
vs 1.65s for 10,000 calls (~28.6x), identical results confirmed via
spot check before switching. Only used by lidarr_cleanup.sh/
sonarr_cleanup.sh/radarr_cleanup.sh.
2026-07-17 02:02:22 -04:00
Gmer4Lfe d2e071dece Use find -printf instead of per-file stat fork in cleanup scripts
lidarr_cleanup.sh/sonarr_cleanup.sh/radarr_cleanup.sh each forked a
separate stat call per file during classification. find already has to
stat() every entry to know it's -type f, so -printf '%s %T@ %p' gets
size+mtime for free during the walk itself. Measured: 5.77s for all
175,954 files in the Lidarr music root (walk + stat combined) vs 85.98s
for stat alone on a 20K-file subset of the same library -- roughly 130x
faster per file, and collapses two passes into one. Verified path
parsing preserves spaces/parens/unicode exactly via read's trailing-
field capture before switching.
2026-07-17 01:52:30 -04:00
Gmer4Lfe 072df6b153 Replace external dirname/basename with parameter expansion in hot loops
lidarr_missing_art.sh's album-directory-map loop (both the cache-hit and
live-fallback branches) and arr_profile_enforcer.sh's _is_kids_path()
called dirname/basename once per item -- 127K+ tracks and ~4000
series/movies respectively, each call forking a subprocess. Measured:
0.39s vs 72.2s for 20K calls, ~185x. Verified identical output against
real paths (including unicode/space/paren edge cases) before switching.
2026-07-17 01:39:21 -04:00
Gmer4Lfe 254b400caf Add missing RUNTIME MODES section, document --log
arr_cache_prefill.sh had no RUNTIME MODES section at all; arr_full_rescan.sh
had one but didn't mention --log despite supporting it via parse_args.
Comment-only.
2026-07-17 01:29:06 -04:00
Gmer4Lfe 976d8d84d7 Add OPERATIONAL SAFEGUARDS headers, skip prefill during active rescans
arr_full_rescan.sh, arr_cache_prefill.sh, and arr_rescan_monitor.sh were
missing the standard SAFEGUARDS header section other Arrs_Stack/Tools
scripts have. Also: arr_cache_prefill.sh now checks for an active rescan
before fetching, instead of doing a live fetch that arr_cache_write()
would just refuse to persist anyway -- avoids wasted API calls every
30min during a long rescan. arr_rescan_monitor.sh was also missing an
actual root check despite writing cache files; added it to match
convention rather than just document a safeguard that wasn't there.
2026-07-17 01:23:31 -04:00
Gmer4Lfe bac1ef1c17 Update headers on today's arr-caching changes
Comment-only. Headers on the scripts touched during today's caching work
(cache-first fetches, write-through per-item cache, single-walk
consolidation, movieFile-embedded fix) still described pre-change
behavior. Also brought common.sh's top-level cache doc block current --
it was written for the single-consumer 2026-07-16 state and didn't
mention the tmpfs move, the write guard, or the 15+ consumers that now
go through it.
2026-07-17 01:08:46 -04:00
Gmer4Lfe de2879bdee Add write-through cache for per-item track/episode data
lidarr_cleanup.sh and sonarr_cleanup.sh already walk every artist/series
individually (trackFile/episodefile) for their own cleanup decisions --
that walk now also writes the raw per-item data through to a short-lived
tmpfs cache (arr_item_cache_write, 4h freshness, no persistent backup
since every consumer already has its own live fallback). lidarr_missing_art.sh
runs later in the same nightly window and now reads that cache first,
skipping its own redundant per-artist walk entirely on a hit. Sonarr side
is write-through only for now -- no second consumer exists yet, but the
data's there for whenever one does. Future consumers: arr_get_cached_items()
first, live per-item fetch as fallback, same pattern as these two.
2026-07-17 00:55:14 -04:00
Gmer4Lfe a018245f40 Consolidate cleanup scripts' double filesystem walk into one
lidarr_cleanup.sh/sonarr_cleanup.sh/radarr_cleanup.sh each walked their
full media root twice per run: once to classify files and total the
deletion size for the safety-threshold check, then again to actually
delete. The size check needs to know the total before deleting anything,
not before knowing what to delete -- the classification pass now records
orphan/junk paths as it finds them, and the deletion pass just reads that
list instead of re-walking and re-classifying the whole tree again. Only
affects real (non-dry-run) executions, where the second walk used to
happen. Also merges two separate stat calls per file into one.
2026-07-17 00:37:31 -04:00
Gmer4Lfe a22433967a Eliminate radarr_cleanup.sh's per-movie API calls
Radarr's movie list already embeds movieFile.path on every hasFile=true
entry -- confirmed live, zero exceptions across the full library. The
separate moviefile?movieId=X call per movie (2896 of them) was fetching
data already sitting in the list this script fetches anyway. One live
list fetch replaces up to 2896 per-movie calls, every time this function
runs including rescan-aware retries.
2026-07-17 00:25:18 -04:00
Gmer4Lfe 9d27fac3b1 Move arr tracked-data cache to tmpfs, keep disk copy as persistent backup
Reads/writes now hit tmpfs (ARR_CACHE_DIR) instead of the array disk --
a full rebuild for all three arrs measures ~12s live, so there's no real
cost to losing it on reboot. The existing on-disk file becomes a backup
that arr_cache_write() keeps in sync on every write, and
arr_cache_age_seconds() transparently restores it into tmpfs the moment
it notices tmpfs is missing -- so a cache that was fresh before reboot
reads as fresh after too, closing the cold-start gap without needing a
dedicated restore step anywhere else.
2026-07-16 23:44:26 -04:00
Gmer4Lfe 5866097d6a Make all arr library-list fetches cache-first with live fallback
Every script that fetches the full Lidarr/Sonarr/Radarr tracked-library
list now goes through arr_get_tracked_data() instead of hitting the API
directly -- cache-first when fresh, live fetch as fallback when stale,
waits out an active rescan before either. Per-item file data (trackFile/
episodefile/moviefile) stays live-only everywhere, since that's the
actual disk-truth these scripts' decisions depend on and was never part
of what's cached.

Also adds arr_cache_prefill.sh to CRITICAL_MAINTENANCE_SCRIPTS (30min
tier) with a short 1min wait ceiling, so the cache stays consistently
fresh instead of only refreshing whenever some other script happens to
write through. A full cache refresh for all three arrs measured at ~12s
total live -- nothing like the multi-hour cost of an actual rescan.
2026-07-16 23:27:23 -04:00
Gmer4Lfe 2c3f0b9cb1 Guard arr cache writes against in-flight rescans, add rescan monitor tool
A direct arr_cache_write() call mid-rescan wrote a partial snapshot that
looked like real data loss to every consumer of the cache. The guard now
lives in arr_cache_write() itself so every caller is protected, not just
arr_get_tracked_data(). arr_rescan_monitor.sh closes the resulting gap for
rescans triggered outside arr_full_rescan.sh's own trigger-and-wait path.
2026-07-16 22:57:54 -04:00
Gmer4Lfe fb13958881 Add weekly full-library rescan job for Lidarr/Sonarr/Radarr
Organic scans only touch files actually involved in an import — an
artist/series/movie that already has files sitting untouched on disk
never gets its tracked-file stats refreshed on its own. Confirmed
2026-07-16: Lidarr reported ~23% of its true trackFileCount with no
scan running, for artists whose files were verified present and
readable the whole time. Downstream scripts trust these stats as
source of truth for the share, so drift needs to be caught before
someone notices a suspiciously low number.
2026-07-16 15:57:12 -04:00
Gmer4Lfe c7ecf99c3f Generalize tracked-data cache from Lidarr-only to all three arrs
Shared cache/rescan-duration logic in common.sh now takes an arr_type
param instead of being Lidarr-specific, so Sonarr and Radarr cleanup
scripts get the same cache-first fetch + rescan-aware retry Lidarr had.
Avoids redundant full-library API calls across scripts run back to back,
and stops false failures when a fetch lands mid-rescan.
2026-07-16 15:34:56 -04:00
Gmer4Lfe 62bb166f9a Lower RSYNC_MAX_RUNTIME_HOURS from 23 to 19
Daily maintenance jobs alone now take ~4h with rsync disabled. 19h cap
leaves enough room in the 24h cycle for maintenance to still run
same-day before the next 1am fire, even if a share hits the cap.
2026-07-16 14:06:22 -04:00
Gmer4Lfe 59cee06f45 Add Lidarr tracked-data cache + duplicate artist cleanup
Shared cache (lidarr_get_tracked_data() in common.sh) so scripts stop
hitting Lidarr's live API for tracked counts every run, and stop
treating a mid-rescan dip as a genuine problem — a whole-library
RescanFolders legitimately makes trackFileCount read far below normal
while it re-verifies every file (confirmed 2026-07-16: 22% of normal
mid-scan). Cache reads fresh-if-recent, waits out an active rescan
(calibrated to that command's own historical duration, tracked per
command name since RescanFolders and DownloadedAlbumsScan take wildly
different amounts of time), then falls back to a stale cache rather
than hard-failing after a few strikes.

lidarr_cleanup.sh: no longer stacks a fresh DownloadedAlbumsScan on
top of one already running, and the tracked-count floor check now
waits out a genuine rescan instead of aborting on every overlap.

lidarr_duplicate_artist_cleanup.sh (new): finds case-insensitive
duplicate artist entries — same display name, different MusicBrainz
ID, added when a search/list-sync matches the wrong same-named artist.
Deletes the empty phantom side and blocks it from Import List
Exclusions, leaves genuinely-different-real-artists alone (checked by
album title overlap, deduped per-artist first so a legitimate reissue
under an artist's own catalog doesn't false-flag as cross-artist
overlap), and only notifies for the rare case where both sides have
real, overlapping content.

lidarr_cache_prefill.sh (new): warms the cache at array start so nothing
reads it cold after boot.

lidarr_missing_art.sh, lidarr_release_fixer.sh: write-through the cache
as a side effect of fetches they already needed for their own purposes.
2026-07-16 14:02:35 -04:00
Gmer4Lfe 8bdf7eeb9c Add smart-import decision for importBlocked items before blocklisting
Most importBlocked downloads are junk/duplicates and still fall straight
through to blocklist+research unchanged. But some are releases arr already
correctly parsed (episode/movie, quality, language all known) that just
trip the title-vs-grab-history safety net — those get imported directly
if the target has no file yet, or the candidate is a same-language
resolution upgrade over what's already there. Sonarr/Radarr only; Lidarr's
manual-import matching doesn't reliably resolve album/track context.

Gated by ARR_SMART_IMPORT_ENABLED (default true) and
ARR_SMART_IMPORT_PREFERRED_LANGUAGE (default English).
2026-07-15 17:30:18 -04:00
Gmer4Lfe f30863f452 Paginate get_queue_data() so the recovery script actually sees the whole queue
page=1&pageSize=200 silently truncated anything past record 200. Sonarr's
queue currently runs 1700+ during a large search campaign, which pushed
every importBlocked/warning item past page 1 — the script logged 'clean'
every run while 52 stuck imports sat completely unseen despite yesterday's
importBlocked fix matching them correctly once actually queried.
2026-07-15 16:48:53 -04:00
Gmer4Lfe f75957130e Cap merge-pass pull with the same 23h timeout as the main push
The pull step (merge mode) had no timeout or bandwidth limit at all,
so a slow/stalled pull could block the entire per-share sync
indefinitely — and since daily_sync_maintenance.sh calls rsync.sh
synchronously per share, that blocked every later share and all
post-sync maintenance jobs too. RSYNC_MAX_RUNTIME_HOURS was only ever
wired into the push half.
2026-07-14 18:29:53 -04:00
Gmer4Lfe ed4d194332 Catch importBlocked in arrs_failed_stalled_recovery.sh
Recovery script only matched importFailed/importPending/error/stalled, so
releases matched to the wrong media by grab-history ID (Sonarr/Radarr's
importBlocked state) sat forever, invisible to automated cleanup.
2026-07-14 18:12:38 -04:00
Gmer4Lfe 958391e326 Add Emby/Jellyfin deep API health checks to docker_watchdog.sh
Both had a basic HTTP check (Emby) or no coverage at all (Jellyfin), but
neither would have caught today's real incident: Jellyfin's SQLite
database locked up hard (repeated 'database table is locked' errors,
30s+ query timeouts) while its own /System/Info endpoint kept responding
200 the whole time — a basic HTTP check on that endpoint would never have
tripped. /Users forces an actual DB round-trip and was confirmed live to
hang during the exact incident.

Generalized the API check's success condition to also accept array-shaped
responses (/Users returns an array; the existing check only recognized
object fields like .ServerName/.Id/.Version, which would error when
applied to an array) — benefits any future array-returning endpoint, not
just this one. Also corrected the host.conf.template's API_CHECKS format
comment, which described a 3-field format the code never actually used.
2026-07-11 18:23:00 -04:00
Gmer4Lfe 2eb595552d Fix Unicode dash mismatch in Lidarr discovery dedup check
blink-182 kept getting rescored and re-added every week despite already
being in the library (id=155). Root cause: MusicBrainz's canonical name
is 'blink‐182' using a Unicode hyphen (U+2010), while Last.fm's candidate
list returns the plain ASCII hyphen — the exact-string _in_lidarr() /
_in_emby_library() checks never matched, so it was treated as a new
artist every run, scored, accepted, and its add attempt correctly failed
against Lidarr's duplicate-MBID rejection.

Added _normalize_dashes() to collapse Unicode hyphen/dash variants (U+2010
through U+2014) to ASCII '-' before comparing, applied to both the stored
library name lists and each candidate name at match time. Fixes this for
any artist with a stylized dash in their canonical name, not just this one.
2026-07-11 18:03:32 -04:00
Gmer4Lfe c3562c2d0b Fix false array-start failure for start_webhook_listener.sh
No 'already running' guard existed — a relaunch (array stop/start that
doesn't kill the old node process) would exec straight into node, hit
EADDRINUSE on the port, and exit 1 within ~1s. array_started.sh correctly
reported that as a failure, even though the prior listener instance was
still healthy and serving webhooks the whole time. Confirmed live: the
node process from 2026-06-23 (PID 25977) is still running today, and the
2026-07-03 array start logged this exact false failure.

Added acquire_lock "continuous" before the exec — an existing, documented
common.sh mode (skip gracefully if a healthy instance is running) that
wasn't actually used anywhere in the codebase yet.
2026-07-11 17:55:51 -04:00
Gmer4Lfe 1574db3eab Add circuit breaker to arrs_failed_stalled_recovery.sh
Some items (e.g. an album missing 1-2 tracks where no available release
matches the existing edition/track count) can never resolve via blind
retry. Without a limit, the same media ID gets blocklisted and re-searched
every 4 hours forever — confirmed live on ~19 Lidarr albums cycling
identically across five consecutive runs today, each one downloading a
fresh release, failing import for the same structural reason, and
starting over.

Tracks consecutive failures per (arr_type, media_id) in a persisted state
file. After ARR_RECOVERY_MAX_ATTEMPTS (default 3) failures, the item is
still blocklisted and removed from the queue, but auto re-search stops —
notified once when it crosses the threshold, then left for manual review
instead of retried forever.
2026-07-11 17:39:37 -04:00
Gmer4Lfe ba07634a88 Replace system free-RAM warning with ARC headroom check
The memory warning used plain system-wide 'free' RAM (via free -g), a
carryover from before this script was ZFS-specific. ZFS ARC deliberately
consumes most otherwise-unused RAM, so 'free' being low is normal and not
a meaningful signal — it fired a false alarm on 2026-07-05 (free 5.8Gi,
but available a healthy 45Gi).

Replaced ZFS_REPORT_FREE_WARN_GB with ZFS_REPORT_ARC_FREE_WARN_GB: warns
when ARC headroom (ARC_MAX - ARC_CURRENT) drops below threshold, which is
what actually indicates ARC is running out of room to grow. Available RAM
check is unchanged — it's a legitimate system-pressure signal on its own.
2026-07-11 17:27:59 -04:00
Gmer4Lfe 85c3aef1b0 Fix rsync.sh global lock bug and add a max-runtime cap
acquire_lock (no args) ran before profile inference, so every rsync.sh
invocation — regardless of share — fought over one generic, unparameterized
lock. The per-profile acquire_rsync_lock() further down (with
RSYNC_MAX_CONCURRENT) never got a chance to matter: a single slow transfer
(e.g. Movies during the HOST2 rebuild) monopolized the lock and starved
every other profile, including Critical-Data's 30-minute sync, for days.

Removed the generic acquire_lock call; acquire_rsync_lock "$PROFILE_NAME"
already provides correct per-profile locking on its own.

Also added RSYNC_MAX_RUNTIME_HOURS (default 23): any single transfer
attempt exceeding it is terminated via timeout, logged as paused rather
than failed, and resumes from where it left off next scheduled run
(safe because --partial is already in DEFAULT_RSYNC_OPTS). Bounds the
worst case for one huge/stuck share instead of letting it hold its lock
indefinitely.
2026-07-11 17:10:37 -04:00
Gmer4Lfe 35d7909828 Downgrade watchdog heartbeat to log level
Was logged as a warning every heartbeat interval; it's routine, not a warning.
2026-07-11 17:03:38 -04:00
Gmer4Lfe 0fb901e275 Enable lidarr_release_fixer/lidarr_cleanup/sonarr_cleanup in daily maintenance template
Already enabled in the live config; template was out of date.
2026-07-11 17:03:38 -04:00
Gmer4Lfe 1d9907e0cf Fix Lidarr JSON path in lidarr_missing_art.sh
Cover/cdart/back art lookups used .[].field instead of .albums[].field,
so the fetch always returned empty.
2026-07-11 17:03:38 -04:00
Gmer4Lfe 855bc53623 Untrack Configurations/master.conf
Contains live credentials; was tracked despite being gitignored.
Pre-work for the GitHub mirror fallback in git_pull_execute.sh.
2026-07-11 17:03:38 -04:00
Gmer4Lfe 7fa0adbda4 Fix external-link warning on the Partnership tab's web terminal link
Same root cause as the tab-navigation fix (a6fe820): the "Open Terminal"
link builds its href from window.location.hostname (the same server the
plugin is already running on) but Unraid's global external-link guard
still flags it, since it's a fully-qualified URL the guard hasn't seen
allowed before. Added class="localURL", the same escape hatch used for
the tab bar and dynamix's own pages.
2026-07-05 00:00:16 -04:00
Gmer4Lfe 084220692f Add unambiguous prefix-match fallback for NetBIOS-truncated hostnames
Unraid truncates the Server Name to 15 chars. Depending on which form
ends up in master.conf's HOST* value (the truncated OS hostname, or a
longer name matching what Tailscale independently registered for that
peer), either host-identity detection or Tailscale IP resolution could
fail — hit live on HOST2 in both directions this session.

- common.sh detect_hosts(): was case-sensitive exact match only, with no
  fallback and an exit 1 on failure — meaning every orchestrator/watchdog/
  rsync script would hard-fail on a truncated-hostname host, not just the
  web UI. Now case-insensitive, with a prefix-match fallback when the
  local hostname is exactly 15 chars.
- common.sh resolve_tailscale_ip(): already did a bare regex prefix match
  with zero ambiguity handling (pre-existing risk: e.g. server1/server10
  could collide). Replaced with an explicit unambiguous-only check.
- config.php vv_detect_host() / vv_resolve_tailscale_ip(): same treatment,
  kept as close a mirror of the bash logic as PHP allows.

All fallbacks require an EXACT prefix match (never fuzzy/percentage
similarity — considered and rejected, since names like server1/server2/
server3 would be dangerously similar under any generic similarity metric)
and require exactly one candidate to qualify; ambiguous matches are
treated as no match rather than guessed. Verified live against HOST1/
HOST2 in both master.conf configurations (short and long HOST2 value).
2026-07-04 23:49:44 -04:00
Gmer4Lfe abfbaa7f47 Fix host identity detection for NetBIOS-truncated hostnames
Unraid truncates the Server Name to 15 chars (NetBIOS limit). HOST2's
real hostname is "unRAID-Jayred36" but master.conf's HOST2 (matching
what Tailscale shows for this peer, since resolve_tailscale_ip() keys
off the same value) is the untruncated "unRAID-Jayred365" — confirmed
live, Tailscale's own Self.HostName on that machine is truncated too.

vv_detect_host() did a strict case-insensitive match against the bare
`hostname -s` output with no tolerance for this, so it always returned
'unknown' on HOST2. That silently broke the first-run wizard (Varaverk.page
explicitly excludes 'unknown' from the "needs setup" check) even though
host2.conf never existed, plus vv_partner_state() and vv_fallback_active()
in monitor.php which independently reimplemented the same hostname
comparison instead of calling vv_detect_host().

Fix: vv_detect_host() falls back to a prefix match when the local hostname
is exactly 15 chars; vv_partner_state()/vv_fallback_active() now call
vv_detect_host() instead of duplicating the comparison. Verified live on
HOST2 — vv_detect_host() now returns 'host2', partner state correctly
flags HOST2 as is_me, and the wizard-trigger condition now evaluates true.
2026-07-04 23:28:54 -04:00
Gmer4Lfe 3d7b15d6bb Fix vv_push_master_conf() — remote command was expanding locally, not on the remote host
The readiness probe wrapped the remote command in raw double quotes with
manually backslash-escaped inner quotes. shell_exec() runs its command
through an extra local `sh -c` layer beyond the ssh invocation itself, and
because the remote command was double-quoted (not single-quoted/opaque),
that extra local layer expanded the $(...)/${...} substitutions using
HOST1's own environment before ssh ever sent anything to the remote host.
Confirmed live: the exact same command run directly (one shell layer)
returned the correct remote SCRIPTS_DIR; run through an extra sh -c layer
(matching shell_exec's real behavior) it silently evaluated everything
against HOST1's local varaverk.cfg instead, producing an empty probe result
every time — so every push silently reported "plugin not installed" even
though HOST2 was fully installed and reachable.

Fix: build the remote command as a plain string and escapeshellarg() it as
a whole, same pattern vv_pt_ssh() already used safely elsewhere. Verified
live — probe now returns HOST2's real SCRIPTS_DIR and the master.conf push
lands with a matching checksum on both hosts.
2026-07-04 23:19:42 -04:00
Gmer4Lfe 223d5ebfc9 Fix GITEA_DOMAIN value — was set with an https:// scheme prefix
git_pull_execute.sh, varaverk.plg, and gitea_ssh_setup.sh all treat
GITEA_DOMAIN as a bare hostname (gitea_ssh_setup.sh even builds its own
https:// API URL from it). The live value on HOST1 had the scheme
included, which would build a malformed SSH URL
(ssh://git@https://git.gmer4lfe.com:221/...) — hadn't been hit yet since
HOST2's install succeeded via Tailscale peer detection before ever
reaching this fallback tier.
2026-07-04 23:10:05 -04:00
Gmer4Lfe a6fe820a61 Fix external-link warning on tab navigation
Tab links use relative query-string hrefs (?tab=scheduler), which fail
every check in Unraid's global external-link click-guard (BodyInlineJS.php):
not a valid absolute URL, doesn't start with "/", doesn't match a
registered plugin page basename. Confirmed live — the guard's dom.hostname
ends up undefined for these, matching the reported "Always Allow undefined"
dialog text exactly.

Fix: add class="localURL", the same escape hatch dynamix's own pages
(ManagementAccess.page) use for this exact situation. Applied to the main
tab bar and the setup wizard's checklist action links (?tab=partnership).
2026-07-04 23:05:47 -04:00
Gmer4Lfe 7e4db7b504 Fix stale plugin docs and dead links
- unraid_api.php comment still told you to run Deployment/deploy.sh,
  deleted a while back
- VaraverkSettings.page linked to a CHANGELOG.md that doesn't exist
  anywhere in the repo
- Manual-Plugin.md / README-Plugin.md described manually heredoc-ing the
  .plg locally and a .txz-based install that isn't wired up — rewritten
  to describe the actual install flow (raw .plg URL via Plugins ->
  Install Plugin), with plugin_setup.sh's dev-symlink role called out
  separately from that install path
2026-07-04 23:00:57 -04:00
Gmer4Lfe f7fa75fdfb Fix dead/incorrect vars in Plugin/ found during full codebase audit
- WEBGUI_PHP_WAIT was referenced by webgui_watchdog.sh but never defined
  in master.conf, always silently falling back to a hardcoded default
- arrs.php/confform.php still pointed at Media/ for arr cleanup/discovery
  scripts moved to Arrs_Stack/ in b4bc926 — broke the Arrs page's stats
  and the per-script settings editor for those scripts
- docker_folders.php read directly from the optional folder.view3 plugin's
  file instead of Varaverk's own docker_folders.json (the primary store
  since the Docker tab got its own config) — left the Monitor page's
  Docker Folders widget empty on any host without folder.view3 installed
- vv_wd_remote_data() read remote watchdog state files from hardcoded
  /tmp or /boot/config paths instead of the remote's actual STATE_DIR
  (which resolves dynamically and can differ under flash mode) — remote
  node's Watchdog panel was always empty; same wrong path also used for
  two local reads (system_watchdog_oom.db, watchdog_appdata_growth.db)
- rsync.php referenced a {HOST}_MONTHLY_SYNC_SHARES conf var that never
  existed (monthly_maintenance.sh has no rsync section) — nulled out to
  match the existing pattern used for the fallback window
- vv_arr_node_names() did a pointless identity array_map
- vv_dk_webui() had its own duplicate local-IP resolution instead of
  using vv_local_ip(), despite config.php's comment claiming that exact
  duplication was already consolidated
2026-07-04 23:00:26 -04:00
Gmer4Lfe 0581c7b2f4 Fix Gitea SSH probe still failing after info-arg fix — stderr was discarded
Gitea's SSH rejection banner (what the grep matches on) is written to
stderr, but the probe piped stdout only via 2>/dev/null — so the grep
never saw it, on any peer, even after dropping the bogus 'info' arg in
be6c16d. Confirmed live on HOST2: the exact probe command against HOST1's
Gitea prints the banner correctly with 2>&1, empty with 2>/dev/null.

Also corrects the CHANGES entry, which claimed a .txz-based install that
was never wired up — actual distribution is git-clone + symlink, not
packaged.
2026-07-04 22:53:41 -04:00
Gmer4Lfe be6c16d74c Fix Gitea SSH probe in plugin installer — drop bogus 'info' command
The probe ran 'ssh git@$ip info' expecting Gitea's banner in the response,
but 'info' is a Gitolite convention Gitea doesn't implement — it returns
'error: Too few arguments' instead, which never matched the grep, so a
real reachable Gitea host was never detected. Confirmed on HOST2: connecting
with no trailing command returns Gitea's actual banner correctly.
2026-07-04 01:03:29 -04:00
Gmer4Lfe c2597cb516 Fix Tailscale peer discovery in plugin installer — drop python3 dependency
python3 isn't installed on stock Unraid (confirmed absent on HOST1), so the
JSON-parsing pipeline silently produced zero peers every time, making the
Tailscale fallback tier a permanent no-op since it was written. Replaced with
plain 'tailscale status' + awk, matching the pattern common.sh already uses.
2026-07-04 00:39:58 -04:00
Gmer4Lfe 997f9d9117 Add GITEA_DOMAIN fallback tier to plugin installer
Local container and Tailscale peer probe both failing (as happened on HOST2)
left GitHub as the only fallback, which requires the repo to be public. Adds
a domain-based Gitea path in between, matching git_pull_execute.sh.
2026-07-04 00:28:50 -04:00
Gmer4Lfe 6623d1e776 Fix dead/incorrect vars and consolidate duplicated logic into common.sh
Codebase-wide audit pass: fixed real bugs (SSH hangs missing BatchMode,
local-outside-function no-ops, variable name collisions, a truncated
ratio calc, wrong state-dir path, DARK vs NO_INTERNET drift, and more),
then pulled logic that was duplicated across multiple scripts — arr
cleanup safety gates, docker restart ordering, container maintenance
stop/restart, watchdog state-file helpers, partnership role resolution,
cert expiry checks, remote node discovery, and TMDB discovery scoring —
into common.sh so each now has a single implementation.
2026-07-03 23:52:33 -04:00
Gmer4Lfe ef3980cf07 Stop tracking Notes_To-Do.md — personal scratch notes, dev-only
Keeps the file locally for day-to-day use but out of the shared repo.
2026-07-03 23:52:12 -04:00
Gmer4Lfe 260f0a61ce Wire up unused config vars found in follow-up audit pass
TRANSCODE_LOG_RETENTION was documented as trimming the daily transcode log
but never referenced — log grew unbounded. TRANSCODE_ORPHAN_AGE was shown in
--status but never used, so empty session folders were deleted immediately
instead of after the documented grace period, risking a race against ffmpeg
creating a folder just before writing its first segment.

docker_watchdog.sh's daemon-health thresholds were only hardcoded fallbacks
despite comments claiming they were master.conf-configurable, and it
referenced a heartbeat feature that was never implemented (that's owned by
watchdog_orchestrator.sh, its caller) — added the three thresholds to
master.conf for real and removed the stale heartbeat claim.

Also added the missing HOSTN_PARTNERSHIP_SERVICES_STACK block to
host.conf.template (containers.sh already read it via detect_hosts, just
never had a template entry) and corrected play_state_sync.sh's doc comment
for PLAY_SYNC_TYPES' actual default.
2026-07-03 17:45:20 -04:00
Gmer4Lfe 69189bbf18 Fix dead-variable and exit-code bugs found in codebase-wide audit
Same audit as the orchestrator standardization pass (2a062e5), extended to
every remaining script. Found the same class of bug independently recurring:
ramdisk_stop.sh checked $LOG (nothing assigns it, should be $ENABLE_LOGGING),
partnership_onboard.sh checked $LOG_MODE (same issue), emby_session_report.sh
checked $TRANSCODE_PCT which was never computed so the high-transcode alert
could never fire, and storage_migrate.sh never called detect_hosts() so
$MY_ID was empty, silently breaking the post-migration host*.conf update.
partnership_manager.sh used `local` at top-level script scope (invalid outside
a function) and had two master.conf path references missing "Configurations/".

Along the way: several scripts (share_setup.sh, conf_sync.sh,
downloaders_reset.sh, transcode_cleanup.sh, transcode_manager.sh,
remote_arr_cache_writer.sh, upgrade_webhook_handler.sh) had no explicit
trailing exit code, so they always reported success regardless of real
failures. play_state_sync.sh was missing the partnership gate its own header
documented, so remote play-state sync ran even with PARTNERSHIP_ENABLED=false;
it also always exited 0 on sync errors. arr_profile_enforcer.sh and
webhook_setup.sh hand-rolled their own flag parsing instead of common.sh's
parse_args, so --log silently did nothing on either.

system_watchdog.sh was itself an un-standardized mini-orchestrator — converted
to the shared run_orch_child()/JOB_PASS/JOB_FAIL pattern, added the missing
failure notification, and fixed dry-run to pass --dry-run down to children
instead of skipping them outright. Also fixed a stale webgui_watchdog.sh path
in master.conf.template that would break system_watchdog.sh on any fresh
install.

Closed a sibling-drift gap: radarr_cleanup.sh and sonarr_cleanup.sh were
missing lidarr_cleanup.sh's tracked-count percentage-drop safety gate and its
"not configured on this host, skip cleanly" guard — both now match Lidarr's
7-gate model.
2026-07-03 17:35:30 -04:00
Gmer4Lfe eb8c6bb1be Add follow-up notes; fix README wording; drop stale User Scripts readme 2026-07-03 11:01:08 -04:00
Gmer4Lfe 2a062e5140 Standardize orchestrator child-script execution and logging
Every orchestrator invoked its children differently — four near-duplicate
run_job() copies, a differently-shaped run_watchdog(), or plain inline bash
calls, each with its own take on path resolution, pass/fail naming, and
dry-run threading. Extracted one shared run_orch_child() into common.sh so
there's a single place to fix or extend this behavior going forward.

Along the way: watchdog_orchestrator.sh and monthly_maintenance.sh were
checking $VERBOSE, a variable nothing in the codebase ever assigns, so --log
silently did nothing beyond basic logging on those two. Fixed to
$ENABLE_LOGGING. watchdog_orchestrator.sh and array_started.sh had no
trailing exit, so their exit codes reflected whatever the last command
happened to return rather than actual success/failure. transcode_management.sh
had no failure notification and no summary at all. Also made
transcode_management.sh's two-script pipeline config-driven
(TRANSCODE_MANAGEMENT_SCRIPTS in master.conf) instead of hardcoded, for room
to extend it later without editing the orchestrator itself.
2026-07-03 10:57:52 -04:00
Gmer4Lfe 6fd22ae4ee Move Custom Scripts out of the repo and add an Import Script picker
Custom Scripts (the Scheduler page's inline editor) used to save into the
git-tracked Custom/ folder, so anything saved there would end up on GitHub.
They now live in /boot/config/plugins/user.scripts/Varaverk/Scripts, same
folder family as Unraid's own User Scripts plugin. Import Script lets you
browse the whole server and move an existing script in instead of only
creating new ones inline — always a move, never a copy, so no stray
duplicate is left where it came from.
2026-07-03 10:57:35 -04:00
Gmer4Lfe 93aa134aaa Drop claude_startup from ARRAY_START_SCRIPTS — Claude persistence is standalone now 2026-07-03 08:25:39 -04:00
Gmer4Lfe 14ecf0c08a Scrub remaining Claude references from docs 2026-07-03 08:25:09 -04:00
Gmer4Lfe 5d5b60a8ed Remove Claude tooling from repo — personal setup moved to standalone /boot/config/claude_startup.sh 2026-07-03 08:23:28 -04:00
Gmer4Lfe 5d32f58f5d fix boot device detection failing on ZFS /boot
findmnt returns 'flash/boot' for a ZFS dataset, not a /dev/* path.
lsblk -no pkname then fails, leaving transport as 'unknown'. Resolve
ZFS pools to a backing device via zpool before checking transport.
2026-06-27 23:18:36 -04:00
Gmer4Lfe b314aa4aff fix monitor disk used space underreported for spun-down disks
Unraid API returns fsUsed=0 when a disk's filesystem is unmounted (spun
down). disks.ini keeps the last-known value in KB even after spindown —
use it as fallback when isSpinning=false and fsUsed=0. Also remove the
mounted-only guard in the ini fallback path (vv_disk_entry) for the same
reason. Stale comments in user_script_plug-in.sh and partnership_manager.sh
also cleaned up.
2026-06-27 23:08:51 -04:00
Gmer4Lfe 75f2a4e3fd docker_watchdog: skip required containers stopped cleanly or explicitly paused
Exit code 0 on a required container (docker stop, UI stop) now reads as
intentional — no strike, no restart. Non-zero exits still trigger the
existing strike → restart path.

Adds --pause / --resume management commands and a persistent intentional-
stops state file for maintenance windows where even the exit-code heuristic
isn't enough. Containers auto-cleared from the list when seen running again.
2026-06-27 19:00:59 -04:00
Gmer4Lfe cf180c1179 Split Media/ docs into Media/ and Arrs_Stack/ to match folder reorganization
Media README and Manual now cover only the 3 remaining scripts (permissions, cleaner,
play_state_sync). Arrs_Stack README and Manual cover all arr stack scripts including
lidarr_release_fixer. Fixed stale --skip-strike-list reference in flag docs.
2026-06-27 18:49:12 -04:00
Gmer4Lfe b4bc9267e9 Move arr stack scripts from Media/ to Arrs_Stack/
Media/ now holds only media-level scripts (cleaner, permissions, play_state_sync).
All arr management scripts (cleanup, discovery, sync, webhooks, release fixer) live in Arrs_Stack/.
2026-06-27 18:39:33 -04:00
Gmer4Lfe 99b2d1879b Rename --skip-strike-list to --skip-age-check in sonarr/radarr cleanup — same stale naming as lidarr 2026-06-27 18:25:27 -04:00
Gmer4Lfe aa8698ddff Rename --skip-strike-list to --skip-age-check — strike system was removed, flag name was stale 2026-06-27 18:23:57 -04:00
304 changed files with 64139 additions and 10467 deletions
+24 -6
View File
@@ -3,12 +3,21 @@
# Templates (*.template) are safe and remain tracked.
Configurations/host*.conf
Configurations/master.conf
Configurations/*.bak
.claude
# *.bak alone missed conf_upgrade's real output — it writes host1.conf.bak-20260802, which does
# not end in .bak — so those sat untracked rather than ignored, one `git add -A` from being
# pushed. The glob has to cover the suffix.
Configurations/*.bak*
.vscode
# ── Personal scratch notes — dev-only, never pushed ───────────────────────────
Notes_To-Do.md
# ── Runtime state, data, logs ─────────────────────────────────────────────────
data/
# Contents, not the directory itself. Ignoring "data/" outright means git never descends into
# it, and a negation for a file inside an excluded directory is silently ineffective — so the
# README explaining what data/ is would be the one file missing from every installation of it.
data/*
!data/README.md
State_Files/
.cache/
*.log
@@ -28,12 +37,21 @@ varaverk-*.txz
# .txz packages are attached to GitHub releases, not committed to the repo.
Plugin/dist/
# ── Claude Code installation (lives alongside repo on flash, not source) ──────
claude-bin/
claude-data/
# ── OS / editor ───────────────────────────────────────────────────────────────
.DS_Store
*.swp
*~
.vscode/
# ── Local-only plugin surfaces (per-installation, never pushed) ───────────────
# Varaverk.page discovers pages/local/*.php and registers each as a tab; api/local/ holds their
# endpoints. Both are symlinks into a store outside this repo, so what they contain belongs to
# one installation and is not part of the project. The tracked loader is deliberately generic —
# it names no page — so the public mirror never learns what a given server runs here.
#
# No trailing slash on either pattern. These paths are symlinks, not directories, and git treats
# a symlink as a blob — a "dir/" pattern does not match one, so the entries sat untracked rather
# than ignored, which is the same near-miss the *.bak rule above documents.
Plugin/unraid/pages/local
Plugin/unraid/api/local
+4
View File
@@ -0,0 +1,4 @@
# The index is generated, host-specific, and regenerable in minutes. Never commit it.
*.db
*.db-wal
*.db-shm
+370
View File
@@ -0,0 +1,370 @@
# ━━━━━ AI ━━━━━
Retrieval over Varaverk's own documentation. Ask the system a question about itself and get an
answer grounded in its actual headers, READMEs, Manuals and conf templates — with sources.
Everything here is **off by default and optional**. Varaverk works exactly as well with
`AI_ENABLED=false` as with it true. Nothing in the ecosystem depends on this folder.
| File | Role |
|------|------|
| `ai_index.sh` | Build / refresh the retrieval index |
| `ai_query.sh` | Ask a question, or search the index directly |
| `lib/chunk.js` | Split repo files into retrieval units |
| `lib/index.js` | Embed chunks, store vectors in SQLite |
| `lib/search.js` | Embed a query, score it, rank results |
| `lib/cli.js` | Argument bridge the bash entry points call |
---
## ━━━ WHY THIS WORKS AT ALL ━━━
**Because the header audit came first.**
The single worst failure in naive retrieval is a chunk that contains half of one idea and half
of another — a fixed-size window cutting mid-thought, embedding two unrelated things as one
vector. That problem does not exist here, because every script in the repo carries the same six
sections at exact, greppable boundaries:
```
PURPOSE → OPERATIONAL MODEL → DESIGN PRINCIPLES → OPERATIONAL SAFEGUARDS
→ CONFIGURATION → RUNTIME MODES
```
Split on those and every chunk is a coherent unit by construction. No token windows, no overlap
heuristics, no tuning.
**And because the headers say *why*.** A model can read `mover_stop.sh` and describe what it
does. It cannot look at that code and know the cache writers are lockless *on purpose*, or that
`removeCompletedDownloads` being true on both arrs is intentional. Those live in
`DESIGN PRINCIPLES` and `OPERATIONAL SAFEGUARDS`, which is precisely what makes this index worth
more than an equivalent pile of source.
---
## ━━━ SECTION ROUTING ━━━
Every chunk stores its section name as its own column, so a question's *shape* can steer
retrieval before similarity is even considered:
| Question shape | Steered toward |
|----------------|----------------|
| "what stops X and Y overlapping" | `OPERATIONAL SAFEGUARDS` |
| "which variable controls X" | `CONFIGURATION` |
| "does this take --dry-run" | `RUNTIME MODES` |
| "why is it built this way" | `DESIGN PRINCIPLES` |
| "how does X work" | `OPERATIONAL MODEL` |
Applied as a **score boost, not a filter**. Intent detection is a heuristic, and a heuristic
must never be able to exclude the one chunk that holds the answer. `--section=NAME` forces a
hard filter when you actually want one.
The boost still has a blind spot worth knowing about: **definitional questions.** "What is
Varaverk?" matches the `PURPOSE` intent, so every script's one-line PURPOSE gets boosted above
the top-level prose that actually answers it — and the model correctly replies that the context
does not define the system. The corpus is fine; `README.md` is indexed. The routing simply
buries it. `--kind=readme` is the hard filter for that case:
```bash
bash AI/ai_query.sh --kind=readme "what is Varaverk"
```
`--kind` filters on where a chunk came from — `header`, `readme`, `manual`, `template`, `doc`, `ui`
and composes with `--section`. Prefer it over `--section` for "what is" and "why does this
exist" questions, where the answer is narrative rather than a header field.
---
## ━━━ NAMED-PARAGRAPH SUB-CHUNKING ━━━
Sections alone were not granular enough, and the failure was instructive.
`rsync.sh` documents fourteen distinct safeguards in one 2.8k-character
`OPERATIONAL SAFEGUARDS` block. Asked *"what happens if pass 1 of a merge run fails"*, the
correct chunk scored **0.558** — below unrelated chunks from other files — because the other
thirteen safeguards dominated the vector.
The header convention writes each safeguard as a named paragraph: an unindented title, an
indented body. Splitting on those titles took the same query to **0.718** and first place.
```
Merge-Run Delete Interlock ← its own chunk
--delete is applied only when pass 1 completed...
```
The parent section name is carried onto every sub-chunk, so routing still works. The title
detection requires the *next* line to be indented — without that check, any wrapped prose line
became a spurious boundary mid-sentence.
---
## ━━━ WHAT IS INDEXED ━━━
Roughly 2,900 chunks across ~180 files:
| Kind | Source |
|------|--------|
| `header` | Script headers — bash and PHP, sections and named paragraphs |
| `readme` | Every `README-*.md` |
| `manual` | Every `Manual-*.md` |
| `template` | `Deployment/*.template` — the versioned conf schema |
| `doc` | Top-level `README.md`, `Manual.md`, design notes |
| `ui` | `Plugin/unraid/pages/readme/*.md` — the WebGUI's own help panels |
**Script bodies are not indexed.** Headers state intent, code states mechanism; for the
questions this answers, intent retrieves better and costs far less.
---
## ━━━ THE SAFETY BOUNDARY ━━━
**Only `git ls-files` is ever indexed.** This is not a convenience — it is the security model.
`Configurations/` and `data/` are gitignored, so every file holding a credential
was never in the repo to begin with. The index therefore describes the full conf schema (via the
tracked templates, which carry all the explanatory comments) while structurally **never
containing a secret**.
> Do not "improve" this into a filesystem walk. A logged secret can be rotated. A secret
> averaged into a 768-dimension float cannot be found, let alone removed.
A live conf value the model genuinely needs should arrive through a tool call at query time,
subject to redaction — never baked into a vector at index time.
---
## ━━━ HOW IT IS BUILT ━━━
**SQLite, no vector database.** ~2,900 chunks × 768 dims is a couple of million multiply-adds
per query — under a millisecond. A vector DB would be a container to run, monitor, fail over and
back up, in exchange for nothing at this scale.
**Vectors are raw little-endian float32 BLOBs.** `nomic-embed-text` returns L2-normalised
vectors, so cosine similarity is a plain dot product — no normalising, no magnitude cache. PHP
reads the same blobs with `unpack('f*', $blob)` when the UI needs them.
**Incremental on mtime.** A file whose mtime has not moved is skipped without being read. A
no-op refresh takes about 70 ms; a full rebuild takes a few minutes.
**Node for the maths, bash for everything else.** The bash entry points own configuration,
gating, locking and logging exactly as every other Varaverk job does. Node owns only float
vector maths and SQLite BLOBs. Same split as `api_cache_writer.sh` and its PHP.
> `lib/index.js` uses `node:sqlite`, which Node still marks experimental. It is used because it
> needs no native compilation on Unraid. If a Node upgrade ever breaks it, the index is
> regenerable in minutes — this is a disposable artefact, not a datastore.
---
## ━━━ USAGE ━━━
```bash
# Build (needs AI_ENABLED=true)
bash AI/ai_index.sh # incremental
bash AI/ai_index.sh --force # full rebuild
bash AI/ai_index.sh --dry-run # what would be indexed; contacts nothing
bash AI/ai_index.sh --status # size, counts, last build
# Ask
bash AI/ai_query.sh "what stops rsync and the mover running at once"
bash AI/ai_query.sh --search "why are the cache writers lockless"
bash AI/ai_query.sh --section=CONFIGURATION "which variable sets the mover grace period"
bash AI/ai_query.sh --kind=readme "what is Varaverk" # definitional / narrative
bash AI/ai_query.sh --json "..." # for other scripts
```
**`--search` is the trustworthy mode.** It returns verbatim repo text with nothing generated.
When the answer matters, use it — or read the sources the generated answer cites.
---
## ━━━ TRUST THE SOURCES, NOT THE PROSE ━━━
The generation prompt instructs the model to answer only from retrieved context and to say what
is missing rather than fill the gap from general knowledge. That instruction matters here more
than usual: this repo's conventions are frequently *not* the conventional ones, and a confident
generic answer about rsync, Docker or systemd is worse than no answer.
It works — and its faithfulness cut both ways on the first real test. Asked which variable
controls the mover's grace window, the model answered `MOVER_STOP_TIMEOUT`, "defaults to 30
seconds", citing `mover_stop.sh CONFIGURATION`. The variable was right. The 30 was wrong — the
real value is 300 — and the model was quoting the header verbatim. **The header was stale.**
That sweep then found six stale `(default: N)` claims across the repo, all since corrected. The
lesson is the operating principle for this whole folder:
> Retrieval is exactly as accurate as the documentation it points at. When an answer looks
> wrong, check the cited source before blaming the model — it is usually reporting a real
> problem in the repo.
---
## ━━━ WHAT THIS DOES NOT DO ━━━
- **It does not write conf.** `AI_CONF_WRITE_ENABLED` exists in `master.conf` and is off, with
an empty key whitelist. Nothing in this folder writes a setting.
- **It does not make decisions.** No watchdog, cleanup or fallback path consults it. The
per-feature `AI_ASSIST_*` toggles are all false and each one is earned separately.
- **It does not index code.** Headers and docs only.
- **It does not sync.** The index is host-local and gitignored. Each node builds its own.
- **It is not required.** Every script runs identically with `AI_ENABLED=false`.
See `Notes_AI-Design.md` at the repo root for the wider design — host resolution across the
Tailscale mesh, per-feature rollout tiers, and the conf-write guardrails that would have to be
built before any of that is enabled.
---
## ━━━ SCHEDULING ━━━
The index tracks the repo automatically, from the only moment the corpus actually changes — a
successful pull. `git_pull_execute.sh` re-indexes behind three gates: the pull succeeded and
changed tracked files, `AI_INDEX_ON_PULL=true`, and `AI_ENABLED=true`. It is never fatal — a
failed index leaves the previous one in place and the pull still reports success.
No cron entry and no `DAILY_MAINTENANCE_SCRIPTS` line are needed; the daily pull carries it.
An incremental run on an unchanged repo is ~70 ms, so a daily entry costs effectively nothing
and a pull that changed twelve files costs a few seconds.
---
## ━━━ PROFILES ━━━
A profile is a contract plus a set of inputs. `Plugin/unraid/include/ai_profiles.php` is the one
definition of both, read by the endpoint, the worker, the shared chat include and the Scheduler
dock.
| Profile | Turns | Retrieves | Notes |
|---|---|---|---|
| `varaverk` | 3 | yes | answers only from the index, with citations. The default. |
| `chat` | 8 | **no** | ordinary conversation. Holds zero capabilities, deliberately. |
| `code` | 4 | no | drafts shell for Custom Scripts; scans its own output for destructive ops |
| `troubleshoot` | 2 | yes | reasons from evidence — an open log, or one you name. May file bug reports. |
Capabilities are granted per profile — retrieval, live health, run evidence, scoped log,
incidents, conf lookup, bug filing, code scanning. `chat` holding an empty list is a guarantee,
not an oversight: anything added to it stops being general chat and becomes an assistant that
sometimes invents claims about this installation.
### Routing out of General Chat
`chat` hands a question to whichever profile fits, decided by `vv_ai_route_from_chat()`. Ordered
most specific first, because these overlap on purpose:
| Question | Goes to | Why |
|---|---|---|
| "write me a script that prunes logs" | `code` | asked for something written |
| "why did the daily orch fail" | `troubleshoot` | diagnostic phrasing **and** something here to diagnose |
| "how did the daily orch go" | `varaverk` | about this install, but not a fault |
| "what does arr_sync.sh do" | `varaverk` | names a script, wants documentation |
| "why is the sky blue" | stays `chat` | diagnostic phrasing about nothing here |
`code` is checked first because it is the only intent about a thing that does not exist yet, so
nothing else can claim it — and it is anchored on the verb, which is what keeps "write me a
script" apart from "what does this script do". Escalation adds capability, so a wrong escalation
costs more than a missed one: anything unrecognised stays in `chat`, the profile that cannot
invent claims about this system. The worker reverts to `chat` anyway if retrieval comes back
empty.
The answer opens with one line naming the profile that took it, because the button still shows
the one you picked and an answer arriving under a different contract otherwise reads as the
assistant ignoring you.
Routing is asserted by `Plugin/unraid/Tools/ai_explain_check.sh` against
`ai_explain_fixtures.txt` — every case runs through the worker's `--explain` mode, which stops
where deterministic assembly ends and never calls the model.
This used to live in five places — history depth in the endpoint, capabilities in `include/ai.php`,
label and depth again in JavaScript, a prompt branch in the worker, and a label map on the
Scheduler page. They had already drifted: the JavaScript knew three profiles where PHP knew four.
The system prompts still live in `Tools/ai_chat_worker.php`, because they have one reader and
moving them would relocate the most delicate text in the subsystem without removing a duplicate.
## ━━━ CONVERSATIONS ━━━
Chats are stored server-side under `AI_DATA_DIR/ai_chats/`, one JSON file each, saved
automatically when a turn completes and pruned to `AI_CHAT_HISTORY_MAX` (default 10, oldest
first by creation).
There is no Save button. A conversation worth keeping is not reliably one you knew was worth
keeping while you were having it.
The same store backs the AI tab and the Monitor tab's AI row, so a thread started on the
dashboard is the one you carry on in the tab. Messages are re-validated per message on the way
in — a stored chat is replayed into a later prompt when reopened, so an unchecked role written
there would be an injection that survives a reload rather than one turn.
Reopened chats render as plain turns: sources, reasoning and timings describe one generation and
are not stored, because redrawing them beside a transcript that may be continued under a
different profile would be citing evidence for an answer no longer being made.
### Secrets are redacted on the way to disk
A conversation about settings is a conversation containing credentials — asking for an API key to
be changed means typing one. Message bodies are redacted in `vv_ai_chat_save()`, and the question
is redacted again before it reaches `ai.log`.
**On the way out, never in flight.** The live turn keeps the real value, because the model needs
it to carry out what was asked. What it does not need is that value still in the transcript a week
later — and a stored chat is replayed into a later prompt when reopened, so an unredacted one
would hand the credential back on every subsequent turn, indefinitely.
Two passes, because they catch different things:
| Pass | Catches | Method |
|---|---|---|
| Known values | a credential this host already holds | exact match against secret-shaped conf keys, longest first |
| Assignment shapes | a credential arriving that is not in the conf yet | `NAME=value`, `"api_key": value`, "set the token to …" |
The second pass is the one that matters for settings changes: *"change the Emby API key to X"* is
a secret arriving, and X matches nothing on disk until after the write it is requesting.
Ordinary prose is left alone — the patterns anchor on a secret-shaped *name*, so `CACHE_WARN_GB=100`
and "turn off the zfs scrub" pass through untouched. `vv_conf_key_is_secret()` is shared with the
conf audit log, so the two cannot disagree about what counts as a secret.
## ━━━ TOKEN ACCOUNTING ━━━
Every completed `ask` appends one row to `AI_TOKEN_DB` (`data/ai/ai_token_history.db`):
```
date|time|host|profile|source|prompt_tokens|completion_tokens|tok_s
2026-08-04|22:03:51|host1|varaverk|cli|2041|318|61.4
```
Both paths write it — this CLI (`source=cli`) and the WebGUI worker (`source=webgui`) — so the
totals are not quietly the tab's alone. `ai_query.sh` passes `--token-db` and `--token-host`;
called by hand without them, `cli.js` simply skips the row rather than guessing a path, because
this file never reads conf itself.
Read it on the plugin's AI tab, which aggregates today / last 7 days / all time, per host. Or
straight from the shell, since it is just a delimited file:
```bash
# tokens used today
awk -F'|' -v d="$(date +%F)" '$1==d {p+=$6; c+=$7} END {print p+c}' data/ai/ai_token_history.db
```
**The host column is where the turn ran, not where the file is read.** Each host writes only its
own rows.
`ai_token_sync.sh` pulls each partner's ledger into `$AI_TOKEN_CACHE_DIR/<slot>.tokens.db` (`/tmp/varaverk/ai/`) — the
same trick `conf_sync.sh` uses for partner confs, and it runs from
`INTERMEDIATE_MAINTENANCE_SCRIPTS` every four hours. The tab then reads every ledger it can see,
so a fleet total is a fleet total.
Pull only, no push: nothing here is needed by anyone else, and a reader that fetches its own data
controls its own freshness instead of depending on the partner's cron. A partner file may only
contribute rows whose `host` column matches its filename — a ledger copied into the wrong slot
would otherwise be double-counted against a total that still looked plausible.
The cache is tmpfs with **no save/restore pair**, unlike the conf cache. Stale counters are worse
than absent ones: absent renders as "not collected here", stale renders as fact. An unreachable
partner leaves its file alone and logs at info, because a partner being down for weeks is a
normal state, not an incident.
Pruning is by row count (`AI_TOKEN_RETAIN_ROWS`, default 20000) and happens on write, but only
once the file passes a size threshold — an ordinary turn costs a `stat()` and an append. The CLI
deliberately does not prune: duplicating a read-modify-write of the whole file in a second
language is how the two drift apart.
Executable
+265
View File
@@ -0,0 +1,265 @@
#!/bin/bash
# ==============================================================================================
# ========================== AI Retrieval Index Builder ========================================
# ==============================================================================================
#
# PURPOSE
# ==============================================================================================
# Builds and refreshes the retrieval index over Varaverk's own documentation — every script
# header, folder README and Manual, and the conf templates. Chunks each file on the section
# boundaries the header convention already defines, embeds each chunk through Ollama, and
# stores the vectors in SQLite for AI/ai_query.sh to search.
#
# The index is derived data. It is gitignored, host-local, and rebuildable from the repo in
# minutes — nothing depends on it surviving.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
# Chunking mirrors the header convention rather than using a fixed token window:
#
# bash headers split on the six section names, then sub-split named-paragraph
# safeguards and principles so one question finds one answer
# PHP headers same six names plus the per-layer tails (EXPORTS, RENDERS, ...)
# markdown split on headings
# conf templates split on the ━━━ section rules
#
# Every chunk keeps its section name as a field, which is what lets a query about a safeguard
# be steered toward OPERATIONAL SAFEGUARDS chunks before similarity is considered.
#
# Incremental by file mtime. A file whose mtime has not moved is skipped without being read,
# so a routine refresh costs well under a second and a full rebuild costs a few minutes.
#
# Heavy lifting runs in Node — float vector maths and SQLite BLOBs are genuinely awkward in
# bash. This follows the api_cache_writer.sh precedent: a bash shim owning config, gating,
# locking and logging, in front of the language that fits the work.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Tracked Files Only
# Indexes exactly what `git ls-files` reports. Configurations/, State_Files/ and data/ are
# gitignored, so it is structurally impossible for a credential to reach the index — the
# files holding them were never in the repo. This must never become a filesystem walk: a
# secret written into a vector cannot be rotated back out of it.
#
# The Index Is Disposable
# Stored under DATA_DIR, gitignored, and never synced to a partner. Losing it costs one
# rebuild. Nothing reads it as a source of truth — it points at files, and the files are
# the truth.
#
# Documentation Is The Corpus, Not The Code
# Script bodies are not indexed. The headers state intent and the code states mechanism;
# for the questions this answers, intent retrieves far better and is far cheaper.
#
# Off By Default
# Does nothing unless AI_ENABLED is true. A node with AI off never pays for this.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Fail-Closed Gate
# Exits cleanly unless AI_ENABLED is exactly "true". Any other value, including unset,
# means off.
#
# Root Enforcement
# Writes into DATA_DIR alongside other Varaverk state.
#
# Concurrency Lock
# acquire_lock() prevents two indexers racing on the same database.
#
# Dependency Verification
# Verifies node and the Ollama endpoint before touching the database. A missing dependency
# is reported and exits non-zero rather than leaving a half-built index.
#
# Reachability Pre-flight
# Probes the resolved Ollama URL with AI_CONNECT_TIMEOUT before starting. An unreachable
# endpoint aborts immediately instead of failing once per batch across the whole corpus.
#
# Partial Failure Is Not Recorded As Success
# A file whose chunks all failed to embed keeps its previous rows and its old mtime, so the
# next run retries it. A run with any failed batch exits 3.
#
# Atomic Per-Run Write
# All database changes commit in one transaction. An interrupted run leaves the previous
# index intact rather than a partially rewritten one.
#
# Deleted Files Are Removed From The Index
# A file that has left the repo has its chunks deleted, so retrieval cannot cite something
# that no longer exists.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# AI_ENABLED
# Master switch. Fail-closed — must be exactly "true".
#
# AI_INDEX_DB
# SQLite index path. (shipped default: $DATA_DIR/ai_index.db)
#
# AI_INDEX_BATCH
# Chunks per embed request. (shipped default: 32)
#
# AI_CONNECT_TIMEOUT
# Seconds for the reachability probe. (shipped default: 5)
#
# AI_REQUEST_TIMEOUT
# Seconds for a single embed batch. (shipped default: 240)
#
# host*.conf
#
# HOST*_OLLAMA_URL
# This host's Ollama endpoint. Empty means no local Ollama.
#
# HOST*_OLLAMA_EMBED_MODEL
# Embedding model. The generation model cannot embed.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# ai_index.sh
# Incremental refresh — only files whose mtime moved are re-embedded.
#
# ai_index.sh --force
# Full rebuild. Discards the existing index and re-embeds every chunk.
#
# ai_index.sh --dry-run
# Report what would be indexed. Contacts nothing and writes nothing.
#
# ai_index.sh --status
# Show index location, size, chunk counts by kind and section, and last build time.
#
# ai_index.sh --log
# Verbose — per-batch embedding progress.
#
# ==============================================================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../load_config.sh"
detect_hosts
DRY_RUN=false; FORCE=false; STATUS=false; LOG=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--force) FORCE=true ;;
--status) STATUS=true ;;
--log) LOG=true ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
CLI="${SCRIPT_DIR}/lib/cli.js"
DB="${AI_INDEX_DB:-${DATA_DIR}/ai_index.db}"
_url_var="${MY_ID}_OLLAMA_URL"
_emb_var="${MY_ID}_OLLAMA_EMBED_MODEL"
OLLAMA_URL="${!_url_var:-}"
EMBED_MODEL="${!_emb_var:-nomic-embed-text}"
# ── Status ────────────────────────────────────────────────────────────────────────────────────
if [[ "$STATUS" == true ]]; then
echo "$ICON_GEAR AI Index Status"
echo " Enabled: ${AI_ENABLED:-false}"
echo " Database: $DB"
if [[ -f "$DB" ]]; then
echo " Size: $(du -h "$DB" 2>/dev/null | cut -f1)"
echo " Chunks: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM vv_chunks;' 2>/dev/null || echo '?')"
echo " Files: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM vv_files;' 2>/dev/null || echo '?')"
_last=$(sqlite3 "$DB" "SELECT v FROM vv_meta WHERE k='last_index';" 2>/dev/null)
[[ -n "$_last" ]] && echo " Last built: $(date -d "@$_last" '+%Y-%m-%d %H:%M:%S' 2>/dev/null)"
echo " Model: $(sqlite3 "$DB" "SELECT v FROM vv_meta WHERE k='embed_model';" 2>/dev/null || echo '?')"
echo " By kind:"
sqlite3 "$DB" "SELECT ' '||kind||': '||COUNT(*) FROM vv_chunks GROUP BY kind ORDER BY COUNT(*) DESC;" 2>/dev/null
else
echo " Database: not built yet"
fi
echo " Ollama: ${OLLAMA_URL:-<none local>}"
echo " Embed: $EMBED_MODEL"
exit 0
fi
# ── Gate ──────────────────────────────────────────────────────────────────────────────────────
if [[ "${AI_ENABLED:-false}" != "true" ]]; then
log "AI_ENABLED is not true — skipping index build"
exit 0
fi
# The mesh shares one AI, and the index belongs to the node that holds the model. A mirror has the
# same checkout and could build one, but nothing there would read it: retrieval happens wherever
# generation happens, which is the owner.
#
# A skip, not an error. This is reached from git_pull_execute.sh on every node after every pull;
# before the AI became mesh-wide it ran here too and failed on the empty OLLAMA_URL, nightly and
# silently, because the caller discards its output.
_ai_owner="${AI_OWNER_HOST:-host1}"
if [[ "${MY_ID,,}" != "${_ai_owner,,}" ]]; then
log "This node is not the AI owner ($_ai_owner) — the index lives there; skipping"
exit 0
fi
if [[ "$DRY_RUN" == false && "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
command -v node >/dev/null 2>&1 || { error "node not found — required to build the index"; exit 1; }
[[ -f "$CLI" ]] || { error "missing $CLI"; exit 1; }
# The AI owner has had data/ai since the subsystem was built, so nothing ever created it — cli.js
# opens the DB by path and does not make the directory. On a first build the failure surfaces as a
# sqlite open error rather than as the missing directory it is.
if [[ "$DRY_RUN" == false ]] && ! mkdir -p "$(dirname "$DB")"; then
error "Cannot create $(dirname "$DB")"
exit 1
fi
if [[ -z "$OLLAMA_URL" ]]; then
error "${MY_ID}_OLLAMA_URL is empty — no local Ollama to index against"
exit 1
fi
# Pre-flight: fail once, up front, rather than once per batch across the whole corpus.
if [[ "$DRY_RUN" == false ]]; then
if ! curl -sf --max-time "${AI_CONNECT_TIMEOUT:-5}" "${OLLAMA_URL%/}/api/tags" >/dev/null 2>&1; then
error "Ollama unreachable at $OLLAMA_URL"
exit 1
fi
fi
# ── Build ─────────────────────────────────────────────────────────────────────────────────────
_args=(
index
"--root=${SCRIPTS_DIR}"
"--db=${DB}"
"--url=${OLLAMA_URL}"
"--model=${EMBED_MODEL}"
"--batch=${AI_INDEX_BATCH:-32}"
"--timeout=$(( ${AI_REQUEST_TIMEOUT:-240} * 1000 ))"
)
[[ "$FORCE" == true ]] && _args+=(--force)
[[ "$DRY_RUN" == true ]] && _args+=(--dry-run)
[[ "$LOG" == false ]] && _args+=(--quiet)
log "$ICON_GEAR Building AI index → $DB"
node --no-warnings "$CLI" "${_args[@]}"
_rc=$?
case "$_rc" in
0) log "$ICON_DONE AI index build complete" ;;
3) warn "AI index built with some batches failed — those files will retry next run" ;;
*) error "AI index build failed (exit $_rc)" ;;
esac
exit "$_rc"
Executable
+209
View File
@@ -0,0 +1,209 @@
#!/bin/bash
# ==============================================================================================
# ============================== AI Retrieval Query ============================================
# ==============================================================================================
#
# PURPOSE
# ==============================================================================================
# Answers questions about Varaverk from Varaverk's own documentation. Embeds the question,
# retrieves the closest chunks from the index AI/ai_index.sh built, and either prints them
# directly or passes them to the generation model as grounding context.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
# Two modes over the same retrieval:
#
# --search print the matching chunks and their sources. No generation model involved,
# so it is fast and its output is verbatim repo text.
# (default) retrieve, then ask the generation model to answer strictly from what was
# retrieved, citing each claim.
#
# Retrieval is steered by question shape. A question about what prevents something is pushed
# toward OPERATIONAL SAFEGUARDS chunks, one about a variable toward CONFIGURATION, one asking
# why toward DESIGN PRINCIPLES. This is a score boost, not a filter — a heuristic must not be
# able to exclude the chunk that actually holds the answer.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Grounded Or Silent
# The prompt instructs the model to answer only from retrieved context and to say what is
# missing rather than fill the gap. This repo's conventions are frequently not the
# conventional ones, and a confident generic answer about rsync or Docker is worse here
# than no answer.
#
# Sources Are Always Shown
# Every answer prints the chunks it drew on. An answer that cannot be traced back to a file
# is not usable for changing anything.
#
# Search Is The Trustworthy Mode
# --search returns repo text with nothing generated. When an answer matters, use it.
#
# Read-Only
# Retrieves and answers. Nothing here writes conf, touches state, or runs another script.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Fail-Closed Gate
# Exits cleanly unless AI_ENABLED is exactly "true".
#
# No Root Required
# Reads the index and calls Ollama. Nothing it does needs privilege, so it does not ask for
# any — this is the one AI script an ordinary user should be able to run.
#
# Missing Index Is Reported, Not Built
# An absent index exits with guidance to run ai_index.sh. Building a corpus-wide index as a
# side effect of a question would turn a two-second query into a several-minute one.
#
# Reachability Pre-flight
# Probes Ollama with AI_CONNECT_TIMEOUT before embedding, so an unreachable endpoint fails
# immediately with a clear message.
#
# Bounded Generation
# The request is capped at AI_REQUEST_TIMEOUT. A wedged model cannot hang the caller.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# AI_ENABLED
# Master switch. Fail-closed — must be exactly "true".
#
# AI_INDEX_DB
# SQLite index to search. (shipped default: $DATA_DIR/ai_index.db)
#
# AI_SEARCH_K
# Chunks retrieved per query. (shipped default: 8)
#
# AI_SEARCH_PER_FILE
# Cap per file, so one document cannot fill the context. (shipped default: 3)
#
# AI_REQUEST_TIMEOUT
# Seconds allowed for generation. (shipped default: 240)
#
# host*.conf
#
# HOST*_OLLAMA_URL / HOST*_OLLAMA_MODEL / HOST*_OLLAMA_EMBED_MODEL
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# ai_query.sh "your question"
# Retrieve and answer, with sources.
#
# ai_query.sh --search "your question"
# Print matching chunks only. No generation.
#
# ai_query.sh --section=CONFIGURATION "your question"
# Restrict retrieval to one header section.
#
# ai_query.sh --kind=readme "what is Varaverk"
# Restrict retrieval to one chunk origin: header, readme, manual, template, doc.
# Use this for definitional and narrative questions. Intent routing boosts header
# sections such as PURPOSE, which answers "what does this script do" well but buries
# the top-level prose that explains what the system *is*. --kind=readme reaches it.
#
# ai_query.sh --json "your question"
# Machine-readable output for other scripts.
#
# ai_query.sh --status
# Show index and endpoint state.
#
# ==============================================================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../load_config.sh"
detect_hosts
SEARCH_ONLY=false; JSON=false; STATUS=false; SECTION=""; KIND=""; QUERY=""
for arg in "$@"; do
case "$arg" in
--search) SEARCH_ONLY=true ;;
--json) JSON=true ;;
--status) STATUS=true ;;
--section=*) SECTION="${arg#*=}" ;;
--kind=*) KIND="${arg#*=}" ;;
--*) echo "Unknown option: $arg" >&2; exit 1 ;;
*) QUERY="$arg" ;;
esac
done
# --kind is a hard filter on chunk origin; --section filters the header section within a
# chunk. They answer different questions and compose: --kind=readme --section=PURPOSE is
# meaningful. Validated here rather than in the CLI so a typo costs nothing — an unknown kind
# silently matches no rows, which reads as "the index has no answer" and is the most
# misleading failure this tool can produce.
if [[ -n "$KIND" ]]; then
case "$KIND" in
header|readme|manual|template|doc|ui) ;;
*) echo "Unknown --kind=$KIND (expected: header, readme, manual, template, doc, ui)" >&2
exit 1 ;;
esac
fi
CLI="${SCRIPT_DIR}/lib/cli.js"
DB="${AI_INDEX_DB:-${DATA_DIR}/ai_index.db}"
_url_var="${MY_ID}_OLLAMA_URL"
_gen_var="${MY_ID}_OLLAMA_MODEL"
_emb_var="${MY_ID}_OLLAMA_EMBED_MODEL"
OLLAMA_URL="${!_url_var:-}"
GEN_MODEL="${!_gen_var:-}"
EMBED_MODEL="${!_emb_var:-nomic-embed-text}"
if [[ "$STATUS" == true ]]; then
echo "$ICON_GEAR AI Query Status"
echo " Enabled: ${AI_ENABLED:-false}"
echo " Index: $DB $([[ -f "$DB" ]] && echo "($(sqlite3 "$DB" 'SELECT COUNT(*) FROM vv_chunks;' 2>/dev/null) chunks)" || echo '(not built)')"
echo " Ollama: ${OLLAMA_URL:-<none>}"
echo " Generate: ${GEN_MODEL:-<unset>}"
echo " Embed: $EMBED_MODEL"
exit 0
fi
if [[ "${AI_ENABLED:-false}" != "true" ]]; then
echo "AI_ENABLED is not true — AI features are off" >&2
exit 0
fi
[[ -z "$QUERY" ]] && { echo "usage: ai_query.sh [--search] [--section=NAME] [--kind=KIND] \"your question\"" >&2; exit 1; }
[[ -f "$DB" ]] || { error "No index at $DB — run AI/ai_index.sh first"; exit 1; }
[[ -f "$CLI" ]] || { error "missing $CLI"; exit 1; }
command -v node >/dev/null 2>&1 || { error "node not found"; exit 1; }
[[ -z "$OLLAMA_URL" ]] && { error "${MY_ID}_OLLAMA_URL is empty"; exit 1; }
if ! curl -sf --max-time "${AI_CONNECT_TIMEOUT:-5}" "${OLLAMA_URL%/}/api/tags" >/dev/null 2>&1; then
error "Ollama unreachable at $OLLAMA_URL"
exit 1
fi
_args=("--db=${DB}" "--url=${OLLAMA_URL}" "--query=${QUERY}")
[[ -n "$SECTION" ]] && _args+=("--section=${SECTION}")
[[ -n "$KIND" ]] && _args+=("--kind=${KIND}")
[[ "$JSON" == true ]] && _args+=(--json)
if [[ "$SEARCH_ONLY" == true ]]; then
node --no-warnings "$CLI" search "${_args[@]}" \
"--model=${EMBED_MODEL}" \
"--k=${AI_SEARCH_K:-8}" "--per-file=${AI_SEARCH_PER_FILE:-3}"
else
[[ -z "$GEN_MODEL" ]] && { error "${MY_ID}_OLLAMA_MODEL is empty — needed for generation"; exit 1; }
# Token accounting. Passed in rather than re-read in node, so the conf stays the shell's job
# and cli.js keeps taking everything it needs as arguments. Omitting either flag simply
# skips the row — the CLI must still work when called by hand outside this wrapper.
node --no-warnings "$CLI" ask "${_args[@]}" \
"--model=${GEN_MODEL}" "--embed-model=${EMBED_MODEL}" \
"--k=${AI_SEARCH_K:-8}" "--per-file=${AI_SEARCH_PER_FILE:-3}" \
"--timeout=$(( ${AI_REQUEST_TIMEOUT:-240} * 1000 ))" \
"--token-db=${AI_TOKEN_DB:-}" "--token-host=$(echo "$MY_ID" | tr '[:upper:]' '[:lower:]')"
fi
+236
View File
@@ -0,0 +1,236 @@
#!/bin/bash
# ==============================================================================================
# ============================= AI Token Ledger Sync ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pulls each partner's AI token ledger into a RAM cache at /tmp/.cache/vv/ai/, so the AI tab
# can report usage for the whole fleet instead of only the host the browser happens to be on.
#
# Each host records its own turns to data/ai_token_history.db and nothing syncs that file, so
# without this a host can only ever see its own totals. The tab is careful to say "not collected
# here" rather than 0 for a partner it cannot see; this script is what turns that into a number.
#
# Same trick as conf_sync.sh, and deliberately so — resolve the partner over Tailscale, scp one
# small file into a tmpfs cache, let the reader treat a missing file as "unknown".
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Gates — PARTNERSHIP_ENABLED, AI_ENABLED, AI_TOKEN_SYNC_ENABLED
# 2. Per partner:
# a. Resolve their Tailscale IP
# b. Resolve their SCRIPTS_DIR from their varaverk.cfg (they may be in appdata mode)
# c. scp their data/ai_token_history.db → $AI_TOKEN_CACHE_DIR/<slot>.tokens.db
#
# Pull only, no push. conf_sync.sh pushes as well because a partner may be unable to reach us
# and still needs our credentials; nothing here is needed by anyone else, and a reader that
# fetches its own data controls its own freshness rather than depending on the partner's cron.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# A missing file means unknown, never zero.
# The whole point of the tab reporting "not collected here" is that it is a different claim
# from "this partner spent nothing". If a partner is dark, unreachable or has never run a turn,
# there is simply no cache file, and the reader is expected to say so rather than render a 0
# that looks like a measurement.
#
# The reader pulls; nobody pushes.
# conf_sync.sh pushes as well, because a partner that cannot reach us still needs our
# credentials. Nothing here is needed by anyone else, so a host that wants fleet totals fetches
# them and owns its own freshness instead of depending on someone else's cron having run.
#
# RAM, not flash.
# The cache lands in tmpfs. It is a copy of a file that already exists on the partner and is
# rebuilt on the next pass, so writing it to flash would cost wear for something that is never
# worth surviving a reboot.
#
# Same shape as conf_sync.sh, deliberately.
# Resolve over Tailscale, scp one small file into a tmpfs cache, let a missing file mean
# unknown. A second transport pattern for a second small file would be a second set of
# failure modes to learn.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# An unreachable partner is not a failure.
# HOST2 is expected to be down for long stretches during onboarding. A warn every four
# hours would train the operator to ignore this script's output, and the AI diagnostic
# path treats every log WARN as actionable. Unresolvable partners are counted and
# reported once at info level; only a partner that resolves and then fails to transfer
# is treated as an error.
#
# The cache is never written directly.
# scp lands on a .part file that is renamed into place, so a transfer interrupted halfway
# cannot leave the reader parsing half a ledger. A truncated final row would be skipped by
# the field-count check on the PHP side, but a torn file should not reach it at all.
#
# Nothing is ever written back to the partner.
# This script only reads. A bug here cannot corrupt a partner's accounting.
#
# The cache is tmpfs and deliberately not preserved.
# Unlike the conf cache there is no save/restore pair. Stale counters are worse than
# absent ones: absent reads as "not collected here", stale reads as fact.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# AI_ENABLED Whole AI subsystem gate
# AI_TOKEN_SYNC_ENABLED This script's own toggle (default: true)
# PARTNERSHIP_ENABLED Checked via require_partnership()
# SSH_KEY Key used for all partner ssh/scp operations
#
# load_config.sh
#
# AI_TOKEN_CACHE_DIR tmpfs directory the tab reads partner ledgers from
#
# host*.conf
#
# HOST* — hostnames used to build the partner list via detect_hosts()
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# ai_token_sync.sh Pull every reachable partner's ledger
# ai_token_sync.sh --dry-run Report what would be pulled, transfer nothing
# ai_token_sync.sh --log Verbose output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
detect_hosts
require_partnership
if [[ "${AI_ENABLED:-false}" != true ]]; then
log "AI_ENABLED=false — skipping"
exit 0
fi
if [[ "${AI_TOKEN_SYNC_ENABLED:-true}" == false ]]; then
log "AI_TOKEN_SYNC_ENABLED=false — skipping"
exit 0
fi
CACHE_DIR="$AI_TOKEN_CACHE_DIR"
SSH_TIMEOUT=10
# Mirrors conf_sync.sh — the remote may be in appdata storage mode, so its ledger is not
# necessarily under /boot.
_remote_scripts_dir() {
local ip="$1"
local cfg line sd
cfg=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${ip}" "cat /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null" 2>/dev/null) || true
while IFS= read -r line; do
[[ "$line" == SCRIPTS_DIR=* ]] || continue
sd="${line#SCRIPTS_DIR=}"; sd="${sd//\"/}"; sd="${sd//\'/}"
echo "$sd"; return
done <<< "$cfg"
echo "/boot/config/plugins/varaverk"
}
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$CACHE_DIR" && chmod 755 "$CACHE_DIR"
fi
PULLED=0
OFFLINE=0
FAILED=0
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
partner_host="${!host_var}"
[[ -z "$partner_host" ]] && continue
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
partner_slot="${host_var,,}"
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
if [[ -z "$partner_ip" ]]; then
log "$partner_host — unresolvable, leaving any cached ledger as-is"
(( OFFLINE++ ))
continue
fi
# Liveness and ledger presence are separate questions, probed separately on purpose.
# Tailscale hands back an IP for a peer that is registered but powered off, so resolving
# proves nothing. And a single `ssh test -f` answers both questions at once: it fails
# identically whether the host is down or the file is simply absent. Treating that one
# failure as "no ledger" would delete a perfectly good cached copy every time the partner
# blinked — turning "synced 3h ago" into "not collected here" on a transient.
if ! timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}" true 2>/dev/null; then
log "$partner_host — not answering, keeping any cached ledger as-is"
(( OFFLINE++ ))
continue
fi
remote_sd=$(_remote_scripts_dir "$partner_ip")
remote_db="${remote_sd}/data/ai_token_history.db"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull $partner_host:$remote_db$CACHE_DIR/${partner_slot}.tokens.db"
continue
fi
# Reachable, but nothing recorded there — a partner with AI off, or one that has simply
# never been asked anything. Now that liveness is established this is a real answer, so the
# stale copy goes: the tab should say "not collected here", not quote a number from before.
if ! timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}" "[[ -f '$remote_db' ]]" 2>/dev/null; then
log "$partner_host — reachable, but no ledger there yet"
rm -f "$CACHE_DIR/${partner_slot}.tokens.db"
(( OFFLINE++ ))
continue
fi
if timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}:${remote_db}" \
"$CACHE_DIR/${partner_slot}.tokens.db.part" 2>/dev/null \
&& mv -f "$CACHE_DIR/${partner_slot}.tokens.db.part" "$CACHE_DIR/${partner_slot}.tokens.db"; then
chmod 644 "$CACHE_DIR/${partner_slot}.tokens.db" 2>/dev/null
echo "Pulled ${partner_slot} ledger from $partner_host"
(( PULLED++ ))
else
rm -f "$CACHE_DIR/${partner_slot}.tokens.db.part"
warn "Could not pull ${partner_slot} ledger from $partner_host"
(( FAILED++ ))
fi
done
# Zero counts are omitted rather than printed. ${VAR:+...} keeps "0" because it is a non-empty
# string, and a summary that always ends "0 failed" is what teaches you to stop reading it.
_summary="AI token sync complete — pulled $PULLED"
(( OFFLINE > 0 )) && _summary+=", $OFFLINE unavailable"
(( FAILED > 0 )) && _summary+=", $FAILED failed"
info "$_summary"
# Only a partner that answered and then failed the transfer is worth an exit code. An absent
# partner is the normal state whenever a partner is not yet onboarded.
[[ "$FAILED" -gt 0 ]] && exit 1
exit 0
+276
View File
@@ -0,0 +1,276 @@
'use strict';
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Chunker — turns repo files into retrieval units.
//
// The whole point of the header audit is that chunk boundaries are deterministic here. Bash
// scripts split on their six section names, markdown on its headings, conf templates on their
// ━━━ section rules. Nothing is split on a fixed token window, so no chunk ever contains half
// of one idea and half of another.
//
// Every chunk carries its section name as its own field, because that is the metadata that
// lets retrieval filter by question shape before it ever computes similarity.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
const fs = require('fs');
const path = require('path');
const BASH_SECTIONS = [
'PURPOSE', 'OPERATIONAL MODEL', 'DESIGN PRINCIPLES',
'OPERATIONAL SAFEGUARDS', 'CONFIGURATION', 'RUNTIME MODES',
];
// PHP headers reuse the first three names deliberately, then diverge per layer.
const PHP_SECTIONS = [
'PURPOSE', 'OPERATIONAL MODEL', 'DESIGN PRINCIPLES', 'OPERATIONAL SAFEGUARDS',
'STATUS', 'EXPORTS', 'REQUEST CONTRACT', 'SIDE EFFECTS', 'RENDERS', 'DEPENDS ON',
'CONFIGURATION',
];
const MIN_CHARS = 40; // below this a chunk carries no retrievable meaning
const MAX_CHARS = 6000; // above this, split on blank lines — protects the embed window
// Banner rules and box-drawing art are everywhere in this repo's headers. They carry no
// meaning to embed, and a chunk that is mostly rule characters is pure noise in the index.
// Measure a chunk by what is left after the decoration is removed, not by raw length.
function meaningful(s) {
return s.replace(/[═─━=_#\/*\s|+.-]/g, '').length;
}
const MIN_MEANINGFUL = 30;
function isRealHeading(h) {
return !!h && /[A-Za-z0-9]/.test(h.replace(/[═─━=_]/g, ''));
}
function stripPrefix(line, prefix) {
// '# text' -> 'text' '// text' -> 'text'
const re = new RegExp('^\\s*' + prefix + '\\s?');
return line.replace(re, '');
}
// ── Comment-header sectioning, shared by bash (#) and PHP (//) ────────────────────────────────
function sectionsFromCommentHeader(text, prefix, names) {
const lines = text.split('\n');
const nameSet = new Set(names);
const found = [];
// headerEnd matters as much as the section starts. The last section (RUNTIME MODES in bash,
// DEPENDS ON in a page) would otherwise run to EOF and sweep up every unrelated comment in
// the file — scheduler.php alone contributed an 11k-char chunk of unrelated inline comments.
let headerEnd = lines.length;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
if (!new RegExp('^\\s*' + prefix).test(raw)) {
// Header block ends at the first non-comment, non-blank line past the shebang.
// '<?php' and '?>' bracket a PHP header block and are not the end of it.
const t = raw.trim();
if (t !== '' && !/^#!/.test(t) && t !== '<?php' && t !== '?>' && found.length) {
headerEnd = i;
break;
}
continue;
}
const inner = stripPrefix(raw, prefix).trim();
if (nameSet.has(inner)) found.push({ name: inner, start: i });
}
const out = [];
for (let k = 0; k < found.length; k++) {
const start = found[k].start + 1;
const end = k + 1 < found.length ? found[k + 1].start : headerEnd;
const body = lines.slice(start, end)
.filter(l => new RegExp('^\\s*' + prefix).test(l))
.map(l => stripPrefix(l, prefix))
// drop pure separator rules (════, ────, ━━━) — they carry no meaning
.filter(l => !/^[\s═─━=_-]*$/.test(l) || l.trim() === '')
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (meaningful(body) >= MIN_MEANINGFUL) {
for (const p of splitNamedParagraphs(body))
out.push({ section: found[k].name, title: p.title, content: p.content });
}
}
return out;
}
// ── Named-paragraph sub-chunking ──────────────────────────────────────────────────────────────
// The header convention writes safeguards and principles as named paragraphs: an unindented
// title line followed by an indented body. Embedding a whole section as one unit dilutes them —
// rsync.sh's OPERATIONAL SAFEGUARDS holds eight distinct guarantees in 2.8k chars, and a query
// about one of them scored below unrelated chunks because the other seven dominated the vector.
// Splitting on the title lines is what makes a specific question find a specific answer.
//
// The section name is carried onto every sub-chunk, so section routing still works; the
// paragraph title becomes the chunk's heading.
function splitNamedParagraphs(body) {
const lines = body.split('\n');
const marks = [];
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (!l.trim()) continue;
if (/^\s/.test(l)) continue; // indented => body, not a title
if (/^[-*•]/.test(l.trim())) continue; // list item, not a title
if (l.trim().length > 80) continue; // a long line is prose, not a heading
if (/[.:;,]$/.test(l.trim())) continue; // ends like a sentence
// The decisive signal: a real title is followed by an indented body. Wrapped prose is
// followed by more unindented prose. Without this check, any short line in a paragraph
// that happened to wrap became a spurious chunk boundary mid-sentence.
let j = i + 1;
while (j < lines.length && !lines[j].trim()) j++;
if (j >= lines.length || !/^\s+\S/.test(lines[j])) continue;
marks.push(i);
}
// Fewer than two titles means this section is not written as named paragraphs — keep it whole.
if (marks.length < 2) return [{ title: null, content: body }];
const out = [];
if (marks[0] > 0) {
const pre = lines.slice(0, marks[0]).join('\n').trim();
if (meaningful(pre) >= MIN_MEANINGFUL) out.push({ title: null, content: pre });
}
for (let k = 0; k < marks.length; k++) {
const start = marks[k];
const end = k + 1 < marks.length ? marks[k + 1] : lines.length;
const title = lines[start].trim();
const content = lines.slice(start, end).join('\n').trim();
if (meaningful(content) >= MIN_MEANINGFUL) out.push({ title, content });
}
return out;
}
// ── Markdown: split on ## headings, keep the heading with its body ─────────────────────────────
function sectionsFromMarkdown(text) {
const lines = text.split('\n');
const marks = [];
let fence = false;
for (let i = 0; i < lines.length; i++) {
if (/^\s*```/.test(lines[i])) { fence = !fence; continue; }
if (fence) continue;
// A heading whose text is nothing but rule characters is a banner, not a section. The
// house style opens a document with three of them —
// # ━━━━━━━━
// # 🏠 VARAVERK
// # ━━━━━━━━
// — and treating each as a boundary split the title into a section of its own, too small
// to survive, then gave the paragraph that actually defines the project a chunk headed by
// the rule beneath it: no heading, and content opening with 75 identical glyphs. That is
// why "what is Varaverk" returned five script PURPOSE headers and never README.md, which
// has been indexed the whole time. 35 of the 40 markdown files here open this way.
//
// Whole-string test, so an ordinary heading containing a dash is unaffected — "Set-up"
// does not reduce to empty, and a line of dashes does.
if (!/^#{1,3}\s+\S/.test(lines[i])) continue;
if (lines[i].replace(/^#+\s*/, '').replace(/[━─═=~_*\-\s]+/gu, '') === '') continue;
marks.push(i);
}
if (!marks.length) return [{ heading: null, content: text.trim() }];
const out = [];
// preamble before the first heading
if (marks[0] > 0) {
const pre = lines.slice(0, marks[0]).join('\n').trim();
if (meaningful(pre) >= MIN_MEANINGFUL) out.push({ heading: null, content: pre });
}
for (let k = 0; k < marks.length; k++) {
const start = marks[k];
const end = k + 1 < marks.length ? marks[k + 1] : lines.length;
const heading = lines[start].replace(/^#+\s*/, '').replace(/[━─═]+/g, '').trim();
const content = lines.slice(start, end).join('\n').trim();
if (meaningful(content) >= MIN_MEANINGFUL) out.push({ heading: isRealHeading(heading) ? heading : null, content });
}
return out;
}
// ── Conf templates: split on the ━━━ / ── section rules ───────────────────────────────────────
function sectionsFromConfTemplate(text) {
const lines = text.split('\n');
const marks = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^#\s*[━─]{2,}\s*(.+?)\s*[━─]{2,}\s*$/);
if (m && isRealHeading(m[1])) marks.push({ i, name: m[1].trim() });
}
if (!marks.length) return [];
const out = [];
for (let k = 0; k < marks.length; k++) {
const start = marks[k].i;
const end = k + 1 < marks.length ? marks[k + 1].i : lines.length;
const content = lines.slice(start, end).join('\n').replace(/\n{3,}/g, '\n\n').trim();
if (meaningful(content) >= MIN_MEANINGFUL) out.push({ heading: marks[k].name, content });
}
return out;
}
// Oversized chunks split on blank lines rather than mid-sentence.
function capSize(chunks) {
const out = [];
for (const c of chunks) {
if (c.content.length <= MAX_CHARS) { out.push(c); continue; }
const paras = c.content.split(/\n\s*\n/);
let buf = [], len = 0, part = 1;
const flush = () => {
if (!buf.length) return;
out.push({ ...c, content: buf.join('\n\n'), part: part++ });
buf = []; len = 0;
};
for (const p of paras) {
if (len + p.length > MAX_CHARS && buf.length) flush();
buf.push(p); len += p.length + 2;
}
flush();
}
return out;
}
function classify(rel) {
const base = path.basename(rel);
if (rel.startsWith('Deployment/') && rel.endsWith('.template')) return 'template';
// WebGUI page docs, written for whoever is using the tab rather than maintaining it. Their
// own kind because every other kind here answers a maintainer's question: an operator asking
// "how do I stop this" needs the click path, and a corpus that is three-quarters script
// headers will otherwise always answer in conf edits. Matched on the folder, not the
// filename, so these can be named whatever reads best.
if (rel.startsWith('Plugin/unraid/pages/readme/') && base.endsWith('.md')) return 'ui';
if (base.endsWith('.md')) {
if (base.startsWith('Manual')) return 'manual';
if (base.startsWith('README') || base === 'README.md') return 'readme';
return 'doc';
}
if (base.endsWith('.sh')) return 'header';
if (base.endsWith('.php')) return 'header';
return 'other';
}
function chunkFile(absPath, rel) {
const text = fs.readFileSync(absPath, 'utf8');
const kind = classify(rel);
let raw = [];
if (kind === 'header' && rel.endsWith('.sh')) {
raw = sectionsFromCommentHeader(text, '#', BASH_SECTIONS)
.map(s => ({ section: s.section, heading: s.title || null, content: s.content }));
} else if (kind === 'header' && rel.endsWith('.php')) {
raw = sectionsFromCommentHeader(text, '//', PHP_SECTIONS)
.map(s => ({ section: s.section, heading: s.title || null, content: s.content }));
} else if (kind === 'template') {
raw = sectionsFromConfTemplate(text)
.map(s => ({ section: null, heading: s.heading, content: s.content }));
} else if (kind === 'readme' || kind === 'manual' || kind === 'doc' || kind === 'ui') {
raw = sectionsFromMarkdown(text)
.map(s => ({ section: null, heading: s.heading, content: s.content }));
}
return capSize(raw).map(c => ({
path: rel,
kind,
section: c.section || null,
heading: c.heading || null,
part: c.part || null,
content: c.content,
}));
}
module.exports = { chunkFile, classify, BASH_SECTIONS, PHP_SECTIONS };
+209
View File
@@ -0,0 +1,209 @@
'use strict';
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// CLI bridge — the thin layer the bash entry points call.
//
// The bash scripts own configuration, gating, locking and logging, exactly as they do for every
// other Varaverk job. This file owns only the work that is genuinely awkward in bash: float
// vector math and SQLite BLOBs. That split follows the existing api_cache_writer.sh precedent —
// a bash shim in front of the language that fits the task.
//
// Every value arrives as an argument or an environment variable read by the caller. This file
// never reads a conf file itself, so there is exactly one place that decides what the settings
// are.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
const fs = require('fs');
const { buildIndex } = require('./index.js');
const { search } = require('./search.js');
function arg(name, dflt) {
const p = `--${name}=`;
const hit = process.argv.find(a => a.startsWith(p));
return hit ? hit.slice(p.length) : dflt;
}
function flag(name) {
return process.argv.includes(`--${name}`);
}
// Token accounting. Writes the same row shape as the WebGUI worker into the same file — one
// ledger for both paths, or the totals quietly come to mean "whatever the tab happened to do".
// Skipped silently when the caller passes neither flag, because cli.js has to stay runnable by
// hand. Best-effort: a failed append must never cost a caller an answer it already has.
//
// Trimming is deliberately not done here. The PHP side prunes on write, and duplicating a
// read-modify-write of the whole file in a second language is how the two drift apart.
function recordTokens(profile, prompt, completion, tokS) {
const db = arg('token-db', ''), host = arg('token-host', '');
if (!db || !host || (prompt <= 0 && completion <= 0)) return;
const d = new Date();
const p2 = n => String(n).padStart(2, '0');
const row = [
`${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`,
`${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`,
host, profile, 'cli', prompt, completion,
tokS === null ? '' : tokS.toFixed(1),
].join('|') + '\n';
try { fs.appendFileSync(db, row); } catch { /* accounting is not the answer */ }
}
function fail(msg, code = 1) {
console.error(msg);
process.exit(code);
}
async function cmdIndex() {
const root = arg('root');
const db = arg('db');
const url = arg('url');
const model = arg('model', 'nomic-embed-text');
if (!root || !db || !url) fail('index: --root, --db and --url are required');
const quiet = flag('quiet');
let stats;
try {
stats = await buildIndex({
root, dbPath: db, url, model,
batch: parseInt(arg('batch', '32'), 10),
timeout: parseInt(arg('timeout', '120000'), 10),
force: flag('force'),
dryRun: flag('dry-run'),
onProgress: p => {
if (p.error) console.error(`embed batch failed: ${p.error}`);
else if (!quiet && p.done % 320 === 0) console.log(` embedded ${p.done}/${p.total}`);
},
});
} catch (e) {
fail(`index failed: ${e.message}`, 2);
}
if (flag('json')) { console.log(JSON.stringify(stats)); return; }
if (stats.dryRun) {
console.log(`DRY RUN — ${stats.files} file(s) would be indexed, ${stats.chunks} chunk(s) embedded`);
console.log(` ${stats.skipped} unchanged, ${stats.removed} stale entr(ies) would be dropped`);
return;
}
console.log(`indexed ${stats.files} file(s), ${stats.chunks} chunk(s) embedded`);
console.log(` ${stats.skipped} unchanged, ${stats.removed} removed, ${stats.failed} failed`);
console.log(` index now holds ${stats.total} chunk(s)`);
// A partial index is usable but not complete — say so in the exit code so a caller can act.
if (stats.failed) process.exit(3);
}
async function cmdSearch() {
const db = arg('db');
const url = arg('url');
const model = arg('model', 'nomic-embed-text');
const q = arg('query');
if (!db || !url || !q) fail('search: --db, --url and --query are required');
let r;
try {
r = await search({
dbPath: db, url, embedModel: model, query: q,
k: parseInt(arg('k', '8'), 10),
perFile: parseInt(arg('per-file', '3'), 10),
section: arg('section', null),
kind: arg('kind', null),
});
} catch (e) {
fail(`search failed: ${e.message}`, 2);
}
if (flag('json')) { console.log(JSON.stringify(r)); return; }
if (!r.results.length) { console.log('no matches'); return; }
if (r.intents.length) console.log(`intent: ${r.intents.join(', ')}\n`);
for (const x of r.results) {
const label = x.heading || x.section || '-';
console.log(`── ${x.score.toFixed(3)} ${x.path} [${x.section || x.kind}] ${label}`);
console.log(x.content.split('\n').map(l => ' ' + l).join('\n'));
console.log('');
}
}
// Retrieval + generation. The prompt is built here so the context block and the instructions
// stay in one reviewable place.
async function cmdAsk() {
const db = arg('db');
const url = arg('url');
const embed = arg('embed-model', 'nomic-embed-text');
const gen = arg('model');
const q = arg('query');
const timeout = parseInt(arg('timeout', '240000'), 10);
if (!db || !url || !gen || !q) fail('ask: --db, --url, --model and --query are required');
let r;
try {
// section and kind must be forwarded here too. They were not, so both filters worked
// under `search` and were silently ignored under `ask` — the documented
// --section=CONFIGURATION usage retrieved from the whole index and the answer looked
// plausible, which is the worst way for a filter to fail.
r = await search({
dbPath: db, url, embedModel: embed, query: q,
k: parseInt(arg('k', '6'), 10), perFile: parseInt(arg('per-file', '2'), 10),
section: arg('section', null),
kind: arg('kind', null),
});
} catch (e) {
fail(`retrieval failed: ${e.message}`, 2);
}
if (!r.results.length) fail('no relevant context found in the index', 4);
const context = r.results.map((x, i) => {
const label = [x.path, x.section, x.heading].filter(Boolean).join(' ');
return `[${i + 1}] ${label}\n${x.content}`;
}).join('\n\n');
const prompt =
`You are answering questions about Varaverk, a two-server self-healing home media ecosystem.
Answer ONLY from the context below. If the context does not contain the answer, say so plainly
and name what is missing — do not fill the gap from general knowledge about Linux, Docker or
rsync, because this system's conventions are frequently not the conventional ones.
Cite the source of each claim as [n]. Be concise and concrete.
CONTEXT
${context}
QUESTION
${q}
ANSWER`;
let res;
try {
res = await fetch(`${url.replace(/\/$/, '')}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: gen, prompt, stream: false,
options: { temperature: 0.2, num_ctx: 8192 },
}),
signal: AbortSignal.timeout(timeout),
});
} catch (e) {
fail(`generation failed: ${e.message}`, 2);
}
if (!res.ok) fail(`generation HTTP ${res.status}`, 2);
const j = await res.json();
// 'varaverk' rather than a CLI-specific name: this path retrieves and cites, so it is the
// same kind of turn the tab's default profile runs, and the two should aggregate together.
recordTokens('varaverk', j.prompt_eval_count || 0, j.eval_count || 0,
j.eval_duration > 0 ? (j.eval_count / (j.eval_duration / 1e9)) : null);
if (flag('json')) {
console.log(JSON.stringify({ answer: j.response, sources: r.results.map(x => ({ path: x.path, section: x.section, heading: x.heading, score: x.score })) }));
return;
}
console.log((j.response || '').trim());
console.log('\nSources:');
r.results.forEach((x, i) => {
console.log(` [${i + 1}] ${[x.path, x.section, x.heading].filter(Boolean).join(' ')}`);
});
}
const cmd = process.argv[2];
const table = { index: cmdIndex, search: cmdSearch, ask: cmdAsk };
if (!table[cmd]) fail(`usage: cli.js <index|search|ask> [--flags]`);
table[cmd]().catch(e => fail(e.message, 2));
+201
View File
@@ -0,0 +1,201 @@
'use strict';
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Indexer — chunk the repo, embed each chunk, store vectors in SQLite.
//
// Incremental by file mtime: a file whose mtime has not moved since its last index is skipped
// entirely, so a routine re-index costs seconds rather than re-embedding the whole corpus.
//
// Vectors are stored as raw little-endian float32 BLOBs. nomic-embed-text returns L2-normalised
// vectors, so cosine similarity is a plain dot product at query time — no normalising, no
// magnitude cache. PHP can read the same blobs with unpack('f*', $blob) when the UI needs them.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { DatabaseSync } = require('node:sqlite');
const { chunkFile, classify } = require('./chunk.js');
const SCHEMA = `
CREATE TABLE IF NOT EXISTS vv_files (
path TEXT PRIMARY KEY,
mtime INTEGER NOT NULL,
chunks INTEGER NOT NULL,
indexed INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS vv_chunks (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
kind TEXT NOT NULL,
section TEXT,
heading TEXT,
part INTEGER,
content TEXT NOT NULL,
vector BLOB NOT NULL,
indexed INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chunks_path ON vv_chunks(path);
CREATE INDEX IF NOT EXISTS idx_chunks_section ON vv_chunks(section);
CREATE INDEX IF NOT EXISTS idx_chunks_kind ON vv_chunks(kind);
CREATE TABLE IF NOT EXISTS vv_meta (k TEXT PRIMARY KEY, v TEXT);
`;
function openDb(dbPath) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA synchronous = NORMAL;');
db.exec(SCHEMA);
return db;
}
// Only ever index what git tracks. Configurations/, State_Files/ and data/ are gitignored, which
// is what makes it structurally impossible for a credential to reach the index — the files that
// hold them were never in the repo. Do not replace this with a filesystem walk.
function trackedFiles(root) {
return execSync('git ls-files', { cwd: root, maxBuffer: 1 << 26 })
.toString().trim().split('\n')
.filter(Boolean)
.filter(f => classify(f) !== 'other');
}
async function embedBatch(url, model, inputs, timeoutMs) {
const ctl = AbortSignal.timeout(timeoutMs);
const res = await fetch(`${url.replace(/\/$/, '')}/api/embed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, input: inputs }),
signal: ctl,
});
if (!res.ok) throw new Error(`embed HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
const j = await res.json();
if (!j.embeddings || j.embeddings.length !== inputs.length)
throw new Error(`embed returned ${j.embeddings ? j.embeddings.length : 0} of ${inputs.length}`);
return j.embeddings;
}
function toBlob(vec) {
return Buffer.from(Float32Array.from(vec).buffer);
}
async function buildIndex(opts) {
const {
root, dbPath, url, model,
batch = 32, timeout = 120000, force = false, dryRun = false,
onProgress = () => {},
} = opts;
const db = dryRun ? null : openDb(dbPath);
const now = Math.floor(Date.now() / 1000);
const known = new Map();
if (db) for (const r of db.prepare('SELECT path, mtime FROM vv_files').all()) known.set(r.path, r.mtime);
const files = trackedFiles(root);
const present = new Set(files);
// Files that left the repo must leave the index with them.
let removed = 0;
if (db && !force) {
for (const p of known.keys()) {
if (!present.has(p)) {
db.prepare('DELETE FROM vv_chunks WHERE path = ?').run(p);
db.prepare('DELETE FROM vv_files WHERE path = ?').run(p);
removed++;
}
}
}
if (db && force) { db.exec('DELETE FROM vv_chunks; DELETE FROM vv_files;'); }
// ── Collect the chunks that actually need embedding ───────────────────────────────────────
const pending = [];
let skipped = 0, scanned = 0;
for (const rel of files) {
const abs = path.join(root, rel);
let st;
try { st = fs.statSync(abs); } catch { continue; }
const mtime = Math.floor(st.mtimeMs / 1000);
scanned++;
if (!force && known.has(rel) && known.get(rel) === mtime) { skipped++; continue; }
let chunks = [];
try { chunks = chunkFile(abs, rel); } catch (e) { continue; }
// A file that yields no chunks still gets a vv_files row so it is not re-chunked every
// run. Queued rather than written here, so every database change lands in the single
// commit below — a run interrupted mid-embed must leave the index exactly as it was.
pending.push({ rel, mtime, chunks });
}
const totalChunks = pending.reduce((n, f) => n + f.chunks.length, 0);
if (dryRun) {
return { dryRun: true, scanned, skipped, removed, files: pending.length, chunks: totalChunks };
}
// ── Embed in batches, write per file so an interrupted run leaves a consistent index ───────
const flat = [];
for (const f of pending) for (const c of f.chunks) flat.push({ f, c });
let done = 0, failed = 0;
for (let i = 0; i < flat.length; i += batch) {
const slice = flat.slice(i, i + batch);
const inputs = slice.map(x => x.c.content);
let vecs;
try {
vecs = await embedBatch(url, model, inputs, timeout);
} catch (e) {
failed += slice.length;
onProgress({ done, total: flat.length, error: e.message });
continue;
}
slice.forEach((x, k) => { x.c.__vec = vecs[k]; });
done += slice.length;
onProgress({ done, total: flat.length });
}
const ins = db.prepare(
'INSERT INTO vv_chunks (path,kind,section,heading,part,content,vector,indexed) VALUES (?,?,?,?,?,?,?,?)'
);
const insF = db.prepare('INSERT OR REPLACE INTO vv_files VALUES (?,?,?,?)');
db.exec('BEGIN');
try {
for (const f of pending) {
// Nothing to index in this file at all — record it so it is not re-chunked next run.
if (!f.chunks.length) {
db.prepare('DELETE FROM vv_chunks WHERE path = ?').run(f.rel);
insF.run(f.rel, f.mtime, 0, now);
continue;
}
const embedded = f.chunks.filter(c => c.__vec);
// A file whose chunks all failed to embed keeps its previous rows and its old mtime,
// so the next run retries it rather than recording a half-indexed file as current.
if (!embedded.length) continue;
db.prepare('DELETE FROM vv_chunks WHERE path = ?').run(f.rel);
for (const c of embedded) {
ins.run(c.path, c.kind, c.section, c.heading, c.part, c.content, toBlob(c.__vec), now);
}
insF.run(f.rel, f.mtime, embedded.length, now);
}
db.prepare('INSERT OR REPLACE INTO vv_meta VALUES (?,?)').run('embed_model', model);
db.prepare('INSERT OR REPLACE INTO vv_meta VALUES (?,?)').run('last_index', String(now));
db.prepare('INSERT OR REPLACE INTO vv_meta VALUES (?,?)').run('dims', '768');
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
const stats = {
scanned, skipped, removed,
files: pending.length,
chunks: done,
failed,
total: db.prepare('SELECT COUNT(*) n FROM vv_chunks').get().n,
};
db.close();
return stats;
}
module.exports = { buildIndex, openDb, toBlob, trackedFiles };
+139
View File
@@ -0,0 +1,139 @@
'use strict';
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Search — embed a question, score it against the index, return the best chunks.
//
// nomic-embed-text returns L2-normalised vectors, so cosine similarity is a plain dot product.
// At this corpus size (~1.7k chunks x 768 dims) that is a couple of million multiply-adds —
// under a millisecond, with no vector database and no index structure to maintain.
//
// Section routing is the payoff from the header audit. Every chunk knows whether it is a
// PURPOSE, a DESIGN PRINCIPLES, an OPERATIONAL SAFEGUARDS and so on, so a question's shape can
// steer retrieval before similarity is even considered. It is applied as a score boost rather
// than a hard filter — intent detection is a heuristic, and a heuristic should not be able to
// exclude the one chunk that actually holds the answer.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
const { DatabaseSync } = require('node:sqlite');
// Question shape → the section most likely to answer it.
const INTENTS = [
{ section: 'OPERATIONAL SAFEGUARDS',
re: /\b(safe|safety|guard|protect|prevent|fail|failure|abort|refuse|lock|root|timeout|dry.?run|what stops|what happens if|race|corrupt|data.?loss)\b/i },
{ section: 'CONFIGURATION',
re: /\b(variable|var|setting|conf|config|threshold|toggle|which key|what controls|where is .* set|default value|env)\b/i },
{ section: 'RUNTIME MODES',
re: /\b(flag|argument|option|--\w+|how do i run|invoke|cli|command line|status mode|usage)\b/i },
{ section: 'DESIGN PRINCIPLES',
re: /\b(why|rationale|reason|design|decision|deliberate|intentional|on purpose|trade.?off|chose|approach)\b/i },
{ section: 'OPERATIONAL MODEL',
re: /\b(how does .* work|flow|sequence|order|tier|lifecycle|state machine|when does)\b/i },
{ section: 'EXPORTS',
re: /\b(function|export|api surface|what does .* provide|helper|vv_\w+)\b/i },
{ section: 'PURPOSE',
re: /\b(what is|what does .* do|purpose|responsible for|job of)\b/i },
];
const SECTION_BOOST = 0.06; // enough to reorder near-ties, not enough to beat a real match
const KIND_BOOST = 0.02; // docs answer "how do I" better than a script header does
function detectIntent(q) {
const hits = [];
for (const i of INTENTS) if (i.re.test(q)) hits.push(i.section);
return hits;
}
function blobToVec(buf) {
const b = Buffer.from(buf);
return new Float32Array(b.buffer, b.byteOffset, b.length / 4);
}
function dot(a, b) {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i] * b[i];
return s;
}
async function embedQuery(url, model, text, timeoutMs = 60000) {
const res = await fetch(`${url.replace(/\/$/, '')}/api/embed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, input: text }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`embed HTTP ${res.status}`);
const j = await res.json();
if (!j.embeddings || !j.embeddings[0]) throw new Error('embed returned no vector');
return Float32Array.from(j.embeddings[0]);
}
// Keep at most `perFile` chunks from any one file, so a single large document cannot fill the
// entire context window and crowd out a better answer living somewhere else.
function diversify(rows, k, perFile) {
const seen = new Map();
const out = [];
for (const r of rows) {
const n = seen.get(r.path) || 0;
if (n >= perFile) continue;
seen.set(r.path, n + 1);
out.push(r);
if (out.length >= k) break;
}
return out;
}
async function search(opts) {
const {
dbPath, url, embedModel, query,
k = 8, perFile = 3, section = null, kind = null, minScore = 0.0,
} = opts;
const db = new DatabaseSync(dbPath, { readOnly: true });
let sql = 'SELECT id,path,kind,section,heading,part,content,vector FROM vv_chunks';
const where = [], args = [];
if (section) { where.push('section = ?'); args.push(section); }
if (kind) { where.push('kind = ?'); args.push(kind); }
if (where.length) sql += ' WHERE ' + where.join(' AND ');
const rows = db.prepare(sql).all(...args);
if (!rows.length) { db.close(); return { results: [], intents: [], scanned: 0 }; }
const qv = await embedQuery(url, embedModel, query);
let intents = section ? [] : detectIntent(query);
// "What is Varaverk" and "what is arr_sync.sh" are not the same question, and the PURPOSE
// intent cannot tell them apart — it fires on both and boosts every PURPOSE block in the
// repository at once. There are a couple of hundred, each genuinely describing the purpose of
// something, and each a short sentence containing the word Varaverk. The project's own README
// then loses to a script that migrates storage modes, because a paragraph is more diluted
// than a one-line summary.
//
// A question that names the project and no component inside it is asking about the whole, so
// PURPOSE is precisely the wrong section to promote. Dropping only that intent, rather than
// all of them, leaves "why was Varaverk built this way" still routed to DESIGN PRINCIPLES.
const namesProject = /\bvaraverk\b/i.test(query);
const namesComponent = /\b[\w.-]+\.(sh|php|js)\b|\b[A-Z][A-Z0-9]*(_[A-Z0-9]+)+\b/.test(query);
const projectLevel = namesProject && !namesComponent;
if (projectLevel) intents = intents.filter(s => s !== 'PURPOSE');
// The same question wants the top-level prose, which is what the doc kinds are.
const wantDoc = projectLevel
|| /\b(how do i|steps|procedure|setup|install|troubleshoot|guide)\b/i.test(query);
const scored = rows.map(r => {
let s = dot(qv, blobToVec(r.vector));
if (intents.includes(r.section)) s += SECTION_BOOST;
if (wantDoc && (r.kind === 'manual' || r.kind === 'readme')) s += KIND_BOOST;
return {
id: r.id, path: r.path, kind: r.kind, section: r.section,
heading: r.heading, part: r.part, content: r.content, score: s,
};
});
scored.sort((a, b) => b.score - a.score);
const kept = diversify(scored.filter(r => r.score >= minScore), k, perFile);
db.close();
return { results: kept, intents, scanned: rows.length };
}
module.exports = { search, detectIntent, blobToVec, dot, embedQuery, INTENTS };
+670
View File
@@ -0,0 +1,670 @@
# ━━━━━ ARRS STACK — Manual ━━━━━
Config reference, procedures, operational workflows.
For overview see README-Arrs_Stack.md. For per-script detail see script headers.
---
## ━━━ ARR CLEANUP — FILE CLASSIFICATION ━━━
Every file found on disk during an arr cleanup run falls into exactly one category:
```
TRACKED → arr API returned this exact path → leave it alone
PROTECTED → matches ARR_PROTECTED_PATTERNS → never delete
ORPHAN → media extension, not tracked, old enough → delete
JUNK → not a media extension, not protected → delete (any age)
RECENT → not tracked, under ARR_ORPHAN_AGE days → skip (may be mid-import)
```
**Why protected patterns are critical:** arrs generate artwork (`*.jpg`), metadata
(`*.nfo`), and subtitles/lyrics that do NOT appear in the tracked file API response.
Without protection, these would be classified as orphans and deleted — removing cover art
from every album, every movie poster, every TV show thumbnail. Requires a full rescan
to recover. Never remove artwork extensions from protected patterns.
---
## ━━━ ARR CLEANUP — SAFETY LAYERS ━━━
All 7 layers must pass before any file is touched. There is no way to push through a
failed safety check without the explicit override flag.
```
1. Container running + healthy — a stopped container has an empty API
2. API reachable — no API = no tracked file list = everything looks orphaned
3. API version matches — major version must match tested version in master.conf
4. Item count > 0 — no artists/series/movies = something is wrong with DB
5. Tracked file count > 0 — empty response = everything would be deleted
6. Tracked count >= MIN_TRACKED_PCT — dramatic drop from last run = abort and alert
7. Deletion size < MAX_DELETE_GB — last line of defense against misconfigured root path
```
Layer 7 is the catastrophic failure prevention. A misconfigured root path — pointing
cleanup at the wrong directory — means the API returns zero tracked files for a root
that actually contains thousands. Everything walks as an orphan. Everything gets deleted.
`LIDARR/SONARR/RADARR_MAX_DELETE_GB` requires `--i-know-what-im-doing` to proceed past it.
---
## ━━━ CONFIGURATION — master.conf ━━━
### Lidarr Cleanup Thresholds
```bash
LIDARR_ORPHAN_AGE=7 # days — files newer than this are RECENT (mid-import window)
LIDARR_MIN_TRACKED_PCT=80 # abort if API returns < 80% of last known count
LIDARR_MAX_DELETE_GB=5 # require --i-know-what-im-doing above this
LIDARR_IMPORT_SCAN_TIMEOUT=600 # seconds to wait for pre-flight import scan
LIDARR_VERSION_MAJOR=3 # expected Lidarr major version (API safety check)
LIDARR_LOCK_WARN_AGE=3600 # 1hr — large libraries take time, not stuck
LIDARR_EXTENSIONS=("flac" "mp3" "m4a" "wav" "aac" "ogg" "opus" "wma")
LIDARR_PROTECTED_PATTERNS=("*.jpg" "*.jpeg" "*.png" "*.nfo" "*.lrc")
LIDARR_TRACKED_COUNT_FILE="$DATA_DIR/lidarr_tracked.count" # persistent baseline
ARR_CLEANUP_STATS="$DATA_DIR/arr_cleanup_stats.db" # read by coffee report
```
---
### Lidarr Release Fixer
```bash
LIDARR_RELEASE_FIXER_ENABLED=true # set false to disable without removing from job list
```
No additional thresholds — uses existing `LIDARR_URL`, `LIDARR_API_KEY`,
`LIDARR_MUSIC_ROOT`, `LIDARR_PATH_MAP`, and `LIDARR_VERSION_MAJOR` from host*.conf and
master.conf. Reads FLAC (vorbis comment block type 4) and MP3 (ID3v2 TXXX frame) tags.
---
### Sonarr Cleanup Thresholds
```bash
SONARR_ORPHAN_AGE=7
SONARR_MAX_DELETE_GB=50
SONARR_IMPORT_SCAN_TIMEOUT=600
SONARR_VERSION_MAJOR=4
SONARR_EXTENSIONS=("mkv" "mp4" "avi" "m4v" "ts" "wmv" "mov")
SONARR_PROTECTED_PATTERNS=("*.jpg" "*.jpeg" "*.png" "*.nfo" "*.srt" "*.sub" "*.ass" "*.ssa")
```
Note: `*.ts` IS in extensions — transport stream is used for Live TV recordings tracked
by Sonarr. Orphaned `.ts` recordings should be cleaned like any other orphaned episode.
---
### Radarr Cleanup Thresholds
```bash
RADARR_ORPHAN_AGE=7
RADARR_MAX_DELETE_GB=50
RADARR_IMPORT_SCAN_TIMEOUT=600
RADARR_VERSION_MAJOR=6
RADARR_EXTENSIONS=("mkv" "mp4" "avi" "m4v" "wmv" "mov")
RADARR_PROTECTED_PATTERNS=("*.jpg" "*.jpeg" "*.png" "*.nfo" "*.srt" "*.sub" "*.ass" "*.ssa")
```
---
### Arr Sync
```bash
ARR_SYNC_ENABLED=true
ARR_SYNC_BLOCKLIST="$DATA_DIR/arr_sync_blocklist.tsv" # tombstone file
ARR_SYNC_CONNECT_TIMEOUT=10 # SSH connect timeout in seconds
ARR_SYNC_API_TIMEOUT=60 # curl API call timeout in seconds
DOCKER_APPDATA_BASE=/mnt/user/appdata
ARR_SYNC_LIDARR_PORT=8686
ARR_SYNC_SONARR_PORT=8989
ARR_SYNC_RADARR_PORT=7878
```
---
### Arr Recovery
```bash
ARR_IMPORT_RECOVERY_AGE=6 # hours — items newer than this are skipped
SONARR_VERSION_MAJOR=4
RADARR_VERSION_MAJOR=6
LIDARR_VERSION_MAJOR=3
ARR_RECOVERY_STATS="$DATA_DIR/arr_recovery_stats.db" # read by coffee report
```
---
### TMDb / TVDB Removed
```bash
RADARR_DROPPED_ADD_EXCLUSION=true # add removed movies to Radarr import exclusion
SONARR_DROPPED_ADD_EXCLUSION=true # add removed series to Sonarr import exclusion
```
---
### Lidarr Missing Art
```bash
FANART_API_KEY="your-fanart-tv-api-key"
LASTFM_API_KEY="your-lastfm-api-key"
LIDARR_ART_MIN_SIZE=5000 # minimum valid download size in bytes
LIDARR_ART_MAX_PARALLEL=4 # concurrent background download jobs
LIDARR_ART_RETRIES=2 # download retry attempts per image
LIDARR_ART_SLEEP_BETWEEN=1 # seconds between fanart.tv API calls (rate limit)
```
---
### Lidarr Discovery
```bash
LIDARR_DISCOVERY_THRESHOLD=70 # minimum score for Stage 1 seeds and Stage 2 adds
LIDARR_DISCOVERY_LOOKBACK_DAYS=7 # Emby play history window in days
LIDARR_DISCOVERY_MIN_PLAYS=3 # min plays before an artist is evaluated as a seed
LIDARR_DISCOVERY_MAX_ADDS=5 # max seeds (Stage 1) and max adds (Stage 2) per run
LIDARR_DISCOVERY_USER_CAP_PCT=35 # max % any one user contributes to play weight
LIDARR_DISCOVERY_REJECT_COOLDOWN=30 # days before re-evaluating a Stage 2 reject
LIDARR_DISCOVERY_HISTORY="$DATA_DIR/lidarr_discovery_history.db"
```
Requires `HOST*_LASTFM_API_KEY` in `host*.conf`.
---
### Radarr Discovery
```bash
RADARR_DISCOVERY_THRESHOLD=52 # minimum score to add a candidate
RADARR_DISCOVERY_LOOKBACK_DAYS=30 # Emby watch history window in days
RADARR_DISCOVERY_MAX_SEEDS=5 # max seed movies from Stage 1
RADARR_DISCOVERY_MAX_ADDS=5 # max movies to add per run
RADARR_DISCOVERY_MIN_VOTE_COUNT=100 # min TMDB votes for a candidate
RADARR_DISCOVERY_MIN_RATING=60 # min TMDB vote_average × 10 (60 = 6.0/10)
RADARR_DISCOVERY_REJECT_COOLDOWN=60 # days before re-evaluating a rejected movie
RADARR_DISCOVERY_SEED_LIBRARIES=("Movies")
RADARR_DISCOVERY_HISTORY="$DATA_DIR/radarr_discovery_history.db"
```
Requires `HOST*_TMDB_API_KEY` in `host*.conf`.
---
### Sonarr Discovery
```bash
SONARR_DISCOVERY_THRESHOLD=52 # minimum score to add a candidate
SONARR_DISCOVERY_LOOKBACK_DAYS=14 # Emby episode history window in days
SONARR_DISCOVERY_MAX_SEEDS=5 # max seed series from Stage 1
SONARR_DISCOVERY_MAX_ADDS=3 # max shows to add per run (TV is a larger commitment)
SONARR_DISCOVERY_MIN_VOTE_COUNT=50 # min TMDB votes for a candidate
SONARR_DISCOVERY_MIN_RATING=65 # min TMDB vote_average × 10 (65 = 6.5/10)
SONARR_DISCOVERY_REJECT_COOLDOWN=60 # days before re-evaluating a rejected show
SONARR_DISCOVERY_USER_EPISODE_CAP=8 # max episodes per user in seed scoring
SONARR_DISCOVERY_MONITOR_MODE="all" # "all" = all seasons monitored; "future" = upcoming only
SONARR_DISCOVERY_HISTORY="$DATA_DIR/sonarr_discovery_history.db"
```
Requires `HOST*_TMDB_API_KEY` in `host*.conf`.
> **MONITOR_MODE note:** Use `"all"` (default) to have Sonarr search all existing seasons
> after adding a show. `"future"` only marks upcoming seasons as monitored — shows where
> all seasons have already aired will appear unmonitored and Sonarr will not search for them.
---
### Upgrade Webhook
```bash
WEBHOOK_PORT=7821 # 0 = disable listener
WEBHOOK_SECRET="" # auto-generated on first start if empty
```
When `WEBHOOK_PORT=0`, `start_webhook_listener.sh` exits cleanly and no listener starts.
When `WEBHOOK_SECRET` is empty, a 32-byte hex secret is generated on first start and
written back to `master.conf`. Run `Tools/webhook_setup.sh` to register the URL in arrs.
Webhook log: `/var/log/varaverk/upgrade_webhook.log`
---
## ━━━ CONFIGURATION — host*.conf ━━━
```bash
# Arr connection details — must match arr settings exactly
HOST1_LIDARR_URL="http://192.168.50.2:8686"
HOST1_LIDARR_API_KEY="..."
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music"
HOST1_LIDARR_PATH_MAP="" # container→host path translation if needed
HOST1_SONARR_URL="http://192.168.50.2:8989"
HOST1_SONARR_API_KEY="..."
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
HOST1_SONARR_PATH_MAP=""
HOST1_RADARR_URL="http://192.168.50.2:7878"
HOST1_RADARR_API_KEY="..."
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
HOST1_RADARR_PATH_MAP=""
HOST1_EMBY_URL="http://192.168.50.2:8096"
HOST1_EMBY_API_KEY="..."
```
> **LIDARR/SONARR/RADARR_MUSIC/TV/MOVIES_ROOT must exactly match the Root Folder path in
> the arr's own settings.** Arr UI → Settings → Media Management → Root Folders.
> A mismatch means every file on disk looks untracked — all appear as orphans.
> MAX_DELETE_GB is the only thing standing between a path mismatch and losing your library.
---
## ━━━ SAFE TESTING PROCEDURE ━━━
> **The arr cleanup scripts permanently delete files.** There is no recycle bin, no undo.
> Follow this procedure on first use, after any root path change, after any API key change,
> and after any significant arr library change.
### Step 1 — Dry Run With Full Logging
```bash
lidarr_release_fixer.sh --dry-run --log
lidarr_cleanup.sh --dry-run --log
sonarr_cleanup.sh --dry-run --log
radarr_cleanup.sh --dry-run --log
```
### Step 2 — Review the Output
```
Release fixer:
Are the "would fix" albums expected?
→ Wrong release UUIDs switching to correct ones is expected behaviour
→ If everything is already correct, nothing to do
Cleanup:
Are TRACKED files the ones you expect?
→ Known arr-managed files should show as TRACKED
→ If they show as ORPHAN, the root path is wrong — STOP
Is the ORPHAN count reasonable?
→ Healthy cleanup removes dozens to hundreds, not tens of thousands
→ Large count = stop, investigate root path before proceeding
Are PROTECTED patterns working?
→ Artwork (*.jpg) and subtitles (*.srt) must show as PROTECTED
→ If they show as ORPHAN, check PROTECTED_PATTERNS config
Are RECENT files being correctly skipped?
→ Files downloaded in the last 7 days should show as RECENT, not ORPHAN
```
### Step 3 — Check Numbers if Something Looks Wrong
```bash
# Root path mismatch? Compare these:
# Lidarr UI: Settings → Media Management → Root Folders
# Sonarr UI: Settings → Media Management → Root Folders
# Radarr UI: Settings → Media Management → Root Folders
# Must exactly match LIDARR_MUSIC_ROOT / SONARR_TV_ROOT / RADARR_MOVIES_ROOT
# Is the arr running?
docker ps | grep -E "Lidarr|Sonarr|Radarr"
```
### Step 4 — Run Live
```bash
# Only after dry run review passes.
lidarr_release_fixer.sh
lidarr_cleanup.sh
sonarr_cleanup.sh
radarr_cleanup.sh
```
### Step 5 — Verify in Arr UI
```
Library count — should not have dropped significantly
Missing files — check if any monitored content shows as missing
Emby library — should show no ghost entries (notify_emby_scan handles this automatically)
```
---
## ━━━ PROCEDURES ━━━
### Adding a New Arr
```bash
# 1. Copy radarr_cleanup.sh as template
cp Arrs_Stack/radarr_cleanup.sh Arrs_Stack/readarr_cleanup.sh
# 2. Replace RADARR_ prefix with READARR_ throughout
# Update API endpoint, tracked file API path, extension list, protected patterns
# 3. Add to host*.conf
HOST1_READARR_URL="http://192.168.50.2:8787"
HOST1_READARR_API_KEY="your-api-key"
HOST1_READARR_BOOKS_ROOT="/mnt/user/Books"
# 4. Add thresholds to master.conf
READARR_ORPHAN_AGE=7
READARR_MAX_DELETE_GB=50
READARR_EXTENSIONS=("epub" "pdf" "mobi" "azw3" "cbz" "cbr")
READARR_PROTECTED_PATTERNS=("*.jpg" "*.jpeg" "*.png" "*.nfo")
# 5. Add to DAILY_MAINTENANCE_SCRIPTS in master.conf
"Arrs_Stack/readarr_cleanup.sh"
```
Run `--dry-run --log` before scheduling.
---
### Managing the Arr Sync Blocklist
```bash
# Add item to blocklist (removes from all arrs + tombstones the ID)
arr_sync.sh --blocklist-add lidarr <musicbrainz-artist-id> "reason"
arr_sync.sh --blocklist-add sonarr <tvdb-series-id> "reason"
arr_sync.sh --blocklist-add radarr <tmdb-movie-id> "reason"
# Remove from blocklist (un-tombstones the ID — does NOT re-add to arrs)
arr_sync.sh --blocklist-remove lidarr <id>
# View all blocklisted IDs
arr_sync.sh --blocklist-list
```
`--blocklist-add` is the only destructive operation — it simultaneously:
1. Writes the tombstone entry to the blocklist TSV file
2. Deletes the item from the local arr API (no file deletion)
3. SSHes each remote node and deletes from their arr API
Files become orphans on all nodes — arr_cleanup removes them on the next run.
---
## ━━━ TROUBLESHOOTING ━━━
### Arr Cleanup Deleting Files It Shouldn't
```
1. Check the protected patterns — artwork and subtitles must be listed
LIDARR_PROTECTED_PATTERNS / SONARR_PROTECTED_PATTERNS / RADARR_PROTECTED_PATTERNS
2. Check the root path matches arr settings exactly
Run: lidarr_cleanup.sh --status (shows configured root path)
Compare: Lidarr UI → Settings → Media Management → Root Folders
3. Check if files are truly orphaned
Run: lidarr_cleanup.sh --dry-run --log
Look for the specific file — verify it shows ORPHAN, not PROTECTED or TRACKED
```
### Lidarr Files Present But Not Importing
```
Symptom: album folder exists with properly tagged files, Lidarr shows 0 tracks imported.
Cause: Lidarr selected the wrong MusicBrainz release edition. The file's MBID doesn't
match Lidarr's foreignReleaseId, so track ID lookup fails.
Fix:
lidarr_release_fixer.sh --dry-run --log
→ Shows which albums would be corrected and which release UUIDs would change
lidarr_release_fixer.sh
→ Applies corrections and queues RefreshArtist for affected artists
If the file MBID doesn't match any Lidarr release (shows "MBID not in list"):
→ File was tagged from a source Lidarr doesn't know about (unofficial, re-tagged)
→ Manual import or re-tag with correct MBID
```
### Arr Cleanup Aborting at Safety Layer 6 (Tracked Count Drop)
```
API returned far fewer tracked files than last run.
Possible causes:
- Arr database was recently rebuilt from scratch
- Large manual library removal
- Path map mismatch after arr migration
If intentional (library intentionally reduced):
Delete LIDARR_TRACKED_COUNT_FILE to reset the baseline
Run cleanup once — it will establish a new baseline
If unintentional:
Investigate before proceeding — the arr may have a problem
```
### Arr Sync Not Picking Up New Content
```
Is ARR_SYNC_ENABLED=true in master.conf?
Can this host SSH to the remote without password?
→ ssh -i [SSH_KEY] root@[remote-tailscale-ip] "hostname"
Is the arr accessible on the remote?
→ arr_sync.sh --status (shows each node's arr reachability)
→ arr_sync.sh --log (verbose output per-node, per-arr)
Is the item in the blocklist?
→ arr_sync.sh --blocklist-list
```
### Emby Still Showing Ghost Entries After Cleanup
```
notify_emby_scan() is called automatically after every arr cleanup deletion.
If ghosts persist:
1. Is Emby's API responding?
curl -s "http://[emby-ip]:8096/System/Info/Public"
2. Is EMBY_URL / EMBY_API_KEY correct in host*.conf?
Run: sonarr_cleanup.sh --status (shows Emby config)
3. Trigger manually in Emby:
Library → Manage Library → Clean Missing Files
```
---
## ━━━ OUTPUT TIERS ━━━
All scripts have two output levels controlled by `--log`.
Without `--log`, each script processes silently and always concludes with a summary
block: identity, duration, counts (files removed, items added, arrs cleaned), and a
status line. Warnings and errors are always visible.
With `--log`, per-item detail appears: individual titles being processed, API query
progress, per-node sync results, and per-file examination output. Use this when
debugging unexpected results or validating configuration before the first scheduled run.
Dry-run output follows the same tiers — `--dry-run` alone shows the summary of what
would happen; `--dry-run --log` shows the full per-item preview list.
---
## ━━━ FLAG REFERENCE ━━━
### lidarr_release_fixer.sh
`lidarr_release_fixer.sh`
Read MUSICBRAINZ_ALBUMID from FLAC/MP3 files, correct Lidarr release selection, queue RefreshArtist.
`lidarr_release_fixer.sh --dry-run`
Show which albums would be corrected and the before/after release UUIDs. No API writes.
`lidarr_release_fixer.sh --status`
Show configuration, Lidarr URL, enabled state.
`lidarr_release_fixer.sh --log`
Verbose per-album output — shows every album checked, not just those corrected.
---
### lidarr_cleanup.sh / sonarr_cleanup.sh / radarr_cleanup.sh
`[script] --dry-run --log`
Preview every classification decision. **Always run this first.** See Safe Testing Procedure.
`[script]`
Live run — deletes confirmed orphans and junk, triggers Emby clean.
`[script] --log`
Live run with verbose per-file output.
`[script] --status`
Show configuration, API status, tracked file count, and last run stats.
`[script] --i-know-what-im-doing`
Bypass the MAX_DELETE_GB size threshold. Required when deletion exceeds the configured
limit. Long flag name is intentional — cannot be added accidentally.
`[script] --skip-age-check`
Bypass the ORPHAN_AGE age check. Deletes RECENT files too — files that are under the
age threshold. Use when you know recent downloads are actually orphans.
`[script] --i-know-what-im-doing --skip-age-check`
**NUCLEAR MODE** — age check and size threshold both bypassed. Deletes on first pass.
Use when you want a clean one-pass wipe of everything the arr doesn't track.
No recovery possible after deletion.
---
### arrs_failed_stalled_recovery.sh
`arrs_failed_stalled_recovery.sh`
Check all configured arrs for failed imports and stalled downloads. Blocklist + remove +
re-search for each problem item.
`arrs_failed_stalled_recovery.sh --dry-run`
Show what would be actioned per arr without making any changes.
`arrs_failed_stalled_recovery.sh --status`
Show configuration, arr reachability, and last recovery stats.
`arrs_failed_stalled_recovery.sh --log`
Verbose output per item per arr.
---
### arr_sync.sh
`arr_sync.sh`
Sync all arr types across all configured nodes.
`arr_sync.sh --dry-run`
Show what would be added/removed on each node without making changes.
`arr_sync.sh --status`
Show node configuration, arr reachability, and blocklist count.
`arr_sync.sh --log`
Verbose per-node, per-arr output.
`arr_sync.sh --blocklist-add [arr] [id] "[reason]"`
Remove item from all arrs and tombstone the ID. See Procedures above.
`arr_sync.sh --blocklist-remove [arr] [id]`
Remove tombstone — does NOT re-add item to arrs.
`arr_sync.sh --blocklist-list`
Show all tombstoned IDs.
---
### radarr_tmdb_removed.sh / sonarr_tvdb_removed.sh
`[script]`
Remove records for entries with status="deleted" (dropped from upstream database).
Files are kept. Import exclusion is added.
`[script] --delete-files`
Also delete associated files from disk. Most dropped entries have no files — they were
announced movies/series that were never downloaded.
`[script] --dry-run`
Preview what would be removed without making changes.
`[script] --status`
Show arr connection status and current count of dropped entries.
`[script] --log`
Verbose per-entry output.
---
### lidarr_missing_art.sh
`lidarr_missing_art.sh`
Fetch all missing album and artist artwork from fanart.tv and fallback sources.
Never overwrites existing files.
`lidarr_missing_art.sh --dry-run`
Show what would be downloaded without writing any files.
`lidarr_missing_art.sh --status`
Show configuration and API key status.
`lidarr_missing_art.sh --log`
Verbose per-album, per-artist output.
---
### playback_aware_lidarr_discovery.sh
`playback_aware_lidarr_discovery.sh`
Score Emby play history, run Last.fm getSimilar on top artists, add candidates above
threshold to Lidarr. Triggers ArtistSearch immediately after each successful add.
`playback_aware_lidarr_discovery.sh --dry-run`
Score and rank all Stage 1 seeds and Stage 2 candidates. No Lidarr API calls. No writes
to history file. Shows exactly what would be added and at what score.
`playback_aware_lidarr_discovery.sh --status`
Show config values, history file path and size, and API key status.
`playback_aware_lidarr_discovery.sh --log`
Verbose per-artist scoring output for both stages.
---
### playback_aware_radarr_discovery.sh
`playback_aware_radarr_discovery.sh`
Score recently watched Emby movies, run TMDB recommendations on seeds, add candidates
above threshold to Radarr. Triggers MoviesSearch immediately after each successful add.
`playback_aware_radarr_discovery.sh --dry-run`
Score and rank all Stage 1 seeds and Stage 2 candidates. No Radarr API calls. No writes
to history file.
`playback_aware_radarr_discovery.sh --status`
Show config values, history file path and size, and API key status.
`playback_aware_radarr_discovery.sh --log`
Verbose per-movie scoring output for both stages.
---
### playback_aware_sonarr_discovery.sh
`playback_aware_sonarr_discovery.sh`
Score recently watched Emby series (weighted by user diversity), run TMDB TV
recommendations on seeds, add candidates above threshold to Sonarr. Triggers SeriesSearch
immediately after each successful add.
`playback_aware_sonarr_discovery.sh --dry-run`
Score and rank all Stage 1 seeds and Stage 2 candidates. No Sonarr API calls. No writes
to history file.
`playback_aware_sonarr_discovery.sh --status`
Show config values, history file path and size, and API key status.
`playback_aware_sonarr_discovery.sh --log`
Verbose per-series scoring output — shows user diversity, recency, and volume scores per
seed; breadth, rating, and votes scores per candidate.
+319
View File
@@ -0,0 +1,319 @@
# ━━━━━ ARRS STACK ━━━━━
Lifecycle management for the arr suite (Lidarr, Sonarr, Radarr) across a multi-server
ecosystem. Library sync so every node tracks the same content. Orphan cleanup against live
arr APIs so deleted content actually leaves disk. Release correction so wrong MusicBrainz
editions don't silently block Lidarr imports. Emby notified automatically after every
deletion. Failed downloads recovered overnight. Artwork fetched continuously. Database
hygiene for dropped upstream entries. Quality upgrades propagate to all nodes immediately
via webhook. Weekly discovery adds new content based on what you actually play and watch.
> **These scripts permanently delete files and modify arr databases.** Orphan cleanup is
> protected by multiple safety layers that must all pass before anything is touched — but
> dry runs and log review are still the right first step on any new system or after any
> configuration change. See Manual-Arrs_Stack.md for the safe testing procedure.
---
## ━━━ THE PROBLEMS THAT BUILT THIS ━━━
**Deleted Shows and Removed Albums Still on Disk**
When you remove a series from Sonarr and the delete command fails — permission issue,
container wasn't running, path mismatch — the files stay permanently. Over years on an
active library this accumulates significantly.
The fix: arr cleanup scripts query the live API for every tracked file path, walk the
disk, and delete anything absent from the API response that's old enough to be past the
import window.
**Wrong MusicBrainz Release Edition Silently Blocking Lidarr Imports**
Lidarr selects one specific release edition per album using a MusicBrainz release ID.
When it picks the wrong edition (Brazil CD instead of US CD, Japan Digital instead of
standard), the track IDs don't match what's embedded in the files. RescanFolders reports
"Importing 0 tracks" even with perfectly tagged, complete files present. No error — just
silence.
The fix: `lidarr_release_fixer.sh` reads the MUSICBRAINZ_ALBUMID tag from each file,
finds the matching release in Lidarr's known releases, switches the selection, and queues
a RefreshArtist. Runs before cleanup so corrected albums are imported before the orphan
scan ever sees them.
**Emby Showing Ghost Entries After Cleanup**
After arr cleanup deletes files, Emby still shows them until its next scheduled scan —
potentially hours later. Users see broken entries that produce "file not found" errors.
The fix: `notify_emby_scan()` is called automatically after every deletion. Triggers
Emby's "Clean Missing Files" task immediately.
**No Safety Net on Deletion Size**
A misconfigured root path — pointing cleanup at the wrong directory — means the API
returns zero tracked files for a root that actually contains thousands. Every file walks
as an orphan. Everything gets deleted. This is the catastrophic failure mode.
The fix: `LIDARR/SONARR/RADARR_MAX_DELETE_GB` — if total deletion size exceeds the
limit, the script stops and requires `--i-know-what-im-doing` to proceed. The flag name
is long and annoying by design. It cannot be added by accident.
**Failed Downloads Accumulating Silently**
Import failures and stalled downloads sit in arr queues indefinitely. Without
intervention they occupy queue slots, block new searches, and the item never gets
downloaded. Checking queues manually across three arrs is tedious.
The fix: `arrs_failed_stalled_recovery.sh` inspects all arr queues, blocklists the bad
release, removes it, and triggers a re-search. Runs daily.
---
## ━━━ WHAT THIS FOLDER DOES ━━━
**Library Sync**
`arr_sync.sh` — full-mesh arr library sync across all nodes. Every node syncs with every
other — union model, no hierarchy. Once arrs agree on what to track, rsync spreads the
actual files.
**Release Correction**
`lidarr_release_fixer.sh` — reads MUSICBRAINZ_ALBUMID from FLAC and MP3 files, matches
against Lidarr's known releases per album, switches monitored=true to the correct
edition, queues RefreshArtist. Runs before lidarr_cleanup.sh daily. Handles both FLAC
(vorbis comment block) and MP3 (ID3v2 TXXX frame).
**Orphan Cleanup**
`lidarr_cleanup.sh`, `sonarr_cleanup.sh`, `radarr_cleanup.sh` — API-verified orphan
removal. Five classification categories (TRACKED/PROTECTED/ORPHAN/JUNK/RECENT), seven
safety layers, automatic Emby notification after deletion.
**Database Hygiene**
`radarr_tmdb_removed.sh`, `sonarr_tvdb_removed.sh` — remove entries that upstream
databases have dropped (TMDb/TVDB status="deleted"). These generate health warnings in
arrs and can never be monitored or downloaded. Most are announced-but-never-released
entries. Files are kept by default — most have none.
**Library Recovery**
`arrs_failed_stalled_recovery.sh` — detect and recover failed imports and stalled
downloads across all arrs. Blocklists the bad release and triggers a re-search — hands-
free overnight recovery.
**Library Enrichment**
`lidarr_missing_art.sh` — fetch missing album and artist artwork from fanart.tv and
fallback sources. Never overwrites existing files.
**Upgrade Propagation**
`start_webhook_listener.sh` — Node.js HTTP server that receives Sonarr/Radarr/Lidarr
OnUpgrade webhooks. Continuous; started at array start. Writes to
`/var/log/varaverk/upgrade_webhook.log`.
`upgrade_webhook_handler.sh` — triggered by the webhook listener. Pushes the upgraded
item folder to every remote node immediately, then triggers an arr library rescan on
each remote so the upgraded file is accepted without triggering a redundant quality search.
**Discovery**
`playback_aware_lidarr_discovery.sh` — behavior-driven music discovery. Scores your
Emby play history, runs Last.fm getSimilar on top artists, adds the best matches to
Lidarr. 05 meaningful adds per week.
`playback_aware_radarr_discovery.sh` — behavior-driven movie discovery. Scores recently
watched movies, runs TMDB recommendations on seeds, adds top candidates to Radarr.
`playback_aware_sonarr_discovery.sh` — behavior-driven TV discovery. Scores recently
watched series weighted by user diversity, runs TMDB TV recommendations on seeds, adds
top shows to Sonarr. Multi-user design: one person binge-watching does not dominate seeds.
---
## ━━━ EXECUTION ORDER ━━━
**Daily via `daily_sync_maintenance.sh` (DAILY_MAINTENANCE_SCRIPTS):**
```
1. lidarr_release_fixer.sh — correct wrong release editions before cleanup sees them
2. lidarr_cleanup.sh — orphan removal (music)
3. sonarr_cleanup.sh — orphan removal (TV)
4. radarr_cleanup.sh — orphan removal (movies)
5. lidarr_missing_art.sh — fetch missing artwork (HOST1 only)
6. radarr_tmdb_removed.sh — remove TMDb-dropped movies
7. sonarr_tvdb_removed.sh — remove TVDB-dropped series
```
Note: `media_shares_permissions.sh` and `media_cleaner.sh` run before these from
`Media/` — permissions and junk removal must complete first.
**Every 30 min + 4hr via orchestrators (CRITICAL/INTERMEDIATE_MAINTENANCE_SCRIPTS):**
```
arrs_failed_stalled_recovery.sh — failed/stalled queue recovery
arr_sync.sh — library sync across all nodes
```
**Weekly via `weekly_sync_maintenance.sh` (WEEKLY_MAINTENANCE_SCRIPTS):**
```
playback_aware_lidarr_discovery.sh — score play history → Last.fm similar → Lidarr
playback_aware_radarr_discovery.sh — score watch history → TMDB recommendations → Radarr
playback_aware_sonarr_discovery.sh — score episode history → TMDB TV → Sonarr
```
**Continuous (started by `array_started.sh`):**
```
start_webhook_listener.sh — Node.js webhook server; dispatches upgrade_webhook_handler.sh
```
**On every arr upgrade (triggered by webhook):**
```
upgrade_webhook_handler.sh — push upgraded folder to all remote nodes + trigger arr rescan
```
**Why release fixer before cleanup:** the fixer corrects Lidarr's release selection so
files get imported. If cleanup ran first, a correctable album could accumulate age toward
the orphan threshold before the fixer had a chance to fix it.
**Why arr_sync before rsync:** once arrs agree on what to track, rsync spreads the actual
files. An upgrade on one node — new tracked path, old path no longer in API — gets
cleaned by arr_cleanup on all nodes after the next sync cycle.
---
## ━━━ HOST AWARENESS ━━━
Scripts run on both servers via `detect_hosts()`, which aliases all `HOST*_` prefixed vars
to their unprefixed names at runtime. No manual `HOST1`/`HOST2` comparisons exist in any
script.
`arr_sync.sh` keeps all arr databases in bidirectional union — either server can download
to any share. Arr cleanup uses the union model: a file is only an orphan if the arr on
this host doesn't have it indexed. Arr scripts check the aliased URL — if empty (arr not
configured on this host), they exit cleanly with no action.
`lidarr_release_fixer.sh` and `lidarr_missing_art.sh` exit cleanly on hosts without
Lidarr configured — no HOST1_LIDARR_URL means nothing runs.
---
## ━━━ SCRIPTS IN THIS FOLDER ━━━
| Script | Role | When It Runs |
|--------|------|--------------|
| `arr_sync.sh` | Full-mesh arr library sync — all nodes track the same content | Every 4hr + weekly before rsync |
| `lidarr_release_fixer.sh` | Fix wrong MusicBrainz release editions so files get imported | Daily before lidarr_cleanup |
| `lidarr_cleanup.sh` | Delete orphaned music files not tracked by Lidarr | Daily |
| `sonarr_cleanup.sh` | Delete orphaned TV files not tracked by Sonarr | Daily |
| `radarr_cleanup.sh` | Delete orphaned movie files not tracked by Radarr | Daily |
| `arrs_failed_stalled_recovery.sh` | Auto-recover failed imports and stalled downloads | Every 30 min / daily |
| `lidarr_missing_art.sh` | Fetch missing album and artist artwork | Daily (HOST1 only) |
| `radarr_tmdb_removed.sh` | Remove movies dropped from TMDb | Daily |
| `sonarr_tvdb_removed.sh` | Remove series dropped from TVDB | Daily |
| `start_webhook_listener.sh` | Node.js webhook server — receive arr OnUpgrade and dispatch handler | Continuous |
| `upgrade_webhook_handler.sh` | Push upgraded item folder to remote nodes + trigger arr rescan | On each arr upgrade |
| `playback_aware_lidarr_discovery.sh` | Behavior-driven music discovery — Emby plays → Last.fm similar → Lidarr | Weekly |
| `playback_aware_radarr_discovery.sh` | Behavior-driven movie discovery — Emby watches → TMDB recommendations → Radarr | Weekly |
| `playback_aware_sonarr_discovery.sh` | Behavior-driven TV discovery — Emby episodes → TMDB TV recommendations → Sonarr | Weekly |
| `arr_download_orphan_cleaner.sh` | Clear orphaned completed downloads out of the SABnzbd Completed folders | Daily |
| `sonarr_classification_scan.sh` | Detect series sitting in the wrong root (anime / kids / general); `--move` acts | Daily |
| `radarr_classification_scan.sh` | Same for movies, plus junk-metadata detection via `--remove-junk` | Daily |
| `lidarr_duplicate_artist_cleanup.sh` | Remove phantom zero-file duplicate artists; flag real ones for review | Daily |
| `arr_cache_prefill.sh` | Warm the shared tracked-data cache so consumers never read cold | Array start + every 4hr |
| `arr_corruption_scan.sh` | ffprobe every tracked video for corrupt headers; `--remediate` deletes + re-searches | Weekly |
| `arr_full_rescan.sh` | Force a real disk↔database reconciliation on all three arrs | Weekly |
---
## ━━━ THE NEWER LAYERS ━━━
The original folder was "delete what the arrs no longer track". These were added as distinct
failure modes surfaced — each exists because something went wrong that the cleanups could not
have caught.
### 🗑️ Download-Side Orphans — `arr_download_orphan_cleaner.sh`
Every cleanup script here walks the **library** side. Nothing walked the **download** side —
so completed downloads the arrs had stopped tracking accumulated in SABnzbd's Completed
folders indefinitely. Discovered as **755 GB of orphaned TV downloads, oldest from 2022**,
filling the cache pool to 89%.
Classifies every entry as TRACKED / RECENT / JUNK / REDUNDANT / IMPORTABLE / UNMATCHED and
acts only on the ones it can justify. The queue is a hard gate: if it cannot be read, the arr
is skipped entirely, because without it there is no way to tell an active import from an
orphan. A run total over `DOWNLOAD_ORPHAN_MAX_DELETE_GB` aborts — an abnormally large delete
is the visible symptom of a partial queue fetch.
### 🎭 Wrong-Root Detection — `sonarr_classification_scan.sh` + `radarr_classification_scan.sh`
Overseerr lets any user request content into the wrong root folder — kids shows into general
TV, anime into Kids_Tv_Shows. These classify every item from metadata alone (genre,
certification, network/studio, original language) and report where the computed classification
disagrees with the folder the item actually sits in.
Report-only by default. `--move` acts on forward misplacements and adult-content-in-kids-root
leaks. It deliberately does **not** move non-anime content out of the anime root — deliberate
style placements (Western animation grouped with anime by choice) are genuine judgment calls.
Both poll the arr's async move command to completion before verifying, because `moveFiles=true`
flips the database instantly while the physical move is still queued behind others.
### 🎨 Phantom Artists — `lidarr_duplicate_artist_cleanup.sh`
MusicBrainz duplicates leave two Lidarr entries for one artist, one holding the files and one
holding nothing. Removes only the zero-file side, with `deleteFiles=false` so nothing on disk
is touched. Pairs where both sides hold files are flagged for review, never auto-resolved.
Gated on the tracked-count floor shared with `lidarr_cleanup.sh` — during a library-wide desync
both sides of a real duplicate can read as zero-file phantoms.
### 🩺 Corruption + Reconciliation — `arr_corruption_scan.sh` + `arr_full_rescan.sh`
`arr_corruption_scan.sh` ffprobes tracked video files for corrupt headers. Report-only unless
`--remediate`, which deletes the file record and triggers an explicit re-search. Requires
repeat detections across separate runs before acting, so a transient probe failure cannot
delete a healthy file.
`arr_full_rescan.sh` forces a genuine disk↔database reconciliation. Organic scans only touch
files involved in an import, so an untouched library silently drifts — confirmed when Lidarr
reported **~23% of its true track count** for 1,004 of 1,357 artists with no scan running and
every file present on disk.
### ⚡ Cache Warmth — `arr_cache_prefill.sh`
Populates the shared tracked-data cache at array start and every 4 hours, so consumers never
pay a cold fetch. Pure enhancement: nothing depends on it having run, and every consumer still
writes through on a cold cache.
---
## ━━━ HOW THE SCRIPTS RELATE ━━━
```
Every 4hr / weekly (arr sync before rsync):
arr_sync.sh ──────────────── syncs tracked IDs across all nodes
│ union model: any node adds → all nodes get it
└── then rsync spreads the actual files to all nodes
└── then arr_cleanup removes orphans on all nodes (old paths, removed content)
Daily maintenance window:
[Media/media_shares_permissions.sh + media_cleaner.sh run first — from Media/]
lidarr_release_fixer.sh ────── reads MBID tag → switches release in Lidarr → RefreshArtist
│ (corrected albums get imported before cleanup scans for orphans)
lidarr_cleanup.sh ──────────── queries Lidarr API → walks /Music → deletes orphans
sonarr_cleanup.sh ──────────── queries Sonarr API → walks /Tv_Shows → deletes orphans
radarr_cleanup.sh ──────────── queries Radarr API → walks /Movies → deletes orphans
└── each cleanup → notify_emby_scan() → Emby removes ghost entries
Daily recovery:
arrs_failed_stalled_recovery.sh ── importFailed/stalled → blocklist → re-search
Weekly discovery (WEEKLY_MAINTENANCE_SCRIPTS):
playback_aware_lidarr_discovery.sh ─ Emby plays → Last.fm similar → top candidates → Lidarr
playback_aware_radarr_discovery.sh ─ Emby watches → TMDB recommendations → top candidates → Radarr
playback_aware_sonarr_discovery.sh ─ Emby episodes → TMDB TV recommendations → top candidates → Sonarr
└── each discovery script fires arr search immediately after successful add
Continuous (started by array_started.sh):
start_webhook_listener.sh ── Node.js HTTP server listens on WEBHOOK_PORT
│ arr OnUpgrade fires webhook → POST to http://HOST_LAN_IP:WEBHOOK_PORT/webhook?key=SECRET
└── upgrade_webhook_handler.sh
├── rsync upgraded folder → all remote nodes immediately
└── trigger arr library rescan on each remote (accept new file, no quality search)
Ad-hoc enrichment:
lidarr_missing_art.sh ─────── discovers missing artwork → fetches from fanart.tv
radarr_tmdb_removed.sh ────── status="deleted" → remove from Radarr + add exclusion
sonarr_tvdb_removed.sh ────── status="deleted" → remove from Sonarr + add exclusion
```
+182
View File
@@ -0,0 +1,182 @@
#!/bin/bash
# ==============================================================================================
# ================================= Arr Cache Prefill ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Populates the shared tracked-data cache (see arr_get_tracked_data() in common.sh) for
# Lidarr, Sonarr, and Radarr. Without this, each arr's cache stays cold until whichever
# script happens to touch that arr first writes through — which could be hours, depending
# on the daily schedule. Originally Lidarr-only (lidarr_cache_prefill.sh, 2026-07-16),
# generalized the same day to cover all three arrs once the cache mechanism itself was
# generalized.
#
# Runs on two schedules (2026-07-17): once at array start (closes the cold-boot gap, 10min
# wait ceiling for slow-starting containers) and again every 30min via
# CRITICAL_MAINTENANCE_SCRIPTS with a 1min wait ceiling (ARR_PREFILL_WAIT_MINUTES=1 override)
# — a live fetch+write takes seconds, so there's no reason to tolerate the boot-time wait on
# a recurring job. This is what keeps the cache-first consumers' data reliably under 30min
# old instead of only refreshing whenever some other script happens to write through.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Each arr's container may still be starting when this fires (array just started) — retries
# reaching that arr's API for up to ARR_PREFILL_WAIT_MINUTES before giving up on it and moving
# to the next. Not fatal if one never comes up in time; that arr's cache just stays cold until
# the next script writes through naturally, exactly as it would without this script existing.
# An arr not configured on this host (e.g. Lidarr is HOST1-only) is skipped cleanly.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Warm the Cache, Never Own It
# This script only pre-populates what arr_get_tracked_data() would fetch on demand
# anyway. Nothing depends on it having run — every consumer still writes through on a
# cold cache. It removes latency and staleness, it is not a dependency.
#
# Failure Is a No-Op, Not an Error
# An arr that never comes up in time simply leaves its cache cold, exactly as if this
# script did not exist. That is why a missed prefill is logged rather than notified —
# the fallback path is the normal path.
#
# Wait Ceiling Matched to the Trigger
# The array-start run tolerates a long wait because containers are genuinely still
# starting. The 30-minute recurring run does not, because a live fetch takes seconds
# and a long wait there would only serve to overlap the next tick.
#
# Per-Arr Independence
# Each arr is prefilled on its own. One unconfigured or slow-starting arr never
# prevents the other two from being warmed.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root required — chown-free here, but matches convention across Arrs_Stack/
# jq required — skips the whole run cleanly (not fatal) if jq is missing
# acquire_lock — prevents two invocations of this script overlapping (default strict
# mode) — matters if the boot-time run is still waiting on a slow
# container when the first 30min critical-tier tick fires
# Reachability retry — tolerates a slow-starting container up to ARR_PREFILL_WAIT_MINUTES;
# never fatal if one never comes up, that arr's cache just stays cold
# Active-rescan check — skips an arr this cycle if a rescan-type command is running, instead
# of a live fetch arr_cache_write() would refuse to persist anyway
# (2026-07-17) — avoids wasted API calls during a long rescan
# Per-arr isolation — one arr failing or timing out never blocks or fails the others
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY
# HOST*_SONARR_URL / HOST*_SONARR_API_KEY
# HOST*_RADARR_URL / HOST*_RADARR_API_KEY
# All aliased by detect_hosts()
#
# master.conf
# ARR_PREFILL_WAIT_MINUTES — how long to retry reaching each arr before giving up on it
# (default 10). The CRITICAL_MAINTENANCE_SCRIPTS entry overrides this to 1 via parse_args'
# VAR=VAL mechanism for the 30min recurring run — the 10min default is sized for cold boot,
# not a job that fires every half hour.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# arr_cache_prefill.sh
# Normal run — populates all three arr caches, or skips cleanly per-arr as described
# under OPERATIONAL SAFEGUARDS above. No --dry-run/--status mode: this script only ever
# reads and writes cache, there's no destructive action to preview and no separate state
# worth inspecting beyond the cache files themselves (see Tools/arr_rescan_monitor.sh
# --status for cache age/active-rescan inspection).
#
# arr_cache_prefill.sh --log
# Verbose — per-arr detail as each one is checked/fetched/skipped.
#
# arr_cache_prefill.sh ARR_PREFILL_WAIT_MINUTES=1
# Override the reachability-retry ceiling for this run only (parse_args VAR=VAL
# mechanism) — this is how CRITICAL_MAINTENANCE_SCRIPTS invokes it every 30min.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
warn "jq not found — skipping arr cache prefill"
exit 0
fi
acquire_lock
detect_hosts
WAIT_MINUTES="${ARR_PREFILL_WAIT_MINUTES:-10}"
# Args: arr_type, url, api_key, api_version
_prefill_one() {
local arr_type="$1" url="$2" api_key="$3" api_version="$4"
if [[ -z "$url" ]] || [[ -z "$api_key" ]]; then
info "${arr_type^} not configured on $MY_ID ($LOCAL_SERVER_NAME) — nothing to prefill"
return 0
fi
local waited=0
until check_api "$url" "${arr_type^}" 5 >/dev/null 2>&1; do
if [[ "$waited" -ge $(( WAIT_MINUTES * 60 )) ]]; then
warn "${arr_type^} not reachable after ${WAIT_MINUTES}m — leaving cache cold, next script will write through"
return 0
fi
sleep 15
(( waited += 15 ))
done
# Skip cleanly if a rescan-type command is already active — arr_cache_write() below
# would refuse to persist a mid-rescan snapshot anyway (2026-07-17 guard), so fetching
# it live first would just be a wasted API call every time this fires during a long
# rescan. Matches the same check arr_full_rescan.sh and the cleanup scripts already do.
local active_cmd
active_cmd=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version")
if [[ -n "$active_cmd" ]]; then
info "${arr_type^} mid-rescan ($active_cmd) — skipping this cycle, cache stays as-is"
return 0
fi
local endpoint="${ARR_LIBRARY_ENDPOINT[$arr_type]:-}"
if [[ -z "$endpoint" ]]; then
warn "No library endpoint known for ${arr_type} — skipping"
return 0
fi
local items
items=$(arr_api "$url" "$api_key" "$api_version" "$endpoint" "${arr_type^}") || {
warn "Could not fetch ${arr_type} library for cache prefill — leaving cache cold"
return 0
}
if arr_cache_write "$arr_type" "$items"; then
log "$ICON_DONE ${arr_type^} cache prefilled ($(echo "$items" | jq 'length') items)"
else
warn "Failed to write ${arr_type} cache prefill"
fi
}
_prefill_one "lidarr" "${LIDARR_URL:-}" "${LIDARR_API_KEY:-}" "v1"
_prefill_one "sonarr" "${SONARR_URL:-}" "${SONARR_API_KEY:-}" "v3"
_prefill_one "radarr" "${RADARR_URL:-}" "${RADARR_API_KEY:-}" "v3"
exit 0
+789
View File
@@ -0,0 +1,789 @@
#!/bin/bash
# ==============================================================================================
# ============================ Arr Corruption Scan ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Scans Sonarr's and Radarr's tracked video files for corrupt headers (ffprobe-based, same
# detection method as the third-party Healarr tool) and, in --remediate mode, deletes the bad
# file from the owning arr and explicitly triggers a search to replace it.
#
# Built after Healarr crashed mid-scan on a genuine Go concurrency bug (unsynchronized
# map access when multiple corruption events land at once — confirmed via its own crash
# log, not fixable from our side). The core idea (scan → delete → re-search) isn't hard to
# replicate; the fix here is architectural: this script processes one file at a time,
# strictly sequential, so the race condition that killed Healarr can't happen — there's
# nothing running concurrently to race.
#
# Sonarr-only originally (2026-07-18/19); Radarr/Movies coverage added 2026-07-21 as a second
# arr in the same per-file scan/strike/remediate loop, not a separate script — the detection,
# strike, and state-file logic is identical, only the API shape (episodefile vs moviefile,
# EpisodeSearch vs MoviesSearch) differs. Radarr's moviefile list is fetched batched
# (movieId=... query params, BATCH_SIZE at a time) rather than off the movie list's embedded
# .movieFile alone — Radarr supports a second tracked file per movie (alternate editions/
# extras) that never shows up there, same gap radarr_cleanup.sh hit and fixed 2026-07-19;
# reusing that batched-fetch shape here instead of the simpler single-file read so a
# corruption scan doesn't silently skip every alternate edition in the library.
#
# ==============================================================================================
# WHY A SEPARATE CONTAINER FOR FFPROBE
# ==============================================================================================
#
# Neither Sonarr nor Radarr bundle ffprobe. ffprobe runs via `docker exec` into a
# different container that does — confirmed live 2026-07-18:
# Jellyfin — working ffprobe, mounts every share Emby does (Tv_Shows, Movies, kids/
# anime shares, standup) as of 2026-07-18
# Emby — mounts everything too, but its bundled ffprobe binary is broken
# (2017-dated, fails to exec — likely a missing dynamic linker
# dependency, not something to fix here)
# Jellyfin is what's configured (HOST*_FFPROBE_CONTAINER).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Runs Sonarr then Radarr, sequentially, never parallel — one arr failing/unconfigured never
# blocks the other. Within each arr, one file at a time, in this order per file:
# 1. Skip if unchanged (mtime+size) since the last time it verified clean — state file
# avoids re-probing the entire library every run, which would take far too long at
# this library size (90k+ tracked files).
# 2. ffprobe via `docker exec` into FFPROBE_CONTAINER. Empty stderr + exit 0 = clean.
# Anything else = corrupt (same signature as Healarr: "Invalid data found when
# processing input", EBML header errors, etc.)
# 3. Report-only by default. --remediate additionally:
# a. DELETE the specific episodefile/moviefile record via the arr's API
# b. Verify hasFile flipped false (never trust the DELETE response alone)
# c. Explicitly trigger EpisodeSearch/MoviesSearch for that episode/movie — this is
# deliberate, not left to the arr's own background missing-search cycle, because
# that cycle skips unmonitored items entirely. An explicit search call does not
# have that restriction (confirmed live: two unmonitored episodes Healarr healed
# both still got successfully re-grabbed via this exact same kind of search call,
# logged in Sonarr's history as "UserInvokedSearch").
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Detect Always, Act Only on Request
# A bare run probes and reports. Deleting a file the arr believes it has is a
# destructive act, so it requires --remediate explicitly. The scan can be scheduled
# weekly and read without any risk of it removing media on its own.
#
# One Probe Result Is Not Evidence
# ffprobe can fail for reasons that have nothing to do with the file — a mid-write
# import, an NFS blip, a container restart. Corruption must be observed
# CORRUPTION_SCAN_STRIKE_LIMIT times consecutively before remediation acts, and a
# single clean re-probe resets the counter.
#
# Skip-Cache Over Re-Probing
# At 90k+ tracked files a full re-probe every run is not viable. Files unchanged by
# mtime and size since they last verified clean are skipped, so each run spends its
# time on what actually changed rather than re-proving the library from scratch.
#
# Delete the Record, Let the Arr Re-Acquire
# Remediation removes the file record and explicitly triggers a search. The arr is
# left to obtain a good copy through its normal path — this script never tries to
# repair a file in place.
#
# Explicit Search, Not the Background Cycle
# The re-search is triggered directly rather than left to the arr's own missing-search
# cycle, because that cycle skips unmonitored items entirely and would silently leave
# an unmonitored corrupt file deleted and never replaced.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# docker exec into the ffprobe container requires root.
#
# Lock Acquisition
# acquire_lock prevents overlapping runs. Two instances would both probe and could
# both count a strike against the same file, reaching the limit in half the intended
# number of observations.
#
# Host Detection
# detect_hosts() aliases the arr URLs, API keys and HOST*_FFPROBE_CONTAINER.
#
# jq Dependency Check
# Fails fast if jq is missing — the tracked-file lists and every hasFile verification
# are parsed with it.
#
# Report-Only Default
# Nothing is deleted without --remediate.
#
# ffprobe Configuration Check
# Exits cleanly if FFPROBE_CONTAINER / FFPROBE_BIN are unconfigured for this host.
#
# ffprobe Container Health Check
# check_container_health() verifies the container is running and healthy before any
# probing. Every probe is a docker exec into it — if it is stopped or unhealthy every
# exec fails, every file reads as corrupt, and two consecutive runs would clear the
# strike limit and hand --remediate the whole library to delete.
#
# API Reachability + Version Gate
# check_api then check_arr_version per arr. A version mismatch skips that arr rather
# than issuing deletes against an API whose file-record endpoints may have moved.
#
# Per-Arr Isolation
# Sonarr and Radarr run sequentially, and one failing, unconfigured or version-
# mismatched arr never blocks the other.
#
# Unmapped Path Skip
# Files whose arr-side path cannot be mapped into the ffprobe container's mount
# namespace are skipped and counted, never probed through a wrong path and never
# treated as corrupt because the probe could not see them.
#
# Strike Threshold
# CORRUPTION_SCAN_STRIKE_LIMIT consecutive corrupt detections are required before
# --remediate deletes anything. A transient ffprobe failure cannot trigger a delete,
# and a clean re-probe clears the counter.
#
# Post-Delete Verification
# The DELETE response is never trusted. hasFile is re-checked and must have flipped
# false before the re-search is issued, so a failed delete never leaves the arr
# searching for something it still believes it has.
#
# Targeted Deletion
# Only the specific episodefile/moviefile record for the corrupt file is removed —
# never the series, movie, or any sibling file.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
# SONARR_URL / SONARR_API_KEY, RADARR_URL / RADARR_API_KEY — existing, aliased by
# detect_hosts(). Either arr missing its URL/key is skipped, not fatal.
# HOST*_FFPROBE_CONTAINER — container name with a working ffprobe binary
# HOST*_FFPROBE_BIN — full path to that binary inside the container
# HOST*_FFPROBE_PATH_MAP — host path prefix → that container's internal path prefix
# (separate from the arrs' own path maps — the ffprobe
# container almost certainly mounts shares differently)
#
# master.conf
# CORRUPTION_SCAN_STATE_FILE — path to the clean-file skip-cache (default in DATA_DIR),
# shared across both arrs — keyed by host path, which never
# collides between a Sonarr and a Radarr share
# CORRUPTION_SCAN_STRIKES_FILE — path to the consecutive-corrupt-detection counter (default
# in DATA_DIR), keyed by host path
# CORRUPTION_SCAN_STRIKE_LIMIT — consecutive corrupt detections required before --remediate
# acts on a file (default 2) — guards against a one-off
# ffprobe hiccup (mid-write file, NFS blip) triggering an
# unnecessary delete+re-search. Resets on a clean re-probe.
# SONARR_VERSION_MAJOR / RADARR_VERSION_MAJOR — reused from sonarr_cleanup.sh/
# radarr_cleanup.sh for the API version check
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# arr_corruption_scan.sh — report-only, scans everything not yet
# verified clean (Sonarr then Radarr)
# arr_corruption_scan.sh --remediate — delete + re-search on every corrupt file found
# arr_corruption_scan.sh --limit=50 — cap EACH arr to 50 newly-probed files this run
# (state file makes repeat runs cheap regardless,
# but useful for a bounded first test)
# arr_corruption_scan.sh --log — verbose (prints every clean file too)
# arr_corruption_scan.sh --status — show config and exit
# arr_corruption_scan.sh --filter=Becker — only consider paths containing this substring
# (testing/targeting a specific show/movie; state
# file and everything else behaves normally)
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# --remediate / --limit are script-local, not recognized by parse_args — check raw args
# before they get filtered.
REMEDIATE=false
SCAN_LIMIT=0
PATH_FILTER=""
for _arg in "$@"; do
case "$_arg" in
--remediate) REMEDIATE=true ;;
--limit=*) SCAN_LIMIT="${_arg#*=}" ;;
--filter=*) PATH_FILTER="${_arg#*=}" ;;
esac
done
unset _arg
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for arr API calls"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
exit 1
fi
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
acquire_lock "wait"
TMP_DIR="/tmp/arr_corruption_scan_$$"
mkdir -p "$TMP_DIR"
trap "_release_all_locks; rm -rf $TMP_DIR" EXIT
detect_hosts
# FFPROBE_* aren't part of the shared arr alias set in detect_hosts() — resolve them here,
# same eval-based pattern build_arr_path_map() uses for the associative array.
FFPROBE_CONTAINER_VAR="${MY_ID}_FFPROBE_CONTAINER"
FFPROBE_CONTAINER="${!FFPROBE_CONTAINER_VAR:-}"
FFPROBE_BIN_VAR="${MY_ID}_FFPROBE_BIN"
FFPROBE_BIN="${!FFPROBE_BIN_VAR:-}"
declare -A FFPROBE_PATH_MAP=()
_fp_map_var="${MY_ID}_FFPROBE_PATH_MAP"
eval "for key in \"\${!${_fp_map_var}[@]}\"; do
FFPROBE_PATH_MAP[\"\$key\"]=\"\${${_fp_map_var}[\$key]}\"
done"
unset _fp_map_var
if [[ -z "$FFPROBE_CONTAINER" || -z "$FFPROBE_BIN" ]]; then
error "FFPROBE_CONTAINER/FFPROBE_BIN not configured on $MY_ID — skipping"
exit 0
fi
# Every probe is a docker exec into this container. If it is stopped or unhealthy, every
# exec fails, every file reads as corrupt, and two such runs would clear the strike limit
# and hand --remediate an entire library to delete. Abort before probing anything.
check_container_health "$FFPROBE_CONTAINER" "${DOCKER_TIMEOUT:-30}" "Arr Corruption Scan"
CORRUPTION_SCAN_STATE_FILE="${CORRUPTION_SCAN_STATE_FILE:-$DATA_DIR/corruption_scan_state.tsv}"
mkdir -p "$(dirname "$CORRUPTION_SCAN_STATE_FILE")"
touch "$CORRUPTION_SCAN_STATE_FILE"
CORRUPTION_SCAN_STRIKES_FILE="${CORRUPTION_SCAN_STRIKES_FILE:-$DATA_DIR/corruption_scan_strikes.tsv}"
CORRUPTION_SCAN_STRIKE_LIMIT="${CORRUPTION_SCAN_STRIKE_LIMIT:-2}"
CORRUPTION_SCAN_MAX_CORRUPT_PCT="${CORRUPTION_SCAN_MAX_CORRUPT_PCT:-10}"
CORRUPTION_SCAN_MAX_CONSECUTIVE="${CORRUPTION_SCAN_MAX_CONSECUTIVE:-15}"
CORRUPTION_SCAN_GUARD_MIN_SCANNED="${CORRUPTION_SCAN_GUARD_MIN_SCANNED:-20}"
mkdir -p "$(dirname "$CORRUPTION_SCAN_STRIKES_FILE")"
touch "$CORRUPTION_SCAN_STRIKES_FILE"
# Thin wrappers around common.sh's wd_state_get/wd_state_set — same shape as
# stability_watchdog.sh's get_strikes/set_strikes/increment_strikes/reset_strikes, keyed here
# by host path instead of a watchdog check name. Requires repeat corrupt detections across
# separate scan runs before --remediate acts, so a one-off ffprobe hiccup (mid-write file,
# NFS blip) can't trigger an unnecessary delete+re-search on its own.
get_scan_strikes() {
wd_state_get "$1" "$CORRUPTION_SCAN_STRIKES_FILE"
}
set_scan_strikes() {
wd_state_set "$1" "$2" "$CORRUPTION_SCAN_STRIKES_FILE"
}
increment_scan_strikes() {
local current
current=$(get_scan_strikes "$1")
[[ -z "$current" ]] && current=0
(( current++ ))
set_scan_strikes "$1" "$current"
echo "$current"
}
reset_scan_strikes() {
local current
current=$(get_scan_strikes "$1")
[[ -n "$current" && "$current" != "0" ]] && set_scan_strikes "$1" 0
}
# Bails out of the whole run without committing anything. Safe to call at any point before
# the commit phase: strikes are queued in memory until then, so an abort leaves the strike
# file exactly as the previous run left it and deletes nothing.
abort_scan() {
local why="$1"
error "Corruption scan ABORTED — $why"
error "No strikes recorded and nothing remediated this run — the library was not trusted."
[[ -n "${FRESH_CLEAN_TMP:-}" ]] && rm -f "$FRESH_CLEAN_TMP"
notify "Corruption scan aborted on $(hostname) ($MY_ID) — $why. Nothing deleted." \
"Arr Corruption Scan" "warning"
exit 1
}
# Per-arr API shape differences — everything else in the scan/strike/remediate loop below is
# identical between Sonarr and Radarr.
declare -A ARR_FILE_ENDPOINT=( [sonarr]="episodefile" [radarr]="moviefile" )
declare -A ARR_PARENT_ENDPOINT=( [sonarr]="episode" [radarr]="movie" )
declare -A ARR_SEARCH_COMMAND=( [sonarr]="EpisodeSearch" [radarr]="MoviesSearch" )
declare -A ARR_SEARCH_ID_FIELD=( [sonarr]="episodeIds" [radarr]="movieIds" )
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
for arr in sonarr radarr; do
url_var="${arr^^}_URL"
echo "$ICON_GEAR ${arr^} URL: ${!url_var:-not configured}"
done
echo "$ICON_GEAR FFprobe container: $FFPROBE_CONTAINER"
echo "$ICON_GEAR FFprobe binary: $FFPROBE_BIN"
echo "$ICON_GEAR FFprobe path map: ${#FFPROBE_PATH_MAP[@]} entries"
echo "$ICON_GEAR State file: $CORRUPTION_SCAN_STATE_FILE"
echo "$ICON_GEAR Strike limit: $CORRUPTION_SCAN_STRIKE_LIMIT"
echo "$ICON_GEAR Remediate: $REMEDIATE"
echo "$ICON_GEAR Corrupt ceiling: ${CORRUPTION_SCAN_MAX_CORRUPT_PCT}% of scanned (min ${CORRUPTION_SCAN_GUARD_MIN_SCANNED} scanned)"
echo "$ICON_GEAR Consecutive trip: $CORRUPTION_SCAN_MAX_CONSECUTIVE"
echo "$ICON_GEAR Scan limit: ${SCAN_LIMIT:-unlimited} (per arr)"
echo "$ICON_GEAR Path filter: ${PATH_FILTER:-none}"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
[[ "$REMEDIATE" == true ]] && warn "REMEDIATE MODE — corrupt files will be deleted and re-searched" \
|| info "Report-only — pass --remediate to act"
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
check_container_health "$FFPROBE_CONTAINER" 15 "Corruption Scan"
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Translates a host filesystem path to FFPROBE_CONTAINER's internal path via prefix match
# against FFPROBE_PATH_MAP. Empty output (return 1) means this file's share isn't covered
# by the ffprobe container yet — caller must skip, not guess.
ffprobe_translate_path() {
local host_path="$1" prefix
for prefix in "${!FFPROBE_PATH_MAP[@]}"; do
if [[ "$host_path" == "$prefix"/* ]]; then
echo "${FFPROBE_PATH_MAP[$prefix]}${host_path#$prefix}"
return 0
fi
done
return 1
}
# Probes one file. Echoes "clean" or "corrupt:<reason>". Never trusts a truncated/garbled
# stderr as automatically corrupt — only a real non-empty ffprobe stderr counts.
probe_file() {
local host_path="$1" container_path output rc
container_path=$(ffprobe_translate_path "$host_path") || { echo "unmapped"; return; }
output=$(docker exec "$FFPROBE_CONTAINER" "$FFPROBE_BIN" -v error "$container_path" 2>&1)
rc=$?
# docker exec writes its own failures to the same stream ffprobe uses, so a stopped
# container or an unreachable daemon is otherwise indistinguishable from a corrupt
# header. A stopped container exits 1 with a daemon message; a missing binary exits
# 127 — neither is evidence about the file, so both must be caught.
if (( rc >= 125 )) \
|| [[ "$output" == "Error response from daemon:"* \
|| "$output" == "Cannot connect to the Docker daemon"* \
|| "$output" == "error during connect:"* ]]; then
echo "probe_error:${output//$'\n'/ }"
return
fi
if [[ -z "$output" ]]; then
echo "clean"
elif (( rc != 0 )); then
# ffprobe could not parse the file — EBML header parsing failed, moov atom not found,
# contradictionary STSC and STCO. This is the only class that may be remediated.
echo "corrupt:${output//$'\n'/ }"
else
# Exit 0 with stderr output: a recoverable muxing complaint, most commonly
# "Referenced QT chapter track not found", which many recent .mp4 releases emit and
# which says nothing about playability. Equating any stderr with corruption is what
# produced 103 "corrupt" files on 2026-08-23 — 28 of 43 newly scanned Radarr items.
# Reported for visibility, never strike-tracked, never remediated.
echo "suspect:${output//$'\n'/ }"
fi
}
# Appends one arr_api() call's output to a batch file, but ONLY on success. arr_api() prints
# its own error message via error() (a plain `echo`, i.e. stdout, not stderr) on any non-200
# response — appending its raw output unconditionally means a single failed batch call (one
# bad seriesId/movieId batch out of hundreds) mixes a plain-text error line into what's
# otherwise a stream of valid JSON arrays, and `jq -s` then fails to parse the WHOLE file,
# turning one bad batch into zero usable files for the entire arr. Confirmed live 2026-07-21:
# Sonarr seriesId=650 returned HTTP 404 (stale/deleted series reference) mid-walk, and that
# single 404's error text corrupted the full 138MB/1177-series concatenated batch, silently
# zeroing out the whole Sonarr scan for that run. Capturing output first and gating the
# append on the actual exit code isolates one bad call to just that call.
_arr_api_append_on_success() {
local out
out=$(arr_api "$1" "$2" "$3" "$4" "$5" 2>/dev/null)
[[ $? -eq 0 ]] && echo "$out" >> "$6"
}
# Fetches every movie's file(s) via Radarr's moviefile endpoint, batched (movieId=X repeated
# query param, BATCH_SIZE at a time — a single whole-library request 414s, confirmed live by
# radarr_cleanup.sh 2026-07-19). Deliberately not just the movie list's embedded .movieFile —
# that only ever has the primary file, missing Radarr's second-tracked-file-per-movie feature
# (alternate editions/extras). Echoes the raw moviefile JSON array (id/movieId/path per item).
fetch_radarr_items() {
local movies_now
movies_now=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr" 2>/dev/null)
local _ids=() _id _qs="" _batch_count=0
local BATCH_SIZE=200 # 250 confirmed working live 2026-07-19 by radarr_cleanup.sh, margin kept
mapfile -t _ids < <(echo "$movies_now" | jq -r '.[] | select(.hasFile==true) | .id' 2>/dev/null)
local all_tmp; all_tmp=$(mktemp)
for _id in "${_ids[@]}"; do
_qs+="movieId=${_id}&"
(( _batch_count++ ))
if [[ "$_batch_count" -ge "$BATCH_SIZE" ]]; then
_arr_api_append_on_success "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" "$all_tmp"
_qs=""
_batch_count=0
fi
done
if [[ -n "$_qs" ]]; then
_arr_api_append_on_success "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" "$all_tmp"
fi
jq -s 'add // []' "$all_tmp" 2>/dev/null
rm -f "$all_tmp"
}
# ==============================================================================================
# ━━━ Load clean-file state (skip cache) ━━━
# ==============================================================================================
declare -A CLEAN_STATE
while IFS=$'\t' read -r _s_path _s_stamp; do
[[ -n "$_s_path" ]] && CLEAN_STATE["$_s_path"]="$_s_stamp"
done < "$CORRUPTION_SCAN_STATE_FILE"
unset _s_path _s_stamp
info "Loaded ${#CLEAN_STATE[@]} previously-verified-clean entries"
# Merges a fresh-clean-stamps temp file into the persistent state file, newest wins per path —
# reading fresh entries first (before the old base file) means the first occurrence tac/awk
# keeps is always the newest one for any path re-verified this run. Called once per arr
# (immediately after that arr's scan, not batched to the very end of the whole script) so a
# hard-exit partway through the NEXT arr — check_container_health()/check_arr_version() both
# exit 1 directly on a real failure, not just return — can never wipe out the previous arr's
# already-computed clean state for this run.
merge_clean_state() {
local fresh_tmp="$1" state_tmp
state_tmp=$(mktemp)
cat "$fresh_tmp" "$CORRUPTION_SCAN_STATE_FILE" | awk -F'\t' '!seen[$1]++' | sort > "$state_tmp"
mv "$state_tmp" "$CORRUPTION_SCAN_STATE_FILE"
}
TOTAL_SCANNED=0
TOTAL_CORRUPT=0
TOTAL_REMEDIATED=0
TOTAL_REMEDIATE_FAILED=0
declare -A ARR_SCANNED ARR_SKIPPED_CACHED ARR_SKIPPED_UNMAPPED ARR_CORRUPT ARR_SUSPECT ARR_PROBE_ERRORS ARR_STRIKE_HELD ARR_REMEDIATED ARR_REMEDIATE_FAILED
for arr in sonarr radarr; do
url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY"
# Named arr_url/arr_key, not url/key — build_arr_path_map() below uses a non-local
# `for key in ...` loop internally (iterating FFPROBE/path-map prefixes) and would
# silently clobber a plain $key with its last loop value otherwise. Confirmed live
# 2026-07-21: this exact collision fed a path-map prefix ("/ext-anime-shows") to Sonarr's
# API calls as the X-Api-Key header instead of the real key, making every Sonarr call
# this loop made fail with 401 while looking like a connectivity problem.
arr_url="${!url_var:-}"; arr_key="${!key_var:-}"
if [[ -z "$arr_url" || -z "$arr_key" ]]; then
info "${arr^} not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
continue
fi
echo ""
echo " ${arr^}$arr_url"
build_arr_path_map "${arr^^}"
check_container_health "${arr^}" 15 "Corruption Scan"
ver_var="${arr^^}_VERSION_MAJOR"
check_arr_version "$arr_url" "$arr_key" "v3" "${!ver_var}" "${arr^}" || {
warn "${arr^} version check failed — skipping this arr"
continue
}
# ──────────────────────────────────────────────────────────────────────────────────────
# Fetch tracked files, normalized to {path, file_id, parent_id, title} regardless of arr —
# everything past this point is arr-agnostic.
# ──────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SYNC Fetching ${arr^} Tracked Files ━━━"
if [[ "$arr" == "sonarr" ]]; then
# Prefer the shared per-episode-file cache written by sonarr_cleanup.sh (has
# id/episodeId/seriesId/path already) — falls back to a live per-series walk only on
# a genuine miss.
RAW_ITEMS=$(arr_get_cached_items "sonarr" 14400)
if [[ -z "$RAW_ITEMS" || "$RAW_ITEMS" == "null" ]]; then
info "No fresh cached episode-file data — fetching live (this is the slow path)"
SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$arr_url" "$arr_key" "v3") || {
error "Failed to fetch series from Sonarr — skipping this arr"
continue
}
SERIES_IDS=$(echo "$SERIES_RESPONSE" | jq -r '.[].id')
all_tmp=$(mktemp)
while IFS= read -r sid; do
[[ -z "$sid" ]] && continue
_arr_api_append_on_success "$arr_url" "$arr_key" "v3" "episodefile?seriesId=${sid}" "Sonarr" "$all_tmp"
done <<< "$SERIES_IDS"
RAW_ITEMS=$(jq -s 'add // []' "$all_tmp" 2>/dev/null)
rm -f "$all_tmp"
arr_item_cache_write "sonarr" "$RAW_ITEMS"
fi
ITEMS=$(echo "$RAW_ITEMS" | jq -c \
'[.[] | {path, file_id:.id, parent_id:.episodeId, title:(.sceneName // .relativePath // .path)}]')
else
RAW_ITEMS=$(arr_get_cached_items "radarr" 14400)
if [[ -z "$RAW_ITEMS" || "$RAW_ITEMS" == "null" ]]; then
info "No fresh cached movie-file data — fetching live (this is the slow path)"
RAW_ITEMS=$(fetch_radarr_items)
arr_item_cache_write "radarr" "$RAW_ITEMS"
fi
ITEMS=$(echo "$RAW_ITEMS" | jq -c \
'[.[] | {path, file_id:.id, parent_id:.movieId, title:(.sceneName // .relativePath // .path)}]')
fi
ITEM_COUNT=$(echo "$ITEMS" | jq 'length' 2>/dev/null)
if [[ -z "$ITEM_COUNT" || "$ITEM_COUNT" -eq 0 ]]; then
error "0 tracked files for ${arr^} — skipping this arr"
continue
fi
info "$ITEM_COUNT tracked files"
# ──────────────────────────────────────────────────────────────────────────────────────
# Scan
# ──────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_CLEAN Scanning ━━━"
SCANNED=0
SKIPPED_CACHED=0
SKIPPED_UNMAPPED=0
CORRUPT_COUNT=0
STRIKE_HELD=0
REMEDIATED=0
REMEDIATE_FAILED=0
PROBE_ERRORS=0
SUSPECT_COUNT=0
CONSECUTIVE_BAD=0
QUEUE_PATH=()
QUEUE_STRIKES=()
QUEUE_ITEM=()
FRESH_CLEAN_TMP=$(mktemp)
while IFS= read -r item; do
api_path=$(echo "$item" | jq -r '.path')
file_id=$(echo "$item" | jq -r '.file_id')
parent_id=$(echo "$item" | jq -r '.parent_id')
host_path=$(translate_path "$api_path")
[[ -f "$host_path" ]] || continue
[[ -n "$PATH_FILTER" && "$host_path" != *"$PATH_FILTER"* ]] && continue
stamp="$(stat -c '%Y:%s' "$host_path" 2>/dev/null)"
[[ -z "$stamp" ]] && continue
if [[ "${CLEAN_STATE[$host_path]:-}" == "$stamp" ]]; then
(( SKIPPED_CACHED++ ))
continue
fi
(( SCANNED++ ))
if [[ "$SCAN_LIMIT" -gt 0 && "$SCANNED" -gt "$SCAN_LIMIT" ]]; then
(( SCANNED-- ))
break
fi
result=$(probe_file "$host_path")
if [[ "$result" == "unmapped" ]]; then
(( SKIPPED_UNMAPPED++ ))
[[ "$ENABLE_LOGGING" == true ]] && warn " ? $host_path — no FFPROBE_PATH_MAP entry covers this share"
continue
fi
# A docker-level failure is not evidence about the file. Count it, never queue it.
if [[ "$result" == probe_error:* ]]; then
(( PROBE_ERRORS++ ))
(( CONSECUTIVE_BAD++ ))
warn " ? $host_path — probe failed, NOT counted as corrupt: ${result#probe_error:}"
if (( CONSECUTIVE_BAD >= CORRUPTION_SCAN_MAX_CONSECUTIVE )); then
abort_scan "$CONSECUTIVE_BAD files in a row failed to probe cleanly"
fi
continue
fi
if [[ "$result" == "clean" ]]; then
CONSECUTIVE_BAD=0
reset_scan_strikes "$host_path"
echo -e "${host_path}\t${stamp}" >> "$FRESH_CLEAN_TMP"
[[ "$ENABLE_LOGGING" == true ]] && echo " $ICON_SUCCESS $host_path"
continue
fi
# A successful probe that merely warned. Proves the container is alive, so it clears
# the consecutive-failure tripwire, but it never becomes a strike.
if [[ "$result" == suspect:* ]]; then
CONSECUTIVE_BAD=0
(( SUSPECT_COUNT++ ))
[[ "$ENABLE_LOGGING" == true ]] && warn " ~ $host_path — ffprobe warning (exit 0), NOT corrupt: ${result#suspect:}"
continue
fi
# corrupt:<reason> — queued, NOT committed. Nothing reaches the strike file and nothing
# is deleted until this arr has been fully probed and the guards below have passed. A
# container that dies mid-scan makes every remaining file read as corrupt, and a delete
# cannot be undone — so the destructive half has to wait until the corrupt rate for the
# whole run is known. 2026-08-23: one Jellyfin restart produced 103 false positives.
reason="${result#corrupt:}"
(( CORRUPT_COUNT++ ))
(( CONSECUTIVE_BAD++ ))
prev_strikes=$(get_scan_strikes "$host_path")
prev_strikes="${prev_strikes//[^0-9]/}"
strikes=$(( ${prev_strikes:-0} + 1 ))
QUEUE_PATH+=("$host_path")
QUEUE_STRIKES+=("$strikes")
QUEUE_ITEM+=("$item")
echo " $ICON_ERROR CORRUPT: $host_path (strike $strikes/$CORRUPTION_SCAN_STRIKE_LIMIT)"
[[ "$ENABLE_LOGGING" == true ]] && echo " $reason"
if (( CONSECUTIVE_BAD >= CORRUPTION_SCAN_MAX_CONSECUTIVE )); then
abort_scan "$CONSECUTIVE_BAD files in a row failed to probe cleanly"
fi
done < <(echo "$ITEMS" | jq -c '.[]')
# ━━━ False-positive guards — run before anything is committed ━━━
if (( CORRUPT_COUNT > 0 )); then
# The pre-flight check only proves the container was up when the scan started.
# Re-check now: a mid-scan death is exactly what this guard exists to catch.
check_container_health "$FFPROBE_CONTAINER" "${DOCKER_TIMEOUT:-30}" "Arr Corruption Scan"
if (( SCANNED >= CORRUPTION_SCAN_GUARD_MIN_SCANNED )); then
corrupt_pct=$(( CORRUPT_COUNT * 100 / SCANNED ))
if (( corrupt_pct >= CORRUPTION_SCAN_MAX_CORRUPT_PCT )); then
abort_scan "$CORRUPT_COUNT of $SCANNED probed files (${corrupt_pct}%) read as corrupt — at or above the ${CORRUPTION_SCAN_MAX_CORRUPT_PCT}% ceiling"
fi
fi
fi
# ━━━ Guards passed — commit strikes, then remediate whatever reached the limit ━━━
for _q in "${!QUEUE_PATH[@]}"; do
host_path="${QUEUE_PATH[$_q]}"
strikes="${QUEUE_STRIKES[$_q]}"
item="${QUEUE_ITEM[$_q]}"
set_scan_strikes "$host_path" "$strikes"
[[ "$REMEDIATE" != true ]] && continue
if (( strikes < CORRUPTION_SCAN_STRIKE_LIMIT )); then
warn " $host_path — strike $strikes/$CORRUPTION_SCAN_STRIKE_LIMIT, not yet remediating (needs repeat confirmation)"
(( STRIKE_HELD++ ))
continue
fi
reset_scan_strikes "$host_path"
file_id=$(echo "$item" | jq -r '.file_id')
parent_id=$(echo "$item" | jq -r '.parent_id')
title=$(echo "$item" | jq -r '.title')
http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
--max-time 15 -H "X-Api-Key: $arr_key" \
"${arr_url}/api/v3/${ARR_FILE_ENDPOINT[$arr]}/${file_id}" 2>/dev/null)
if [[ "$http_code" != "200" ]]; then
error "$title — delete failed (HTTP $http_code)"
(( REMEDIATE_FAILED++ ))
continue
fi
sleep 2
verify_hasfile=$(arr_api "$arr_url" "$arr_key" "v3" "${ARR_PARENT_ENDPOINT[$arr]}/${parent_id}" "${arr^}" 2>/dev/null \
| jq -r '.hasFile // "unknown"')
if [[ "$verify_hasfile" != "false" ]]; then
error "$title — deleted but hasFile still '$verify_hasfile' — not searching, needs review"
(( REMEDIATE_FAILED++ ))
continue
fi
search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
--max-time 30 -H "X-Api-Key: $arr_key" -H "Content-Type: application/json" \
-d "{\"name\":\"${ARR_SEARCH_COMMAND[$arr]}\",\"${ARR_SEARCH_ID_FIELD[$arr]}\":[${parent_id}]}" \
"${arr_url}/api/v3/command" 2>/dev/null)
if [[ "$search_code" == "200" || "$search_code" == "201" ]]; then
echo " $ICON_SUCCESS $title — deleted, verified, search triggered"
(( REMEDIATED++ ))
else
warn " $title — deleted and verified, but search trigger returned HTTP $search_code"
(( REMEDIATE_FAILED++ ))
fi
done
merge_clean_state "$FRESH_CLEAN_TMP"
rm -f "$FRESH_CLEAN_TMP"
ARR_SCANNED[$arr]=$SCANNED
ARR_SKIPPED_CACHED[$arr]=$SKIPPED_CACHED
ARR_SKIPPED_UNMAPPED[$arr]=$SKIPPED_UNMAPPED
ARR_CORRUPT[$arr]=$CORRUPT_COUNT
ARR_SUSPECT[$arr]=$SUSPECT_COUNT
ARR_PROBE_ERRORS[$arr]=$PROBE_ERRORS
ARR_STRIKE_HELD[$arr]=$STRIKE_HELD
ARR_REMEDIATED[$arr]=$REMEDIATED
ARR_REMEDIATE_FAILED[$arr]=$REMEDIATE_FAILED
(( TOTAL_SCANNED += SCANNED ))
(( TOTAL_CORRUPT += CORRUPT_COUNT ))
(( TOTAL_REMEDIATED += REMEDIATED ))
(( TOTAL_REMEDIATE_FAILED += REMEDIATE_FAILED ))
done
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY CORRUPTION SCAN SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
for arr in sonarr radarr; do
[[ -z "${ARR_SCANNED[$arr]:-}" ]] && continue
echo ""
echo " ${arr^}:"
echo " $ICON_SYNC Newly scanned: ${ARR_SCANNED[$arr]}"
echo " $ICON_SUCCESS Skipped (cached): ${ARR_SKIPPED_CACHED[$arr]}"
echo " $ICON_WARN Skipped (unmapped): ${ARR_SKIPPED_UNMAPPED[$arr]}"
echo " $ICON_ERROR Corrupt found: ${ARR_CORRUPT[$arr]}"
echo " $ICON_WARN Warnings (exit 0): ${ARR_SUSPECT[$arr]} (reported, never remediated)"
echo " $ICON_WARN Probe errors: ${ARR_PROBE_ERRORS[$arr]} (not counted as corrupt)"
if [[ "$REMEDIATE" == true ]]; then
echo " $ICON_WARN Held (strikes): ${ARR_STRIKE_HELD[$arr]}"
echo " $ICON_SUCCESS Remediated: ${ARR_REMEDIATED[$arr]}"
echo " $ICON_ERROR Remediation failed: ${ARR_REMEDIATE_FAILED[$arr]}"
fi
done
echo ""
echo " Total:"
echo " $ICON_SYNC Newly scanned: $TOTAL_SCANNED"
echo " $ICON_ERROR Corrupt found: $TOTAL_CORRUPT"
if [[ "$REMEDIATE" == true ]]; then
echo " $ICON_SUCCESS Remediated: $TOTAL_REMEDIATED"
echo " $ICON_ERROR Remediation failed: $TOTAL_REMEDIATE_FAILED"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
+537
View File
@@ -0,0 +1,537 @@
#!/bin/bash
# ==============================================================================================
# ========================== Arr Download Orphan Cleaner =======================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Clears orphaned completed downloads out of the SABnzbd Completed folders that Sonarr and
# Radarr import from. Anything sitting there that the arr's queue no longer references is an
# orphan — the arr will never touch it again on its own, so without this script the folder
# only ever grows.
#
# Built 2026-07-26 after exactly that: 755G of orphaned completed TV downloads (oldest from
# 2022) had silently accumulated and filled the cache pool to 89%. Every existing cleaner
# covers the library side (sonarr_cleanup.sh walks SONARR_TV_ROOT etc.) — nothing covered
# the download side. This is that missing piece, using the same triage that recovered the
# pool that day.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Runs Sonarr then Radarr, sequentially. Per arr, every top-level entry in the configured
# download dir is classified:
#
# TRACKED — basename matches a queue record's outputPath or title → leave it alone,
# the arr still knows about it
# RECENT — mtime under DOWNLOAD_ORPHAN_AGE days → skip, may be mid-import
# JUNK — no video file over DOWNLOAD_ORPHAN_MIN_VIDEO_MB → delete (par2 debris,
# samples, failed/never-extracted archives — the arr can't import these,
# and if the content is still wanted its own missing-search re-grabs it)
# REDUNDANT — parse API matches it AND the library already has every episode / the
# movie file → delete (the arr already refused it as not-an-upgrade)
# IMPORTABLE — parse API matches it but the library is missing episodes / the movie
# → trigger DownloadedEpisodesScan/DownloadedMoviesScan on the folder and
# leave it; whatever imports gets swept as REDUNDANT next run, whatever
# the arr rejects (XEM-blocked, season-span files) stays HELD for a human
# UNMATCHED — parse API can't match it (series/movie not in the arr) → delete. Past the
# age gate an entry the arr cannot even name is not going to import: if the
# title is in the library and monitored, clearing it lets the arr search a
# copy it can actually parse; if it is not in the library, nothing is
# tracking it and it is dead weight either way. Guarded — see Non-Empty
# Library Requirement below
# HELD — IMPORTABLE entries the arr keeps refusing (XEM-blocked, season-span
# files), and everything skipped by a guard → report only, human call
#
# The queue fetch is a hard gate: if it fails, the whole arr is skipped — with no queue
# there is no way to tell tracked from orphaned, and guessing means deleting active imports.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Classify Before Acting
# Every entry is placed in exactly one class before anything is deleted, and each class
# has its own justification. Nothing is removed because it merely looked unwanted — it is
# removed because it matched a category whose deletion rationale is written down above.
#
# The Queue Is the Source of Truth
# Tracked-vs-orphaned is decided by the arr's own queue, never inferred from filenames or
# timestamps. If the queue cannot be read, the arr is skipped entirely rather than
# falling back to a weaker signal — a guess here deletes an active import.
#
# Deleting Is Recoverable, Deleting Wrong Is Not
# The classes that get deleted are ones the arr can re-acquire: junk it could never
# import, content the library already has, and entries it cannot even name. Anything
# whose loss would be permanent or ambiguous is held and reported for a human instead.
#
# Abnormal Volume Means Broken Input
# The delete cap exists because the realistic failure mode is bad input, not bad logic —
# a partial queue fetch classifies live downloads as orphans, and the only visible
# symptom is an unusually large delete total. The cap turns that into a stop.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Download folders are written by container users; removing them requires root.
#
# Lock Acquisition
# acquire_lock "wait" with an EXIT trap releasing all locks, so an interrupted run never
# strands a lock and the daily orchestrator is never silently skipped.
#
# Host Detection
# detect_hosts() runs before any HOST*_-prefixed download dir is resolved.
#
# DOWNLOAD_ORPHAN_CLEANER_ENABLED Toggle
# Master switch — exits cleanly when disabled.
#
# Download Path Restriction
# The download dir must exist and live under /mnt/. Anything else is refused rather
# than walked, so a blank or malformed path can never point the scan at the filesystem
# root or a system directory.
#
# Queue Fetch Hard Gate
# The arr is skipped entirely if its queue cannot be read. Without the queue there is no
# way to distinguish tracked from orphaned, and guessing deletes active imports.
#
# Age Gate
# Nothing under DOWNLOAD_ORPHAN_AGE days is touched, so an entry mid-import is never a
# deletion candidate regardless of how it classifies.
#
# Deletion Class Restriction
# Only JUNK, parse-verified REDUNDANT and UNMATCHED are deleted. IMPORTABLE entries and
# anything a guard has held are reported, never removed.
#
# Non-Empty Library Requirement
# UNMATCHED deletions require the arr to report a non-empty library. An empty or
# restoring database answers every parse with "no match", which would condemn the whole
# download dir. The library is queried directly rather than inferred from this run's own
# match rate — a small batch that is legitimately all-unmatched is normal once daily runs
# have caught up, and would otherwise read as a broken database.
#
# Delete Volume Cap
# A run total over DOWNLOAD_ORPHAN_MAX_DELETE_GB aborts the delete pass and notifies.
# --i-know-what-im-doing overrides it for a deliberate first run against a known backlog.
#
# Dry Run Support
# --dry-run classifies everything and reports, deleting and importing nothing.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf (resolved per host after detect_hosts())
#
# HOST*_SONARR_DOWNLOAD_DIR / HOST*_RADARR_DOWNLOAD_DIR
# Host-side path to the arr's completed-download folder. Absent means that arr's
# cleanup is skipped, not an error.
#
# HOST*_SONARR_DOWNLOAD_CONTAINER_DIR / HOST*_RADARR_DOWNLOAD_CONTAINER_DIR
# The same folder as the arr container sees it — used when triggering the
# DownloadedEpisodesScan / DownloadedMoviesScan path.
#
# SONARR_URL / SONARR_API_KEY / RADARR_URL / RADARR_API_KEY
# Aliased by detect_hosts(). A missing URL or key skips that arr.
#
# master.conf
#
# DOWNLOAD_ORPHAN_CLEANER_ENABLED
# Master toggle (default: true)
#
# DOWNLOAD_ORPHAN_AGE
# Days before an entry is eligible at all — younger entries may be mid-import
# (default: 7)
#
# DOWNLOAD_ORPHAN_MIN_VIDEO_MB
# An entry with no video file above this size is JUNK (default: 50). Sonarr/Radarr only.
#
# DOWNLOAD_ORPHAN_MIN_AUDIO_MB
# The same test for Lidarr (default: 2). Separate because a 50M floor would mark
# every album folder as JUNK — single tracks rarely reach it.
#
# DOWNLOAD_ORPHAN_KEEP_MARKER
# A file with this name inside a download folder pins it — the folder is never
# classified or deleted (default: .vv-keep). For lossless rips the library holds
# only at lower quality, which REDUNDANT would otherwise sweep.
#
# DOWNLOAD_ORPHAN_MAX_DELETE_GB
# Per-run delete budget in GB (default: 100). A backlog above this is drained
# safest-first (JUNK, then REDUNDANT, then UNMATCHED) up to the budget, and the
# remainder is deferred to the next run rather than aborting the pass.
#
# SONARR_EXTENSIONS / RADARR_EXTENSIONS
# Video extensions used to decide whether an entry contains real media
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# arr_download_orphan_cleaner.sh — daily orchestrator entry
# arr_download_orphan_cleaner.sh --dry-run — classify and report only
# arr_download_orphan_cleaner.sh --status — show config and exit
# arr_download_orphan_cleaner.sh --i-know-what-im-doing — bypass MAX_DELETE_GB budget
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_destructive_flags "$@"
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
for cmd in curl jq; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error "$cmd not found — required"
exit 1
fi
done
detect_hosts
if [[ "${DOWNLOAD_ORPHAN_CLEANER_ENABLED:-false}" != true ]]; then
info "DOWNLOAD_ORPHAN_CLEANER_ENABLED=false — skipping"
exit 0
fi
DOWNLOAD_ORPHAN_AGE="${DOWNLOAD_ORPHAN_AGE:-7}"
DOWNLOAD_ORPHAN_MIN_VIDEO_MB="${DOWNLOAD_ORPHAN_MIN_VIDEO_MB:-50}"
DOWNLOAD_ORPHAN_MIN_AUDIO_MB="${DOWNLOAD_ORPHAN_MIN_AUDIO_MB:-2}"
DOWNLOAD_ORPHAN_KEEP_MARKER="${DOWNLOAD_ORPHAN_KEEP_MARKER:-.vv-keep}"
DOWNLOAD_ORPHAN_MAX_DELETE_GB="${DOWNLOAD_ORPHAN_MAX_DELETE_GB:-100}"
if [[ "${SHOW_STATUS:-false}" == true ]]; then
echo "━━━━━ $ICON_SUMMARY DOWNLOAD ORPHAN CLEANER STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Enabled: ${DOWNLOAD_ORPHAN_CLEANER_ENABLED}"
echo "$ICON_TIME Age gate: ${DOWNLOAD_ORPHAN_AGE}d"
echo "$ICON_DISK Junk threshold: ${DOWNLOAD_ORPHAN_MIN_VIDEO_MB}M video / ${DOWNLOAD_ORPHAN_MIN_AUDIO_MB}M audio"
echo "$ICON_SHIELD Delete cap: ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G"
echo "$ICON_SHIELD Keep marker: ${DOWNLOAD_ORPHAN_KEEP_MARKER}"
for arr in SONARR RADARR LIDARR; do
dir_var="${MY_ID}_${arr}_DOWNLOAD_DIR"
echo "$ICON_CLEAN ${arr}: ${!dir_var:-<not configured>}"
done
exit 0
fi
acquire_lock "wait"
trap "_release_all_locks" EXIT
AGE_CUTOFF=$(( $(date +%s) - DOWNLOAD_ORPHAN_AGE * 86400 ))
TOTAL_DELETED=0
TOTAL_DELETED_MB=0
TOTAL_HELD=0
TOTAL_DEFERRED=0
TOTAL_KEPT=0
TOTAL_SCANS=0
echo "━━━━━ $ICON_CLEAN DOWNLOAD ORPHAN CLEANER ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
[[ "$DRY_RUN" == true ]] && echo "$ICON_SKIP DRY RUN — nothing will be deleted or imported"
for arr in sonarr radarr lidarr; do
api_ver="v3"; [[ "$arr" == "lidarr" ]] && api_ver="v1"
url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY"
arr_url="${!url_var:-}"; arr_key="${!key_var:-}"
dir_var="${MY_ID}_${arr^^}_DOWNLOAD_DIR"
cdir_var="${MY_ID}_${arr^^}_DOWNLOAD_CONTAINER_DIR"
dl_dir="${!dir_var:-}"; container_dir="${!cdir_var:-}"
if [[ -z "$arr_url" || -z "$arr_key" || -z "$dl_dir" ]]; then
info "${arr^} download cleanup not configured on $MY_ID — skipping"
continue
fi
if [[ "$dl_dir" != /mnt/* || ! -d "$dl_dir" ]]; then
warn "${arr^} download dir invalid or missing: $dl_dir — skipping"
continue
fi
echo ""
echo "━━━ $ICON_SYNC ${arr^}$dl_dir ━━━"
ver_var="${arr^^}_VERSION_MAJOR"
check_arr_version "$arr_url" "$arr_key" "$api_ver" "${!ver_var}" "${arr^}" || {
warn "${arr^} version check failed — skipping this arr"
continue
}
# min_mb is per-arr because the JUNK test is "contains no real media file". A 50MB floor
# is right for video and catastrophic for audio — most single tracks never reach it, so
# every music folder would classify as JUNK and be deleted regardless of import state.
case "$arr" in
sonarr)
queue_endpoint="queue?pageSize=1000&includeUnknownSeriesItems=true"
exts_var="SONARR_EXTENSIONS"
scan_command="DownloadedEpisodesScan"
library_endpoint="series"
min_mb="$DOWNLOAD_ORPHAN_MIN_VIDEO_MB"
;;
radarr)
queue_endpoint="queue?pageSize=1000&includeUnknownMovieItems=true"
exts_var="RADARR_EXTENSIONS"
scan_command="DownloadedMoviesScan"
library_endpoint="movie"
min_mb="$DOWNLOAD_ORPHAN_MIN_VIDEO_MB"
;;
lidarr)
queue_endpoint="queue?pageSize=1000&includeUnknownArtistItems=true"
exts_var="LIDARR_EXTENSIONS"
scan_command="DownloadedAlbumsScan"
library_endpoint="artist"
min_mb="$DOWNLOAD_ORPHAN_MIN_AUDIO_MB"
;;
esac
QUEUE_JSON=$(arr_api "$arr_url" "$arr_key" "$api_ver" "$queue_endpoint" "${arr^}") || {
error "${arr^} queue fetch failed — cannot tell tracked from orphaned, skipping this arr"
continue
}
declare -A PROTECTED=()
while IFS= read -r name; do
[[ -n "$name" ]] && PROTECTED["$name"]=1
done < <(echo "$QUEUE_JSON" | jq -r '.records[] | (.outputPath // empty | split("/") | last), (.title // empty)')
eval "arr_exts=(\"\${${exts_var}[@]}\")"
DELETE_PATHS=()
DELETE_SIZES=()
DELETE_LABELS=()
SCAN_PATHS=()
UNMATCHED_PATHS=()
UNMATCHED_SIZES=()
arr_tracked=0; arr_recent=0; arr_held=0; arr_delete_mb=0; arr_kept=0
while IFS= read -r entry; do
base="${entry##*/}"
# An operator keep-marker outranks every verdict below. Needed because REDUNDANT only
# asks "does the library hold this album", not "at what quality" — a lossless rip whose
# library copy is MP3 is redundant by that test and would be swept on the next run.
# The marker is a file inside the folder rather than a conf list so it survives renames
# and cannot drift out of sync with what is actually on disk.
if [[ -e "$entry/$DOWNLOAD_ORPHAN_KEEP_MARKER" ]]; then
arr_kept=$((arr_kept + 1))
continue
fi
if [[ -n "${PROTECTED[$base]:-}" ]]; then
arr_tracked=$((arr_tracked + 1))
continue
fi
mtime=$(stat -c %Y "$entry" 2>/dev/null) || continue
if (( mtime > AGE_CUTOFF )); then
arr_recent=$((arr_recent + 1))
continue
fi
# JUNK means "holds no real media". That verdict is only as good as the extension
# list, and a missing extension turns real content into a delete — 2026-08-21 the
# audio list had no "wv", which classified 23 folders of WavPack lossless (1.5G per
# file) as junk. So a folder with large files that are merely *unrecognised* is held
# for review, never deleted; only a folder with nothing big in it at all is junk.
has_media=false
big_unknown=0
while IFS= read -r f; do
if has_extension "$f" "${arr_exts[@]}"; then
has_media=true
break
fi
big_unknown=$((big_unknown + 1))
done < <(find "$entry" -type f -size +"${min_mb}"M 2>/dev/null)
size_mb=$(dir_size_mb "$entry") || size_mb=0
if [[ "$has_media" == false ]] && (( big_unknown > 0 )); then
warn " no recognised media, but $big_unknown large file(s) of unknown type — holding: $base"
arr_held=$((arr_held + 1))
continue
fi
if [[ "$has_media" == false ]]; then
DELETE_PATHS+=("$entry")
DELETE_SIZES+=("$size_mb")
DELETE_LABELS+=("JUNK")
arr_delete_mb=$((arr_delete_mb + size_mb))
continue
fi
enc_title=$(jq -rn --arg t "$base" '$t|@uri')
parse=$(arr_api "$arr_url" "$arr_key" "$api_ver" "parse?title=${enc_title}" "${arr^}") || {
warn " parse failed for: $base — holding"
arr_held=$((arr_held + 1))
continue
}
case "$arr" in
sonarr)
matched=$(echo "$parse" | jq '(.series != null) and ((.episodes | length) > 0)')
missing=$(echo "$parse" | jq '[.episodes[]? | select(.hasFile == false)] | length')
;;
radarr)
# Radarr's parse never populates hasFile — movieFileId is the reliable signal
matched=$(echo "$parse" | jq '.movie != null')
missing=$(echo "$parse" | jq 'if (.movie.movieFileId // 0) > 0 then 0 else 1 end')
;;
lidarr)
# Lidarr's parse returns albums with statistics:null, so the track count has
# to be read back from album/{id} — the same shape of gap as Radarr's hasFile.
matched=$(echo "$parse" | jq '(.artist != null) and ((.albums | length) > 0)')
missing=1
if [[ "$matched" == true ]]; then
album_id=$(echo "$parse" | jq -r '.albums[0].id // empty')
if [[ -z "$album_id" ]]; then
warn " parse matched but returned no album id: $base — holding"
arr_held=$((arr_held + 1))
continue
fi
album_json=$(arr_api "$arr_url" "$arr_key" "$api_ver" "album/$album_id" "${arr^}") || {
warn " album lookup failed for: $base — holding"
arr_held=$((arr_held + 1))
continue
}
missing=$(echo "$album_json" | jq 'if ((.statistics.trackFileCount // 0) > 0) then 0 else 1 end')
fi
;;
esac
if [[ "$matched" != true ]]; then
UNMATCHED_PATHS+=("$entry")
UNMATCHED_SIZES+=("$size_mb")
elif (( missing == 0 )); then
DELETE_PATHS+=("$entry")
DELETE_SIZES+=("$size_mb")
DELETE_LABELS+=("REDUNDANT")
arr_delete_mb=$((arr_delete_mb + size_mb))
else
SCAN_PATHS+=("$base")
arr_held=$((arr_held + 1))
fi
done < <(find "$dl_dir" -mindepth 1 -maxdepth 1 2>/dev/null)
# An arr with an empty or still-restoring database answers every parse with "no match",
# which would turn the whole download dir into UNMATCHED and delete it. Confirm the
# library actually holds titles before trusting a no-match to mean what it says. This
# has to be asked of the arr directly — inferring health from the run's own matches
# fails on a small batch that is legitimately all-unmatched, which is the normal case
# once daily runs have caught up.
if (( ${#UNMATCHED_PATHS[@]} > 0 )); then
library_count=$(arr_api "$arr_url" "$arr_key" "$api_ver" "$library_endpoint" "${arr^}" | jq 'length' 2>/dev/null)
if [[ ! "$library_count" =~ ^[0-9]+$ ]] || (( library_count == 0 )); then
warn " ${arr^}: library reports ${library_count:-no} titles — cannot trust 'no match', holding ${#UNMATCHED_PATHS[@]} unmatched"
arr_held=$((arr_held + ${#UNMATCHED_PATHS[@]}))
else
for i in "${!UNMATCHED_PATHS[@]}"; do
DELETE_PATHS+=("${UNMATCHED_PATHS[$i]}")
DELETE_SIZES+=("${UNMATCHED_SIZES[$i]}")
DELETE_LABELS+=("UNMATCHED")
arr_delete_mb=$((arr_delete_mb + UNMATCHED_SIZES[i]))
done
fi
fi
# The cap is a per-run risk budget, not a reason to do nothing. Aborting the whole pass
# once the backlog exceeds it is self-defeating: the backlog can never shrink below the
# cap on its own, so every later run aborts too and the pool fills anyway (exactly how
# 347G accumulated here by 2026-08-21). Delete in ascending order of risk instead, stop
# at the cap, and defer the rest to the next run so a backlog drains over days.
#
# Live downloads are already protected by DOWNLOAD_ORPHAN_AGE, not by this cap — anything
# in flight is younger than the age gate and never reaches classification. That is what
# makes draining safe: the partial-queue-data case the cap was written for cannot put a
# still-downloading entry in these arrays.
cap_mb=$((DOWNLOAD_ORPHAN_MAX_DELETE_GB * 1024))
cap_active=true
[[ "$I_KNOW" == true || "$DRY_RUN" == true ]] && cap_active=false
arr_deferred=0; arr_deferred_mb=0; arr_run_mb=0
if [[ "$cap_active" == true ]] && (( arr_delete_mb > cap_mb )); then
warn " ${arr^}: $((arr_delete_mb / 1024))G classified vs ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G cap — deleting safest-first up to the cap, deferring the rest"
notify "${arr^} download orphan backlog is $((arr_delete_mb / 1024))G on $(hostname), above the ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G per-run cap. Draining safest-first; the remainder follows on later runs. Re-run with --i-know-what-im-doing to clear it in one pass." \
"Download Orphan Cleaner" "warning"
fi
# JUNK first (no media at all), then REDUNDANT (parse-verified already in the library),
# then UNMATCHED last — it rests on "the arr does not know this title", the weakest of
# the three signals, so it is the first thing the cap defers.
for pass in JUNK REDUNDANT UNMATCHED; do
for i in "${!DELETE_PATHS[@]}"; do
[[ "${DELETE_LABELS[$i]}" == "$pass" ]] || continue
entry="${DELETE_PATHS[$i]}"
if [[ "$cap_active" == true ]] && (( arr_run_mb + DELETE_SIZES[i] > cap_mb )); then
arr_deferred=$((arr_deferred + 1))
arr_deferred_mb=$((arr_deferred_mb + DELETE_SIZES[i]))
continue
fi
if [[ "$DRY_RUN" == true ]]; then
echo " $ICON_SKIP would delete [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)"
else
echo " $ICON_TRASH deleting [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)"
rm -rf "$entry"
fi
arr_run_mb=$((arr_run_mb + DELETE_SIZES[i]))
TOTAL_DELETED=$((TOTAL_DELETED + 1))
TOTAL_DELETED_MB=$((TOTAL_DELETED_MB + DELETE_SIZES[i]))
done
done
if (( arr_deferred > 0 )); then
echo " $ICON_WARN ${arr^}: deferred $arr_deferred entries ($((arr_deferred_mb / 1024))G) to the next run — cap reached"
TOTAL_DEFERRED=$((TOTAL_DEFERRED + arr_deferred))
fi
for base in "${SCAN_PATHS[@]}"; do
if [[ -z "$container_dir" ]]; then
echo " $ICON_WARN IMPORTABLE but ${cdir_var} not set — holding: $base"
continue
fi
if [[ "$DRY_RUN" == true ]]; then
echo " $ICON_SKIP would trigger $scan_command: $base"
else
echo " $ICON_RUN triggering $scan_command: $base"
payload=$(jq -nc --arg n "$scan_command" --arg p "$container_dir/$base" '{name: $n, path: $p, importMode: "Move"}')
curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $arr_key" -H "Content-Type: application/json" \
-d "$payload" "$arr_url/api/v3/command" >/dev/null \
|| warn " $scan_command trigger failed for: $base"
TOTAL_SCANS=$((TOTAL_SCANS + 1))
fi
done
echo " $ICON_SUMMARY ${arr^}: $arr_tracked tracked, $arr_kept kept, $arr_recent recent, $((${#DELETE_PATHS[@]} - arr_deferred)) deleted ($((arr_run_mb / 1024))G), $arr_deferred deferred ($((arr_deferred_mb / 1024))G), ${#SCAN_PATHS[@]} import scans, $arr_held held"
TOTAL_HELD=$((TOTAL_HELD + arr_held))
TOTAL_KEPT=$((TOTAL_KEPT + arr_kept))
unset PROTECTED
done
echo ""
echo "━━━━━ $ICON_DONE SUMMARY ━━━━━"
echo "$ICON_TRASH Deleted: $TOTAL_DELETED ($((TOTAL_DELETED_MB / 1024))G)"
echo "$ICON_RUN Import scans: $TOTAL_SCANS"
echo "$ICON_WARN Held: $TOTAL_HELD"
echo "$ICON_SKIP Deferred: $TOTAL_DEFERRED"
echo "$ICON_SHIELD Kept (marker): $TOTAL_KEPT"
# Held alone never notifies — there is always something awaiting review, and on a daily
# schedule that would be a notification every morning saying nothing happened.
if [[ "$DRY_RUN" != true ]] && (( TOTAL_DELETED > 0 )); then
notify "Download orphan cleaner on $(hostname): deleted $TOTAL_DELETED orphans ($((TOTAL_DELETED_MB / 1024))G), triggered $TOTAL_SCANS import scans, $TOTAL_HELD held for review" \
"Download Orphan Cleaner" "normal"
fi
+246
View File
@@ -0,0 +1,246 @@
#!/bin/bash
# ==============================================================================================
# ======================= Arr Full Library Rescan =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Forces a genuine full disk↔database reconciliation for Lidarr/Sonarr/Radarr. Organic
# scans (triggered by new imports, RSS sync, etc.) only touch the files actually involved —
# an artist/series/movie that already has files sitting untouched on disk never gets its
# file-tracking stats refreshed on its own. Confirmed 2026-07-16: Lidarr reported only ~23%
# of its true trackFileCount with no active scan running, for 1,004 of 1,357 artists — files
# verified present and readable on disk the whole time. Every downstream script (cleanup,
# duplicate-artist detection, discovery) trusts these arr stats as source of truth for what's
# on the share, so silent drift like this is exactly what check_tracked_count_floor() exists
# to catch reactively. This job exists to catch it proactively instead of waiting for someone
# to notice a suspiciously low number.
#
# Runs sequentially across all three arrs, never parallel — each is a heavy full-disk walk,
# and running them concurrently would just contend for the same disk I/O for no benefit.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Lidarr, then Sonarr, then Radarr — strictly sequential. Per arr:
#
# 1. Reachability
# → check_api; unreachable skips this arr only
#
# 2. Already-scanning check
# → a rescan already active (manual, or another script) means skip rather than
# stack a second full-disk walk on top of it
#
# 3. Capture the before count
# → tracked file count read from the arr's own stats
#
# 4. Trigger the rescan command
# → RescanFolders (Lidarr) / RescanSeries (Sonarr) / RescanMovie (Radarr)
#
# 5. Poll to completion
# → bounded by ARR_FULL_RESCAN_TIMEOUT
#
# 6. Report the delta
# → before vs after tracked count, so drift that was corrected is visible
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Proactive, Not Reactive
# check_tracked_count_floor() catches stat drift reactively, at the moment some other
# script is about to act on bad numbers. This job exists so that drift is corrected on a
# schedule instead of being discovered by whichever cleanup happens to trip over it first.
#
# Sequential by Design
# Each rescan is a full-disk walk. Running three concurrently contends for the same
# spindles and finishes no sooner, so the arrs are never parallelised — the slowness is
# accepted deliberately rather than optimised into I/O thrash.
#
# Never Stack a Scan
# An already-running rescan is left alone rather than duplicated. A second concurrent
# walk of the same library doubles the I/O cost and returns nothing the first will not.
#
# Per-Arr Isolation
# One arr being down, slow, or already scanning must never prevent the other two from
# being reconciled. Partial coverage beats a skipped run.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Kept for consistency across Arrs_Stack/ — this script makes no direct filesystem writes.
#
# Lock Acquisition
# acquire_lock "wait" — waits for a prior run rather than skipping or colliding. A full
# rescan across three arrs runs long and is worth queuing behind, not silently dropping.
#
# Host Detection
# detect_hosts() aliases each arr's URL and API key.
#
# jq Dependency Check
# Fails fast if jq is missing. Both the before/after tracked counts and the command
# payload are built with jq — without it the counts read empty and every delta would be
# reported as if nothing changed.
#
# Reachability Check
# check_api before touching an arr; unreachable skips that arr only.
#
# Active-Rescan Check
# Skips triggering a new rescan if one is already active on that arr, so a duplicate
# full-disk walk is never stacked. See Tools/arr_rescan_monitor.sh for catching that
# arr's cache up once the pre-existing scan finishes, rather than waiting a week.
#
# Sequential Only
# Two arrs' rescans never run in parallel.
#
# Per-Arr Isolation
# One arr failing, timing out, or being skipped never blocks the others.
#
# Timeout Bound
# ARR_FULL_RESCAN_TIMEOUT caps the wait per arr, so a rescan that never completes cannot
# hold the weekly window open indefinitely.
#
# Dry Run Support
# --dry-run reports which arrs would be rescanned and triggers nothing.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
# ARR_FULL_RESCAN_TIMEOUT — seconds to wait per arr (default 3600). A whole-library
# RescanFolders/RescanSeries/RescanMovie is far heavier than the 600s pre-flight scan
# timeout used elsewhere — that shorter timeout is sized for a single release, not a
# full-library walk.
#
# host*.conf
# HOST1_LIDARR_URL / _API_KEY, HOST1_SONARR_URL / _API_KEY, HOST1_RADARR_URL / _API_KEY
# — aliased by detect_hosts()
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# arr_full_rescan.sh — normal run
# arr_full_rescan.sh --dry-run — preview which arrs would be rescanned, trigger nothing
# arr_full_rescan.sh --log — verbose, per-arr detail
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
# Both the tracked-count reads and the command payload are built with jq — without it the
# counts read empty and every arr would report a zero delta as if nothing had drifted.
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
notify "Arr full rescan failed on $(hostname) — jq not installed" "Arr Full Rescan" "warning"
exit 1
fi
acquire_lock "wait"
detect_hosts
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no rescans will be triggered"
declare -A ARR_FULL_RESCAN_COMMAND=(
[lidarr]="RescanFolders"
[sonarr]="RescanSeries"
[radarr]="RescanMovie"
)
RESCANNED=0
SKIPPED=0
for arr in lidarr sonarr radarr; do
url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY"
url="${!url_var:-}"; key="${!key_var:-}"
ver="v3"; [[ "$arr" == "lidarr" ]] && ver="v1"
if [[ -z "$url" || -z "$key" ]]; then
info "${arr^} not configured on $MY_ID — skipping"
continue
fi
check_api "$url" "${arr^}" 10 || {
warn "${arr^} unreachable — skipping full rescan this run"
(( SKIPPED++ ))
continue
}
active=$(arr_active_rescan_command "$arr" "$url" "$key" "$ver")
if [[ -n "$active" ]]; then
warn "${arr^} already mid-rescan ($active) — skipping, will catch it next scheduled run (run Tools/arr_rescan_monitor.sh ${arr} to refresh its cache as soon as this one finishes instead of waiting)"
(( SKIPPED++ ))
continue
fi
endpoint="${ARR_LIBRARY_ENDPOINT[$arr]}"
expr="${ARR_TRACKED_COUNT_EXPR[$arr]}"
before_json=$(curl -sf --max-time 60 -H "X-Api-Key: $key" "${url}/api/${ver}/${endpoint}" 2>/dev/null)
before_count=$(echo "$before_json" | jq "$expr" 2>/dev/null)
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would trigger full ${ARR_FULL_RESCAN_COMMAND[$arr]} for ${arr} (currently: ${before_count:-unknown} tracked)"
continue
fi
log "$ICON_GEAR Triggering full ${ARR_FULL_RESCAN_COMMAND[$arr]} for ${arr} (before: ${before_count:-unknown} tracked)"
payload=$(jq -c -n --arg name "${ARR_FULL_RESCAN_COMMAND[$arr]}" '{name:$name}')
trigger_and_await_command "$url" "$key" "$ver" "$payload" "${ARR_FULL_RESCAN_TIMEOUT:-3600}" "$arr"
after_json=$(curl -sf --max-time 60 -H "X-Api-Key: $key" "${url}/api/${ver}/${endpoint}" 2>/dev/null)
after_count=$(echo "$after_json" | jq "$expr" 2>/dev/null)
if [[ -n "$after_json" && -n "$after_count" && "$after_count" != "null" ]]; then
arr_cache_write "$arr" "$after_json"
log "$ICON_DONE ${arr^} rescan complete — tracked: ${before_count:-?}${after_count}"
(( RESCANNED++ ))
# A completed full rescan is ground truth — if it's STILL far below the running
# baseline, that's a real problem (missing disk, permissions, actual data loss),
# not a stale-cache or mid-scan artifact. Worth a direct heads-up either way.
count_file_var="${arr^^}_TRACKED_COUNT_FILE"
min_pct_var="${arr^^}_MIN_TRACKED_PCT"
count_file="${!count_file_var:-}"
min_pct="${!min_pct_var:-80}"
if [[ -n "$count_file" && -f "$count_file" ]]; then
baseline=$(cat "$count_file" 2>/dev/null || echo 0)
if [[ "$baseline" -gt 0 ]]; then
pct=$(awk "BEGIN {printf \"%d\", ($after_count / $baseline) * 100}")
if [[ "$pct" -lt "$min_pct" ]]; then
notify "${arr^} full rescan complete but tracked count still ${pct}% of baseline ($after_count vs $baseline) on $(hostname) — real drop, not a scan artifact, needs a look" \
"Arr Full Rescan" "warning"
else
echo "$after_count" > "$count_file"
fi
else
echo "$after_count" > "$count_file"
fi
fi
else
warn "${arr^} rescan finished but re-fetch failed — cache not updated"
fi
done
echo ""
echo "━━━━━ $ICON_SUMMARY ARR FULL RESCAN SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Rescanned: $RESCANNED"
echo "$ICON_SKIP Skipped: $SKIPPED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
+25 -14
View File
@@ -10,6 +10,12 @@
# rsync in the weekly sync window: once arrs agree on what to track, rsync
# spreads the actual files.
#
# The LOCAL side of each sync (_local_library()) is cache-first (2026-07-17) — comes from
# the shared tracked-data cache via arr_get_tracked_data(), fresh (kept warm every 30min by
# arr_cache_prefill.sh), live fetch as fallback. The REMOTE side (_remote_library()) is
# unaffected — that cache is per-host by design, so a remote node's library is always
# fetched live here.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
@@ -38,7 +44,7 @@
# ==============================================================================================
#
# Remote API Access — Cache-First, SSH Fallback
# If conf_sync.sh has populated /tmp/.cache/vv/d/ and
# If conf_sync.sh has populated /tmp/varaverk/conf/ and
# load_config.sh has sourced it, HOST*_<ARR>_API_KEY vars are available
# in the environment. Remote functions use them to call the arr API
# directly over Tailscale (no SSH, no remote shell). If the cached key
@@ -206,13 +212,7 @@ declare -A _ID_TYPE=([lidarr]="string" [sonarr]="int" [radarr]="int")
ARR_TYPES=(lidarr sonarr radarr)
# ── Remote node discovery ──────────────────────────────────────────────────────────────────────
REMOTE_NODES=()
for _hv in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
[[ "$_hv" == "$MY_ID" ]] && continue
[[ -z "${!_hv:-}" ]] && continue
REMOTE_NODES+=("$_hv")
done
unset _hv
discover_remote_nodes
if [[ "${#REMOTE_NODES[@]}" -eq 0 ]]; then
warn "No remote nodes defined in master.conf — nothing to sync"
@@ -478,7 +478,7 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo " Arr Port URL"
for _arr in "${ARR_TYPES[@]}"; do
local _url="" _key=""
_url=""
case "$_arr" in
lidarr) _url="${LIDARR_URL:-not configured}" ;;
sonarr) _url="${SONARR_URL:-not configured}" ;;
@@ -665,9 +665,20 @@ REMOTE
_local_library() {
local url="$1" api_key="$2" api_ver="$3" endpoint="$4"
curl -sf --max-time "$ARR_SYNC_API_TIMEOUT" \
-H "X-Api-Key: $api_key" \
"${url}/api/${api_ver}/${endpoint}" 2>/dev/null
# endpoint doubles as arr_type here — artist/series/movie match lidarr/sonarr/radarr's
# ARR_LIBRARY_ENDPOINT values exactly, so the same lookup works either direction.
local arr_type
case "$endpoint" in
artist) arr_type="lidarr" ;;
series) arr_type="sonarr" ;;
movie) arr_type="radarr" ;;
esac
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (kept
# current every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back
# to a live fetch when it's stale, and waits out an active rescan before either. This is
# the LOCAL host's library only — the remote side of this sync isn't cached, since the
# shared cache is per-host by design.
arr_get_tracked_data "$arr_type" "$url" "$api_key" "$api_ver"
}
_local_defaults() {
@@ -992,8 +1003,8 @@ _sync_arr() {
fi
fi
local _n_local=${#to_add_local[@]:-}; _n_local=${_n_local:-0}
local _n_remote=${#to_add_remote[@]:-}; _n_remote=${_n_remote:-0}
local _n_local=${#to_add_local[@]}
local _n_remote=${#to_add_remote[@]}
log " $node_name: +${_n_local} local | +${_n_remote} remote | $total_skipped blocklisted"
unset remote_ids to_add_local to_add_remote _n_local _n_remote
@@ -13,16 +13,27 @@
# OPERATIONAL MODEL
# ==============================================================================================
#
# Four problem types detected from the arr queue API:
# Five problem types detected from the arr queue API:
# importFailed — downloaded but arr couldn't import the file
# importPending — downloaded, stuck waiting to import (will not self-resolve)
# importBlocked — downloaded, but arr matched the release to the wrong media
# by grab-history ID instead of by title and refuses to import
# (permanent block, never self-resolves)
# error status — serious failure not covered by the above two states
# stalled — download stuck with no connections or no progress
#
# Never touches items with state "downloading" or "imported" — safe to run anytime.
# Items newer than ARR_IMPORT_RECOVERY_AGE are skipped — gives arr time to retry first.
#
# Per problem item (3-step response):
# importBlocked items get one extra check first (try_smart_import, Sonarr/Radarr only):
# most are junk/duplicates and fall straight through to the normal 3-step response below,
# but some are a release arr already correctly parsed — episode/movie identified, quality
# and language known — that's just tripping the title-vs-grab-history safety net. If the
# target has no file yet (missing) or the candidate is a same-language resolution upgrade
# over what's already there, it's imported directly instead of being discarded. See
# ARR_SMART_IMPORT_ENABLED in CONFIGURATION below.
#
# Per problem item that isn't smart-imported (3-step response):
# 1. Blocklist the release — prevents re-grabbing the same bad release
# 2. Remove from queue — cleans up the failed item
# 3. Trigger new search — finds a different release automatically
@@ -36,6 +47,14 @@
# re-search. No operator decision required. A failed import at midnight resolves
# itself before morning without any intervention.
#
# Smart Import Is Conservative By Design
# try_smart_import only acts when every file in the download is unambiguous: no
# rejections from arr's own analysis, and (no existing file) or (matching language
# plus a strictly higher resolution). Any ambiguity — mixed multi-episode files,
# unknown language, equal-or-lower quality, wrong language — falls straight through
# to blocklist+research, exactly today's behavior. It only ever adds a chance to
# keep something worth keeping; it never makes the no-smart-import case worse.
#
# Age Gate Before Action
# Items newer than ARR_IMPORT_RECOVERY_AGE are skipped. Arrs have their own
# retry logic — acting immediately would race against it. The age gate gives
@@ -45,6 +64,22 @@
# The bad release is blocklisted before removal and re-search. Without this,
# the re-search can re-grab the same release that just failed.
#
# Circuit Breaker Per Media Item
# Some items can never resolve via blind retry — e.g. an album missing 1-2
# tracks where every available release is a different edition that doesn't
# match. Without a limit, the same media ID gets blocklisted + re-searched
# forever, every run, burning bandwidth and indexer queries for nothing.
# After ARR_RECOVERY_MAX_ATTEMPTS consecutive failures for the same
# (arr_type, media_id), the item is still blocklisted/cleaned from the queue
# but search is no longer auto-triggered — it's flagged chronic and left for
# manual review instead.
#
# "Consecutive" is enforced, not just counted — a media_id's failure count
# is pruned at the end of every process_arr() pass if it no longer appears
# in that run's problem-item set (2026-07-19 fix: counts were never reset on
# success, so an item that failed a few times months apart and then imported
# fine could still get stuck permanently chronic from stale history).
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
@@ -67,6 +102,8 @@
# ==============================================================================================
#
# ARR_RECOVERY_STATS — stats file written after each run (read by coffee report)
# ARR_RECOVERY_FAILURE_COUNTS — per (arr_type, media_id) consecutive-failure counts,
# persists across runs so the circuit breaker survives restarts.
#
# ==============================================================================================
# CONFIGURATION
@@ -82,10 +119,20 @@
# master.conf
#
# ARR_IMPORT_RECOVERY_AGE — hours before item is eligible for recovery (default: 6)
# ARR_RECOVERY_MAX_ATTEMPTS — consecutive failures before an item is flagged chronic
# and auto re-search stops (default: 3)
# ARR_SMART_IMPORT_ENABLED — try_smart_import gate for importBlocked items, Sonarr/
# Radarr only — Lidarr's manual-import matching doesn't
# reliably resolve album/track context (default: true)
# ARR_SMART_IMPORT_PREFERRED_LANGUAGE — only import as a match/upgrade if the
# candidate is this language; existing files in a
# different language are always treated as upgradeable
# (default: English)
# SONARR_VERSION_MAJOR — expected Sonarr major version (e.g. 4)
# RADARR_VERSION_MAJOR — expected Radarr major version (e.g. 6)
# LIDARR_VERSION_MAJOR — expected Lidarr major version (e.g. 3)
# ARR_RECOVERY_STATS — stats file path (read by coffee report)
# ARR_RECOVERY_FAILURE_COUNTS — failure-count state file path
#
# ==============================================================================================
# RUNTIME MODES
@@ -135,11 +182,24 @@ log "jq found"
# Age threshold in seconds
AGE_THRESHOLD_SECONDS=$(( ARR_IMPORT_RECOVERY_AGE * 3600 ))
ARR_RECOVERY_MAX_ATTEMPTS="${ARR_RECOVERY_MAX_ATTEMPTS:-3}"
ARR_RECOVERY_FAILURE_COUNTS="${ARR_RECOVERY_FAILURE_COUNTS:-$DATA_DIR/arr_recovery_failure_counts.db}"
log "$ICON_GEAR Config: age-threshold=${ARR_IMPORT_RECOVERY_AGE}hr sonarr-v${SONARR_VERSION_MAJOR} radarr-v${RADARR_VERSION_MAJOR} lidarr-v${LIDARR_VERSION_MAJOR:-?}"
log "$ICON_GEAR Config: age-threshold=${ARR_IMPORT_RECOVERY_AGE}hr max-attempts=${ARR_RECOVERY_MAX_ATTEMPTS} sonarr-v${SONARR_VERSION_MAJOR} radarr-v${RADARR_VERSION_MAJOR} lidarr-v${LIDARR_VERSION_MAJOR:-?}"
# Load persisted per-item failure counts — key is "arr_type:media_id"
declare -A FAILURE_COUNTS
if [[ -f "$ARR_RECOVERY_FAILURE_COUNTS" ]]; then
while IFS='|' read -r _key _count; do
[[ -z "$_key" ]] && continue
FAILURE_COUNTS["$_key"]="$_count"
done < "$ARR_RECOVERY_FAILURE_COUNTS"
fi
TOTAL_ACTIONED=0
TOTAL_SKIPPED=0
TOTAL_CHRONIC=0
TOTAL_SMART_IMPORTED=0
ARR_SUMMARIES=()
# ==============================================================================================
@@ -153,6 +213,8 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo "$ICON_SYNC Radarr: ${RADARR_URL:-not configured} (recovery: ${RADARR_RECOVERY:-true})"
echo "$ICON_SYNC Lidarr: ${LIDARR_URL:-not configured on this host} (recovery: ${LIDARR_RECOVERY:-false})"
echo "$ICON_TIME Age thresh: ${ARR_IMPORT_RECOVERY_AGE}hr"
echo "$ICON_GEAR Max attempts: ${ARR_RECOVERY_MAX_ATTEMPTS:-3} (chronic after this many)"
echo "$ICON_GEAR Smart import: ${ARR_SMART_IMPORT_ENABLED:-true} (preferred language: ${ARR_SMART_IMPORT_PREFERRED_LANGUAGE:-English})"
echo "$ICON_GEAR Sonarr ver: v${SONARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Radarr ver: v${RADARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Lidarr ver: v${LIDARR_VERSION_MAJOR} expected"
@@ -177,14 +239,41 @@ item_is_old_enough() {
[[ "$age_seconds" -ge "$AGE_THRESHOLD_SECONDS" ]]
}
# Query the arr queue API and return all records
# Query the arr queue API and return all records, paginated.
# A single page=1&pageSize=200 request silently misses everything past record 200 —
# on a busy Sonarr instance the queue can run into the thousands (e.g. a large
# missing-episode search campaign), which pushed every importBlocked/warning item
# past page 1 and made this whole script blind to them despite matching correctly.
# Args: url, api_key, api_version
get_queue_data() {
local url="$1" api_key="$2" api_version="$3"
curl -sf --max-time 15 \
local page=1 page_size=250 max_pages=50
local page_data page_count
# Accumulate pages as files rather than growing a shell variable — on a large
# queue (thousands of records) passing the combined JSON through --argjson
# blows past ARG_MAX ("Argument list too long"). jq -s reads files instead.
local tmp_dir
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' RETURN
while [[ "$page" -le "$max_pages" ]]; do
page_data=$(curl -sf --max-time 15 \
-H "X-Api-Key: $api_key" \
"${url}/api/${api_version}/queue?page=1&pageSize=200&includeUnknownSeriesItems=true&includeUnknownArtistItems=true" \
2>/dev/null
"${url}/api/${api_version}/queue?page=${page}&pageSize=${page_size}&includeUnknownSeriesItems=true&includeUnknownArtistItems=true" \
2>/dev/null)
[[ -z "$page_data" ]] && break
page_count=$(echo "$page_data" | jq '.records // [] | length' 2>/dev/null)
[[ -z "$page_count" || "$page_count" -eq 0 ]] && break
echo "$page_data" | jq -c '.records // []' > "$tmp_dir/page_${page}.json"
[[ "$page_count" -lt "$page_size" ]] && break
(( page++ ))
done
jq -c -s '{totalRecords: ([.[][]] | length), records: [.[][]]}' "$tmp_dir"/page_*.json 2>/dev/null \
|| echo '{"totalRecords":0,"records":[]}'
}
# Blocklist and remove a queue item
@@ -225,6 +314,161 @@ trigger_search() {
>/dev/null 2>&1
}
# Decide whether an importBlocked download is actually worth keeping, and import
# it directly if so — instead of always discarding it via blocklist+research.
#
# Fires the ManualImport command and returns as soon as it's accepted (HTTP 201)
# rather than polling for completion. Deliberately does NOT fall back to
# blocklist_item() after a successful trigger: Radarr/Sonarr's import runs async
# in the background, and blocklisting (which deletes the source via
# removeFromClient) right after firing it would race a still-in-progress import
# for anything but the smallest files. If the import silently fails, the item
# simply reappears as importBlocked next run and gets tried again — safe, if not
# maximally fast, since the source file is never touched by this function.
#
# Returns 0 if a smart-import was triggered (caller should skip the normal
# blocklist+research path for this item), 1 if declined or failed (caller should
# fall through to the normal path exactly as before this function existed).
# Args: url, api_key, api_version, arr_type, download_id, title
try_smart_import() {
local url="$1" api_key="$2" api_version="$3" arr_type="$4" download_id="$5" title="$6"
# Lidarr's manual-import matching doesn't reliably resolve album/track context
# (confirmed 2026-07-15 — 659/659 track candidates came back with no album
# match at all) — not worth attempting, always fall through to normal handling.
[[ "$arr_type" == "lidarr" ]] && return 1
[[ -z "$download_id" ]] && return 1
local candidates
candidates=$(curl -sf --max-time 30 \
-H "X-Api-Key: $api_key" \
"${url}/api/${api_version}/manualimport?downloadId=${download_id}" \
2>/dev/null)
[[ -z "$candidates" || "$candidates" == "[]" || "$candidates" == "null" ]] && return 1
# Any rejected file (Sample, Unknown Movie/Series, "Not an upgrade", etc.)
# disqualifies the whole download — conservative by design.
local rejected_count
rejected_count=$(echo "$candidates" | jq '[.[] | select(.rejections | length > 0)] | length' 2>/dev/null)
[[ -z "$rejected_count" || "$rejected_count" -gt 0 ]] && return 1
local file_count
file_count=$(echo "$candidates" | jq 'length' 2>/dev/null)
[[ -z "$file_count" || "$file_count" -eq 0 ]] && return 1
local preferred_lang="${ARR_SMART_IMPORT_PREFERRED_LANGUAGE:-English}"
local qualifying_files=()
local i entry target_id has_file existing existing_res existing_lang candidate_res candidate_lang decision
for (( i=0; i<file_count; i++ )); do
entry=$(echo "$candidates" | jq -c ".[$i]" 2>/dev/null)
[[ -z "$entry" ]] && return 1
candidate_res=$(echo "$entry" | jq -r '.quality.quality.resolution // 0' 2>/dev/null)
candidate_lang=$(echo "$entry" | jq -r '.languages[0].name // "Unknown"' 2>/dev/null)
existing_res=0
existing_lang="Unknown"
target_id=""
has_file="false"
case "$arr_type" in
sonarr)
# Multi-episode files complicate the existing-quality comparison per
# episode — skip rather than guess when a release covers more than one.
[[ "$(echo "$entry" | jq '.episodes | length' 2>/dev/null)" != "1" ]] && return 1
target_id=$(echo "$entry" | jq -r '.episodes[0].id // empty' 2>/dev/null)
has_file=$(echo "$entry" | jq -r '.episodes[0].hasFile // false' 2>/dev/null)
if [[ "$has_file" == "true" ]]; then
existing=$(curl -sf --max-time 15 -H "X-Api-Key: $api_key" \
"${url}/api/${api_version}/episode/${target_id}?includeEpisodeFile=true" 2>/dev/null)
existing_res=$(echo "$existing" | jq -r '.episodeFile.quality.quality.resolution // 0' 2>/dev/null)
existing_lang=$(echo "$existing" | jq -r '.episodeFile.languages[0].name // "Unknown"' 2>/dev/null)
fi
# /manualimport only nests the IDs under .series.id / .episodes[].id —
# the ManualImport command body needs them flattened to top-level
# seriesId/episodeIds or Sonarr rejects the whole command with
# "Series with ID 0 does not exist" (confirmed live 2026-07-19: every
# smart-import this run reported as successful had actually failed
# this way, silently, since the caller only checks the HTTP 201 accept).
entry=$(echo "$entry" | jq -c --argjson eid "$target_id" \
'. + {seriesId: .series.id, episodeIds: [$eid]}' 2>/dev/null)
;;
radarr)
target_id=$(echo "$entry" | jq -r '.movie.id // empty' 2>/dev/null)
has_file=$(echo "$entry" | jq -r '.movie.hasFile // false' 2>/dev/null)
if [[ "$has_file" == "true" ]]; then
existing=$(echo "$entry" | jq -c '.movie.movieFile // empty' 2>/dev/null)
if [[ -z "$existing" || "$existing" == "null" ]]; then
existing=$(curl -sf --max-time 15 -H "X-Api-Key: $api_key" \
"${url}/api/${api_version}/movie/${target_id}" 2>/dev/null | jq -c '.movieFile // empty')
fi
existing_res=$(echo "$existing" | jq -r '.quality.quality.resolution // 0' 2>/dev/null)
existing_lang=$(echo "$existing" | jq -r '.languages[0].name // "Unknown"' 2>/dev/null)
fi
# Same flattening issue as Sonarr above — command body needs a
# top-level movieId or Radarr rejects it with "Movie with ID 0
# does not exist".
entry=$(echo "$entry" | jq -c --argjson mid "$target_id" '. + {movieId: $mid}' 2>/dev/null)
;;
esac
[[ -z "$target_id" || -z "$entry" ]] && return 1
if [[ "$has_file" != "true" ]]; then
decision="import" # nothing there yet — fills a real gap
elif [[ "$candidate_lang" != "$preferred_lang" ]]; then
decision="decline" # never replace anything with a non-preferred language
elif [[ "$existing_lang" != "$preferred_lang" ]]; then
decision="import" # existing is wrong-language, candidate is right — upgrade
elif [[ "$candidate_res" -gt "$existing_res" ]]; then
decision="import" # same language, strictly higher resolution — upgrade
else
decision="decline" # same or worse, same language — no benefit
fi
[[ "$decision" == "decline" ]] && return 1
qualifying_files+=("$entry")
done
[[ "${#qualifying_files[@]}" -eq 0 ]] && return 1
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would smart-import: $title"
return 0
fi
local files_json cmd_body response http_code cmd_id cmd_status
files_json=$(printf '%s\n' "${qualifying_files[@]}" | jq -s -c '.' 2>/dev/null)
[[ -z "$files_json" ]] && return 1
cmd_body=$(jq -c -n --argjson files "$files_json" \
'{name:"ManualImport", files:$files, importMode:"auto"}' 2>/dev/null)
[[ -z "$cmd_body" ]] && return 1
response=$(curl -s -w '\n%{http_code}' -X POST \
-H "X-Api-Key: $api_key" -H "Content-Type: application/json" \
-d "$cmd_body" \
"${url}/api/${api_version}/command" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
cmd_id=$(echo "$response" | head -n -1 | jq -r '.id // empty' 2>/dev/null)
[[ "$http_code" != "201" || -z "$cmd_id" ]] && return 1
# A bad payload (e.g. the seriesId/movieId=0 bug this was written to catch)
# fails in ~10ms — well before any real file copy would even start — so a
# brief poll here catches that failure class without racing a genuinely
# long-running import, which is the reason this doesn't poll to completion.
local _i
for _i in 1 2 3; do
sleep 1
cmd_status=$(curl -sf --max-time 10 -H "X-Api-Key: $api_key" \
"${url}/api/${api_version}/command/${cmd_id}" 2>/dev/null | jq -r '.status // empty')
[[ "$cmd_status" == "failed" ]] && return 1
[[ "$cmd_status" == "completed" ]] && break
done
return 0
}
# ==============================================================================================
# ── PROCESS AN ARR ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -246,7 +490,11 @@ process_arr() {
local version_major="$7"
local version_api_prefix="$8"
local actioned=0 skipped_new=0
local actioned=0 skipped_new=0 chronic=0 smart_imported=0
local is_chronic fail_key fail_count
local -A seen_media_ids # media_ids appearing as a problem this run — anything NOT in
# here by the end has stopped being a problem and has its
# FAILURE_COUNTS entry pruned below
echo ""
echo "━━━ $ICON_SYNC $arr_name ━━━"
@@ -303,6 +551,7 @@ process_arr() {
(
.trackedDownloadState == "importFailed" or
.trackedDownloadState == "importPending" or
.trackedDownloadState == "importBlocked" or
.trackedDownloadStatus == "error" or
(.status == "warning" and (
(.errorMessage // "" | ascii_downcase | contains("stalled")) or
@@ -327,7 +576,7 @@ process_arr() {
while IFS= read -r item; do
[[ -z "$item" ]] && continue
local queue_id title added tracked_state tracked_status problem_type media_id
local queue_id title added tracked_state tracked_status problem_type media_id download_id
queue_id=$(echo "$item" | jq -r '.id // empty' 2>/dev/null)
title=$(echo "$item" | jq -r '.title // "Unknown"' 2>/dev/null)
@@ -339,6 +588,7 @@ process_arr() {
case "$tracked_state" in
importFailed) problem_type="import failed" ;;
importPending) problem_type="import pending/stuck" ;;
importBlocked) problem_type="import blocked (matched by ID)" ;;
*)
[[ "$tracked_status" == "error" ]] && \
problem_type="error" || problem_type="stalled"
@@ -352,6 +602,8 @@ process_arr() {
lidarr) media_id=$(echo "$item" | jq -r '.albumId // .album.id // empty' 2>/dev/null) ;;
esac
[[ -n "$media_id" ]] && seen_media_ids["$media_id"]=1
[[ -z "$queue_id" ]] && continue
# Age check — skip items that are too new to have self-resolved
@@ -362,6 +614,23 @@ process_arr() {
continue
fi
# importBlocked gets one extra chance before the normal blocklist path —
# most are junk/duplicates and fall straight through unchanged, but a
# clean same-language upgrade or gap-fill gets imported directly instead
# of discarded. See try_smart_import() for the full decision logic.
if [[ "${ARR_SMART_IMPORT_ENABLED:-true}" == "true" ]] && \
[[ "$tracked_state" == "importBlocked" ]]; then
download_id=$(echo "$item" | jq -r '.downloadId // empty' 2>/dev/null)
if try_smart_import "$url" "$api_key" "$api_version" "$arr_type" "$download_id" "$title"; then
log " $ICON_DONE Smart-imported (upgrade/gap-fill): $title"
(( smart_imported++ ))
(( TOTAL_SMART_IMPORTED++ ))
(( actioned++ ))
(( TOTAL_ACTIONED++ ))
continue
fi
fi
warn " $ICON_TRASH $problem_type$title"
# Step 1: Blocklist + remove from queue
@@ -372,8 +641,30 @@ process_arr() {
fi
log " Blocklisted: $queue_id"
# Step 2: Trigger new search
# Step 2: Circuit breaker — track consecutive failures per (arr_type, media_id).
# Some items can never resolve via blind retry (e.g. an album missing 1-2 tracks
# where no available release matches the existing edition) — without this, the
# same item gets blocklisted + re-searched forever, every run.
is_chronic=false
if [[ -n "$media_id" ]]; then
fail_key="${arr_type}:${media_id}"
fail_count=$(( ${FAILURE_COUNTS[$fail_key]:-0} + 1 ))
FAILURE_COUNTS[$fail_key]="$fail_count"
if [[ "$fail_count" -gt "$ARR_RECOVERY_MAX_ATTEMPTS" ]]; then
is_chronic=true
(( chronic++ ))
(( TOTAL_CHRONIC++ ))
warn " Chronic (${fail_count} consecutive failures) — needs manual review: $title"
[[ "$fail_count" -eq $(( ARR_RECOVERY_MAX_ATTEMPTS + 1 )) ]] && \
notify "$arr_name item now chronic after ${ARR_RECOVERY_MAX_ATTEMPTS} failed attempts — needs manual review: $title" \
"Arr Recovery" "warning"
fi
fi
# Step 3: Trigger new search — skipped for chronic items
if [[ "$is_chronic" == true ]]; then
log " Skipping auto re-search (chronic): $title"
elif [[ -n "$media_id" ]]; then
if trigger_search "$url" "$api_key" "$api_version" "$arr_type" "$media_id"; then
log " New search triggered: $title"
else
@@ -388,13 +679,28 @@ process_arr() {
done <<< "$problem_items"
# Prune stale failure counts — anything for this arr_type that isn't a problem in this
# run's queue snapshot has either imported successfully or is otherwise no longer stuck.
# FAILURE_COUNTS never decremented on success (confirmed live 2026-07-19: Sekirei S06E04
# sat chronic at count 4 despite hasFile=true, already fully resolved) — "consecutive
# failures" is supposed to mean consecutive since it last wasn't a problem, not a
# cumulative count for all time. Age-skipped items are still in seen_media_ids (added
# before the age check above), so a too-new item correctly keeps its count instead of
# being reset just for not having been acted on yet.
local _fc_key _fc_id
for _fc_key in "${!FAILURE_COUNTS[@]}"; do
[[ "$_fc_key" == "${arr_type}:"* ]] || continue
_fc_id="${_fc_key#${arr_type}:}"
[[ -z "${seen_media_ids[$_fc_id]:-}" ]] && unset "FAILURE_COUNTS[$_fc_key]"
done
if [[ "$actioned" -gt 0 ]]; then
warn "$arr_name — actioned: $actioned | skipped (too new): $skipped_new"
warn "$arr_name — actioned: $actioned (smart-imported: $smart_imported) | skipped (too new): $skipped_new | chronic: $chronic"
else
log "$arr_name — nothing actioned | skipped (too new): $skipped_new"
fi
ARR_SUMMARIES+=("$arr_name: actioned $actioned | too new $skipped_new")
ARR_SUMMARIES+=("$arr_name: actioned $actioned (smart-imported $smart_imported) | too new $skipped_new | chronic $chronic")
}
# ==============================================================================================
@@ -442,6 +748,15 @@ process_arr \
END=$(date +%s)
# Persist updated failure counts — skipped in dry-run so nothing is recorded for a preview
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$(dirname "$ARR_RECOVERY_FAILURE_COUNTS")" 2>/dev/null
: > "$ARR_RECOVERY_FAILURE_COUNTS"
for key in "${!FAILURE_COUNTS[@]}"; do
echo "${key}|${FAILURE_COUNTS[$key]}" >> "$ARR_RECOVERY_FAILURE_COUNTS"
done
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
@@ -450,7 +765,9 @@ echo "━━━━━ $ICON_SUMMARY ARR RECOVERY SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "$ICON_TRASH Actioned: $TOTAL_ACTIONED items blocklisted + searched"
echo "$ICON_DONE Smart-imported: $TOTAL_SMART_IMPORTED items (upgrade/gap-fill, kept instead of discarded)"
echo "$ICON_SKIP Skipped: $TOTAL_SKIPPED items (too new)"
echo "$ICON_WARN Chronic: $TOTAL_CHRONIC items (blocklisted, auto re-search stopped)"
echo ""
for summary in "${ARR_SUMMARIES[@]}"; do
echo " $ICON_SUMMARY $summary"
@@ -470,7 +787,7 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━
# Write stats for sunday_morning_coffee_report.sh
if [[ "$DRY_RUN" == false ]] && [[ -n "${ARR_RECOVERY_STATS:-}" ]]; then
echo "$(date '+%Y-%m-%d')|$(date '+%H:%M')|${TOTAL_ACTIONED}|${TOTAL_SKIPPED}" \
echo "$(date '+%Y-%m-%d')|$(date '+%H:%M')|${TOTAL_ACTIONED}|${TOTAL_SKIPPED}|${TOTAL_CHRONIC}" \
>> "$ARR_RECOVERY_STATS" 2>/dev/null || true
fi
+222 -225
View File
@@ -10,6 +10,29 @@
# that is old enough to be past the import window. Triggers an Emby library
# clean after runs where files were deleted so ghost entries disappear immediately.
#
# Pre-flight and the tracked-count floor check are both rescan-aware: a library-wide
# rescan legitimately makes tracked counts read low mid-scan (confirmed 2026-07-16 —
# 22% of normal during an active RescanFolders), which used to trigger this script's own
# hard abort every time it overlapped with a real rescan. Now it checks for an active
# rescan-type command first — if one's running, it waits (calibrated to that command's
# own historical duration via arr_get_rescan_duration(), up to 3 strikes) and re-fetches
# rather than either stacking a duplicate scan or crying wolf on a normal, if slow, state.
# Only escalates to the scary abort-and-notify when the count is genuinely low AND nothing
# is actively rescanning.
#
# Cache-first, both layers (2026-07-17). The artist list itself comes from the shared
# tracked-data cache via arr_get_tracked_data() — fresh (kept warm every 30min by
# arr_cache_prefill.sh), live fetch as fallback. The per-artist trackFile walk below still
# always fetches live (that's the actual disk-truth this script's delete decisions depend
# on), but write-throughs its result to arr_item_cache_write() so lidarr_missing_art.sh,
# running later in the same nightly window, can read it instead of repeating the same walk.
# The filesystem is walked once per run, not twice — classification records which paths are
# eligible for deletion as it goes, and the delete pass (once the size-threshold check below
# passes) just acts on that list instead of re-walking and re-classifying the whole tree.
# That single walk also gets size+ctime straight from find -printf instead of a separate stat
# fork per file — find already has to stat() every entry to know it's -type f, so this is
# free by comparison. Measured ~130x faster per file (0.033ms vs 4.3ms).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
@@ -39,6 +62,9 @@
# Files under LIDARR_ORPHAN_AGE are left alone regardless of tracked status.
# Lidarr's import pipeline writes files before registering them — acting
# immediately would delete files mid-import.
# Age is measured from ctime, not mtime — an import preserves the release's original
# mtime, so a file that landed today can read as years old and skip this gate. Depends
# on media_shares_permissions.sh touching only entries that are actually wrong.
#
# Seven-Gate Safety Model
# Multiple independent sanity checks must all pass before any file is touched.
@@ -60,9 +86,8 @@
#
# acquire_lock "wait" — large scans take time, wait for previous run to finish
# jq + curl validation — exits if either tool missing
# DOCKER_TIMEOUT — container checks protected against daemon hangs
# ARR_DOCKER_TIMEOUT — container checks protected against daemon hangs (script-local, not common.sh's DOCKER_TIMEOUT)
# Duplicate detection — temp file of tracked paths, grep before delete
# platform_require_cmd — notify script validated before use
# Silent by default — orphans/junk warn(), clean library logs silently
#
# ==============================================================================================
@@ -105,7 +130,7 @@
# lidarr_cleanup.sh --log — verbose output
# lidarr_cleanup.sh --status — show config and exit
# lidarr_cleanup.sh --i-know-what-im-doing — bypass size threshold
# lidarr_cleanup.sh --i-know-what-im-doing --skip-strike-list — NUCLEAR MODE
# lidarr_cleanup.sh --i-know-what-im-doing --skip-age-check — NUCLEAR MODE
#
# NUCLEAR MODE: both flags bypass age check AND size threshold. Use when Soularr
# has filled the gaps and you want a clean one-pass wipe. User accepts full
@@ -118,39 +143,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Special flag pre-processing ───────────────────────────────────────────────────────────────
# Filter --i-know-what-im-doing and --skip-strike-list before parse_args
# to avoid unknown flag errors — these are handled separately below.
I_KNOW=false
SKIP_STRIKES=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--i-know-what-im-doing) I_KNOW=true ;;
--skip-strike-list) SKIP_STRIKES=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
# Filter --i-know-what-im-doing and --skip-age-check before parse_args
# to avoid unknown flag errors — both are handled separately below.
parse_destructive_flags "$@"
parse_args "${FILTERED_ARGS[@]}"
# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────
if [[ "$I_KNOW" == true ]] && [[ "$SKIP_STRIKES" == true ]] && [[ "$DRY_RUN" != true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ WARNING — NUCLEAR MODE ACTIVE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Flags: --i-know-what-im-doing --skip-strike-list"
echo " Strike system: BYPASSED — deletes on first pass"
echo " Size threshold: BYPASSED — no GB limit"
echo " Data recovery: NOT POSSIBLE after deletion"
echo ""
echo " Review --dry-run output before proceeding."
echo " You have 10 seconds to cancel (Ctrl+C)..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
sleep 10
echo " Proceeding..."
echo ""
fi
nuclear_mode_warning
# ==============================================================================================
# ━━━ Setup ━━━
@@ -194,15 +194,11 @@ if [[ -z "${LIDARR_URL:-}" ]] || [[ -z "${LIDARR_API_KEY:-}" ]]; then
exit 0
fi
DOCKER_TIMEOUT=15
ARR_DOCKER_TIMEOUT=15
LIDARR_CONTAINER="Lidarr" # container name on HOST1
# Build path map from MY_ID's Lidarr path map
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_LIDARR_PATH_MAP"
eval "for key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$key\"]=\"\${${local_path_map_var}[\$key]}\"
done"
build_arr_path_map "LIDARR"
# Validate required vars — detect_hosts() should have set these
require_var LIDARR_URL
@@ -221,7 +217,7 @@ echo " $MY_ID ($LOCAL_SERVER_NAME) — $LIDARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
[[ "$SKIP_STRIKES" == true ]] && warn "OVERRIDE — --skip-strike-list active — age check bypassed"
[[ "$SKIP_AGE_CHECK" == true ]] && warn "OVERRIDE — --skip-age-check active — age check bypassed"
# ==============================================================================================
# ━━━ Status ━━━
@@ -240,7 +236,7 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo "$ICON_GEAR Protected patterns: ${LIDARR_PROTECTED_PATTERNS[*]}"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "$ICON_GEAR I know: $I_KNOW"
echo "$ICON_GEAR Skip strikes: $SKIP_STRIKES"
echo "$ICON_GEAR Skip age check: $SKIP_AGE_CHECK"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
@@ -251,83 +247,13 @@ fi
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
CONTAINER_RUNNING=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Running}}' "$LIDARR_CONTAINER" 2>/dev/null)
if [[ "$CONTAINER_RUNNING" != "true" ]]; then
error "$LIDARR_CONTAINER is not running — aborting"
notify "Lidarr cleanup aborted on $(hostname) — container not running" \
"Lidarr Cleanup" "warning"
exit 1
fi
CONTAINER_HEALTH=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Health.Status}}' "$LIDARR_CONTAINER" 2>/dev/null)
case "$CONTAINER_HEALTH" in
healthy) info "$LIDARR_CONTAINER is healthy" ;;
"") info "$LIDARR_CONTAINER has no health check — proceeding" ;;
starting)
error "$LIDARR_CONTAINER is still starting — aborting"
notify "Lidarr cleanup aborted on $(hostname) — container still starting" \
"Lidarr Cleanup" "warning"
exit 1 ;;
unhealthy)
error "$LIDARR_CONTAINER is unhealthy — aborting"
notify "Lidarr cleanup aborted on $(hostname) — container unhealthy" \
"Lidarr Cleanup" "warning"
exit 1 ;;
*) warn "$LIDARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution" ;;
esac
info "Safety layer 1 passed — container healthy"
check_container_health "$LIDARR_CONTAINER" "$ARR_DOCKER_TIMEOUT" "Lidarr Cleanup"
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Lidarr API call with HTTP status check
# Usage: lidarr_api "artist" | lidarr_api "trackFile?artistId=123"
lidarr_api() {
local endpoint="$1"
local response http_code body
response=$(curl -sf \
--max-time 30 \
-H "X-Api-Key: $LIDARR_API_KEY" \
-w "\n%{http_code}" \
"${LIDARR_URL}/api/v1/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "Lidarr API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
# Check if a file extension is a tracked music format
is_music_file() {
local ext="${1##*.}"
ext="${ext,,}"
for valid_ext in "${LIDARR_EXTENSIONS[@]}"; do
[[ "$ext" == "$valid_ext" ]] && return 0
done
return 1
}
# Check if a file matches any protected pattern
is_protected_file() {
local filename
filename=$(basename "$1")
for pattern in "${LIDARR_PROTECTED_PATTERNS[@]}"; do
# shellcheck disable=SC2254
case "$filename" in
$pattern) return 0 ;;
esac
done
return 1
}
# check_container_health(), arr_api(), has_extension(), matches_pattern_list() — common.sh
# ==============================================================================================
# ━━━ Pre-flight: Lidarr Import Scan ━━━
@@ -345,43 +271,30 @@ for _cp in "${!ARR_PATH_MAP[@]}"; do
done
unset _cp
if [[ -n "$LIDARR_CONTAINER_ROOT" ]]; then
# Don't stack a fresh scan on top of one already running — confirmed 2026-07-16 that
# repeated runs each firing their own DownloadedAlbumsScan piled up in Lidarr's command
# queue behind each other rather than replacing/coalescing, contributing to a multi-hour
# backlog. If something's already scanning, just wait for that one instead.
_already_active=$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")
if [[ -n "$_already_active" ]]; then
info "$_already_active already in progress — waiting for it instead of starting a new scan"
_wait=$(( $(arr_get_rescan_duration "lidarr" "$_already_active" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}") ))
_polled=0
while [[ "$_polled" -lt "$_wait" ]]; do
[[ -z "$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")" ]] && break
sleep 15
(( _polled += 15 ))
[[ $(( _polled % 60 )) -eq 0 ]] && log " Still waiting on $_already_active... (${_polled}s elapsed)"
done
elif [[ -n "$LIDARR_CONTAINER_ROOT" ]]; then
info "Triggering DownloadedAlbumsScan on: $LIDARR_CONTAINER_ROOT"
SCAN_PAYLOAD="{\"name\": \"DownloadedAlbumsScan\", \"path\": \"$LIDARR_CONTAINER_ROOT\"}"
trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$SCAN_PAYLOAD" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" "lidarr"
else
info "No path map match — triggering DownloadedAlbumsScan (all root folders)"
SCAN_PAYLOAD='{"name": "DownloadedAlbumsScan"}'
fi
SCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $LIDARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$SCAN_PAYLOAD" \
"${LIDARR_URL}/api/v1/command" 2>/dev/null)
SCAN_CMD_ID=$(echo "$SCAN_RESPONSE" | jq -r '.id // empty' 2>/dev/null)
if [[ -z "$SCAN_CMD_ID" ]]; then
warn "Could not trigger import scan — proceeding without pre-flight"
else
info "Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion..."
POLL_TIMEOUT=${LIDARR_IMPORT_SCAN_TIMEOUT:-600}
POLLED=0
while [[ "$POLLED" -lt "$POLL_TIMEOUT" ]]; do
SCAN_STATUS=$(curl -sf --max-time 10 \
-H "X-Api-Key: $LIDARR_API_KEY" \
"${LIDARR_URL}/api/v1/command/${SCAN_CMD_ID}" 2>/dev/null | \
jq -r '.status // empty' 2>/dev/null)
case "$SCAN_STATUS" in
completed) info "Import scan complete ✅"; break ;;
failed) warn "Import scan reported failed — proceeding anyway"; break ;;
esac
sleep 10
(( POLLED += 10 ))
[[ $(( POLLED % 60 )) -eq 0 ]] && log " Still scanning... (${POLLED}s elapsed)"
done
[[ "$POLLED" -ge "$POLL_TIMEOUT" ]] && \
warn "Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway"
trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$SCAN_PAYLOAD" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" "lidarr"
fi
# ==============================================================================================
@@ -401,8 +314,12 @@ check_arr_version "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$LIDARR_VERSION_MAJOR" "
info "Querying Lidarr API..."
# Fetch all artists
ARTIST_RESPONSE=$(lidarr_api "artist") || {
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (now kept
# current every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to
# a live fetch when it's stale, and waits out an active rescan before either. Only the artist
# list itself is cached — the per-artist trackFile data below is never cached and always live,
# since that's the actual disk-truth this script's cleanup decisions depend on.
ARTIST_RESPONSE=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1") || {
error "Failed to fetch artists from Lidarr"
notify "Lidarr cleanup failed on $(hostname) — could not fetch artists" \
"Lidarr Cleanup" "warning"
@@ -410,7 +327,7 @@ ARTIST_RESPONSE=$(lidarr_api "artist") || {
}
ARTIST_IDS=$(echo "$ARTIST_RESPONSE" | jq -r '.[].id' 2>/dev/null)
ARTIST_COUNT=$(echo "$ARTIST_IDS" | grep -c "[0-9]" 2>/dev/null || echo 0)
ARTIST_COUNT=$(echo "$ARTIST_IDS" | grep -c "[0-9]" 2>/dev/null || true)
# Safety Layer 4 — artist count > 0
if [[ "$ARTIST_COUNT" -eq 0 ]]; then
@@ -423,30 +340,49 @@ fi
info "Found $ARTIST_COUNT artists — fetching track files..."
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
> "$TRACKED_FILE"
while IFS= read -r artist_id; do
# Fetches every artist's track-file paths fresh into TRACKED_FILE/TRACKED_MAP/TRACKED_COUNT.
# Pulled into a function so the rescan-aware retry below can re-fetch after waiting without
# duplicating this whole loop inline.
#
# Write-through — also caches the raw per-track data via arr_item_cache_write() (2026-07-17)
# so scripts running later in the same maintenance window (lidarr_missing_art.sh) can read it
# instead of repeating this same per-artist walk. This walk is happening regardless for our
# own cleanup decisions; the cache write is free by comparison. See common.sh for the pattern.
_fetch_tracked_files() {
> "$TRACKED_FILE"
local all_tracks_tmp
all_tracks_tmp=$(mktemp)
while IFS= read -r artist_id; do
[[ -z "$artist_id" ]] && continue
ARTIST_TRACKS=$(lidarr_api "trackFile?artistId=${artist_id}" 2>/dev/null)
ARTIST_TRACKS=$(arr_api "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "trackFile?artistId=${artist_id}" "Lidarr" 2>/dev/null)
if [[ -n "$ARTIST_TRACKS" ]]; then
echo "$ARTIST_TRACKS" >> "$all_tracks_tmp"
while IFS= read -r api_path; do
[[ -z "$api_path" ]] && continue
translate_path "$api_path" >> "$TRACKED_FILE"
done < <(echo "$ARTIST_TRACKS" | jq -r '.[].path' 2>/dev/null)
fi
done <<< "$ARTIST_IDS"
done <<< "$ARTIST_IDS"
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
arr_item_cache_write "lidarr" "$(jq -s 'add // []' "$all_tracks_tmp" 2>/dev/null)"
rm -f "$all_tracks_tmp"
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
# Eliminates the main performance bottleneck for large libraries
declare -A TRACKED_MAP
while IFS= read -r _tracked_path; do
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
# Eliminates the main performance bottleneck for large libraries
unset TRACKED_MAP
declare -gA TRACKED_MAP
while IFS= read -r _tracked_path; do
[[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1
done < "$TRACKED_FILE"
unset _tracked_path
done < "$TRACKED_FILE"
unset _tracked_path
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
}
_fetch_tracked_files
info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths"
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
# Safety Layer 5 — tracked count > 0
if [[ "$TRACKED_COUNT" -eq 0 ]]; then
@@ -458,26 +394,42 @@ fi
info "$ARTIST_COUNT artists | $TRACKED_COUNT tracked files"
# Safety Layer 6 — percentage drop vs last known count
if [[ -f "$LIDARR_TRACKED_COUNT_FILE" ]]; then
LAST_COUNT=$(cat "$LIDARR_TRACKED_COUNT_FILE" 2>/dev/null || echo 0)
if [[ "$LAST_COUNT" -gt 0 ]]; then
PCT=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $LAST_COUNT) * 100}")
if [[ "$PCT" -lt "$LIDARR_MIN_TRACKED_PCT" ]]; then
error "Tracked count dropped to ${PCT}% of last run ($TRACKED_COUNT vs $LAST_COUNT)"
error "Suggests API issue — aborting to prevent mass deletion"
error "If expected (large library removal) delete: $LIDARR_TRACKED_COUNT_FILE"
notify "Lidarr cleanup aborted on $(hostname) — tracked count dropped to ${PCT}%" \
"Lidarr Cleanup" "warning"
exit 1
# Safety Layer 6 — percentage drop vs last known count, with rescan-aware retry.
# A library-wide rescan legitimately makes tracked counts read low mid-scan — sometimes
# dramatically (confirmed 2026-07-16: 22% of normal during an active RescanFolders).
# That's not "something's wrong," it's Lidarr actively re-verifying every file. Wait it out
# (calibrated to that command's own historical duration) before treating a drop as a genuine
# problem worth the scary abort-and-notify. Only escalates to the hard abort in
# check_tracked_count_floor if the count is still low AND nothing is actively rescanning —
# that combination is the actually-suspicious case the floor check exists to catch.
_last_known=$(cat "$LIDARR_TRACKED_COUNT_FILE" 2>/dev/null || echo 0)
if [[ "$_last_known" -gt 0 ]]; then
_strike=1
while [[ "$_strike" -le 3 ]]; do
_pct=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $_last_known) * 100}")
[[ "$_pct" -ge "${LIDARR_MIN_TRACKED_PCT:-50}" ]] && break
_active_cmd=$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")
[[ -z "$_active_cmd" ]] && break # low count, nothing rescanning — genuine, don't retry
_wait=$(( $(arr_get_rescan_duration "lidarr" "$_active_cmd" 300) / 2 ))
[[ "$_wait" -lt 30 ]] && _wait=30
warn "Tracked count ${_pct}% of last run, but $_active_cmd active — waiting ${_wait}s (strike ${_strike}/3)"
sleep "$_wait"
_fetch_tracked_files
(( _strike++ ))
done
if [[ "$_strike" -gt 3 ]]; then
_active_cmd=$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")
if [[ -n "$_active_cmd" ]]; then
warn "Lidarr still busy ($_active_cmd) after 3 strikes — deferring to next scheduled run"
exit 0
fi
info "Tracked count: ${PCT}% of last run ($TRACKED_COUNT vs $LAST_COUNT) ✅"
fi
else
info "No previous count on record — first run, saving baseline"
fi
echo "$TRACKED_COUNT" > "$LIDARR_TRACKED_COUNT_FILE"
check_tracked_count_floor "$TRACKED_COUNT" "$LIDARR_TRACKED_COUNT_FILE" "$LIDARR_MIN_TRACKED_PCT" "Lidarr Cleanup"
# ==============================================================================================
# ━━━ Scan Music Root ━━━
@@ -496,10 +448,42 @@ JUNK_BYTES=0
AGE_SECONDS=$(( LIDARR_ORPHAN_AGE * 86400 ))
NOW=$(date +%s)
MAX_DELETE_BYTES=$(awk "BEGIN {printf \"%d\", $LIDARR_MAX_DELETE_GB * 1073741824}")
while IFS= read -r filepath; do
# Files classified ORPHAN/JUNK below get their path recorded here, so the deletion pass can
# just delete them directly instead of re-walking and re-classifying the whole tree a second
# time (2026-07-17) — the size-threshold check below needs to know the total before deleting
# anything, not before knowing what to delete, so there's no need to redo the classification
# itself once it's already been decided.
TO_DELETE_FILE="$TMP_DIR/to_delete_paths.txt"
> "$TO_DELETE_FILE"
# ── Orphan strikes ────────────────────────────────────────────────────────────────────────────
# Same contract as radarr_cleanup.sh: a file must classify for deletion on
# LIDARR_ORPHAN_STRIKE_LIMIT consecutive runs before it is removed. Covers the partial
# classification failure that is too small to trip the tracked-count floor above. The file is
# rebuilt from each run rather than edited, which is what prunes it.
LIDARR_ORPHAN_STRIKE_LIMIT="${LIDARR_ORPHAN_STRIKE_LIMIT:-2}"
STRIKES_FILE="${LIDARR_ORPHAN_STRIKES_FILE:-$DB_DIR/lidarr_orphan_strikes.tsv}"
mkdir -p "$(dirname "$STRIKES_FILE")" 2>/dev/null || true
touch "$STRIKES_FILE" 2>/dev/null || true
STRIKES_NEW="$TMP_DIR/strikes_new.tsv"
> "$STRIKES_NEW"
HELD_COUNT=0
HELD_BYTES=0
orphan_strike_ok() {
local path="$1" prev strikes
prev=$(wd_state_get "$path" "$STRIKES_FILE"); prev="${prev//[^0-9]/}"
strikes=$(( ${prev:-0} + 1 ))
printf '%s:%s\n' "$path" "$strikes" >> "$STRIKES_NEW"
(( strikes >= LIDARR_ORPHAN_STRIKE_LIMIT )) && return 0
warn " strike $strikes/$LIDARR_ORPHAN_STRIKE_LIMIT — not removing yet: $path"
return 1
}
while read -r FILE_SIZE FILE_CTIME filepath; do
[[ -z "$filepath" ]] && continue
FILE_CTIME="${FILE_CTIME%%.*}"
# Tracked — leave alone
if [[ -n "${TRACKED_MAP[$filepath]:-}" ]]; then
@@ -508,19 +492,23 @@ while IFS= read -r filepath; do
fi
# Protected — never delete
if is_protected_file "$filepath"; then
if matches_pattern_list "$filepath" "${LIDARR_PROTECTED_PATTERNS[@]}"; then
log "$ICON_PROTECTED PROTECTED: $filepath"
(( PROTECTED_COUNT++ ))
continue
fi
FILE_SIZE=$(stat -c%s "$filepath" 2>/dev/null || echo 0)
if has_extension "$filepath" "${LIDARR_EXTENSIONS[@]}"; then
# ctime, not mtime — an import preserves the release's original mtime, so a file
# Lidarr moved in today can read as years old and skip this gate entirely.
# Measured 2026-07-27: 400 of 400 files imported that week had mtimes over 7
# days, one of them 9613 days. ctime is stamped when the file lands on this
# filesystem and cannot be carried in from an archive. This only holds because
# media_shares_permissions.sh applies owner/mode conditionally — a blanket
# chown/chmod restamps every inode nightly and would peg every file at age 0.
FILE_AGE=$(( NOW - FILE_CTIME ))
if is_music_file "$filepath"; then
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_STRIKES" != true ]]; then
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_AGE_CHECK" != true ]]; then
log "RECENT (skipping): $filepath"
(( RECENT_COUNT++ ))
continue
@@ -529,55 +517,67 @@ while IFS= read -r filepath; do
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
fi
done < <(find "$LIDARR_MUSIC_ROOT" -type f 2>/dev/null)
# -printf gets size + mtime directly from find's own stat() during the walk, instead of a
# separate stat fork per file (2026-07-17) — measured ~130x faster per file (0.033ms vs
# 4.3ms), since find already has to stat() every entry anyway to know it's -type f.
done < <(find "$LIDARR_MUSIC_ROOT" -type f -printf '%s %C@ %p\n' 2>/dev/null)
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
# Eligible, not classified: a file still serving its strikes is an orphan but is not queued this
# run, so it must not appear in the denominator the budget reports against.
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES - HELD_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT - HELD_COUNT ))
# Rebuilt, never edited. Skipped on a dry run: a preview that advanced real counters would make
# the next real run delete a run early.
if [[ "$DRY_RUN" == false ]]; then
mv "$STRIKES_NEW" "$STRIKES_FILE" 2>/dev/null || warn "Could not update $STRIKES_FILE"
fi
# ==============================================================================================
# ━━━ Safety Layer 7 — Deletion Size Threshold ━━━
# ==============================================================================================
if [[ "$TOTAL_DELETE_BYTES" -gt "$MAX_DELETE_BYTES" ]]; then
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $TOTAL_DELETE_BYTES / 1073741824}")
if [[ "$I_KNOW" != true ]]; then
echo ""
error "Deletion would exceed ${LIDARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted"
error "Review ORPHAN lines above carefully before proceeding"
error "Rerun with: --i-know-what-im-doing"
error "To also bypass age check: add --skip-strike-list"
notify "Lidarr cleanup halted on $(hostname)${TOTAL_HUMAN} requires --i-know-what-im-doing" \
# A per-run budget, not a veto — see apply_delete_budget() in common.sh. The ceiling still caps
# any single run; it just no longer deadlocks on a backlog larger than itself.
BUDGET_FILE="$TMP_DIR/to_delete_budgeted.txt"
if [[ "$I_KNOW" == true ]]; then
warn "OVERRIDE — --i-know-what-im-doing active, per-run budget not applied"
cut -d"$(printf '\t')" -f3- "$TO_DELETE_FILE" > "$BUDGET_FILE"
_BUDGET_KEPT_COUNT=$TOTAL_REMOVED; _BUDGET_KEPT_BYTES=$TOTAL_DELETE_BYTES
_BUDGET_DEFERRED_COUNT=0; _BUDGET_DEFERRED_BYTES=0; _BUDGET_STUCK=""
else
apply_delete_budget "$TO_DELETE_FILE" "$BUDGET_FILE" "$LIDARR_MAX_DELETE_GB"
if [[ -n "$_BUDGET_STUCK" ]]; then
error "Single file exceeds the ${LIDARR_MAX_DELETE_GB}GB budget on its own — nothing removed this run"
error " $_BUDGET_STUCK"
error "Raise LIDARR_MAX_DELETE_GB or clear this one with --i-know-what-im-doing"
notify "Lidarr cleanup stalled on $(hostname) — one file exceeds the ${LIDARR_MAX_DELETE_GB}GB budget" \
"Lidarr Cleanup" "warning"
exit 1
else
warn "OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing"
elif [[ "$_BUDGET_DEFERRED_COUNT" -gt 0 ]]; then
warn "Budget ${LIDARR_MAX_DELETE_GB}GB — removing $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED ($(format_bytes "$_BUDGET_KEPT_BYTES")), deferring $_BUDGET_DEFERRED_COUNT ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) to the next run"
notify "Lidarr cleanup removed $(format_bytes "$_BUDGET_KEPT_BYTES") of $(format_bytes "$TOTAL_DELETE_BYTES") on $(hostname)$_BUDGET_DEFERRED_COUNT file(s) deferred" \
"Lidarr Cleanup" "normal"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
# All safety layers passed — delete orphans and junk
# All safety layers passed — delete orphans and junk. Reuses TO_DELETE_FILE from the
# classification pass above instead of re-walking and re-classifying the whole tree again.
if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
[[ -n "${TRACKED_MAP[$filepath]:-}" ]] && continue
is_protected_file "$filepath" && continue
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if is_music_file "$filepath"; then
[[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && \
[[ "$SKIP_STRIKES" != true ]] && continue
fi
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < <(find "$LIDARR_MUSIC_ROOT" -type f 2>/dev/null)
done < "$BUDGET_FILE"
info "Cleaning up empty folders..."
find "$LIDARR_MUSIC_ROOT" -mindepth 1 -type d -empty -delete 2>/dev/null
@@ -586,16 +586,7 @@ fi
END=$(date +%s)
format_bytes() {
local bytes=$1
if (( bytes > 1073741824 )); then
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
elif (( bytes > 1048576 )); then
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
else
echo "${bytes}B"
fi
}
# format_bytes() — provided by common.sh
ORPHAN_HUMAN=$(format_bytes "$ORPHAN_BYTES")
JUNK_HUMAN=$(format_bytes "$JUNK_BYTES")
@@ -611,6 +602,10 @@ echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (cover art, metadata
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${LIDARR_ORPHAN_AGE} days)"
[[ "${HELD_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Held (strikes): $HELD_COUNT files ($(format_bytes "$HELD_BYTES")) — under ${LIDARR_ORPHAN_STRIKE_LIMIT} consecutive runs"
[[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Deferred: $_BUDGET_DEFERRED_COUNT files ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) — over the ${LIDARR_MAX_DELETE_GB}GB run budget"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
@@ -619,8 +614,10 @@ if [[ "$DRY_RUN" == true ]]; then
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
notify "Lidarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" "Lidarr Cleanup" "warning"
# What was actually removed, not what was classified. With strikes and a budget in force those
# differ, and reporting the classification as the outcome is the oldest bug shape here.
warn "$ICON_DONE Removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED eligible files ($(format_bytes "$_BUDGET_KEPT_BYTES"))"
notify "Lidarr cleanup on $(hostname) — removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED eligible files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" "Lidarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
fi
+363
View File
@@ -0,0 +1,363 @@
#!/bin/bash
# ==============================================================================================
# ======================= Lidarr Duplicate Artist Cleanup =======================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Detects and resolves duplicate artist entries in Lidarr's library — cases where the
# same display name (case-insensitive) is backed by two different MusicBrainz artist
# IDs. This happens when a search or list sync matches a same-named-but-different real
# artist and adds it alongside the one already in the library. From that point on,
# every completed download for that display name throws MultipleArtistsFoundException
# and can never import — Lidarr correctly refuses to guess which of the two it means.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# For each case-insensitive duplicate artist name found:
#
# Only one side has any tracked files
# → The zero-file side is a phantom — delete it (deleteFiles=false, nothing on disk
# to lose) and add its MusicBrainz ID to Lidarr's Import List Exclusions so it
# can't be silently re-added by a future list sync. Then trigger a refresh on the
# surviving artist so anything that was stuck on this exact ambiguity resolves
# immediately instead of waiting for Lidarr's own next check cycle.
#
# Both sides have files, but their album titles don't overlap at all
# → Genuinely two different real artists sharing a name (e.g. a band's classic
# lineup vs. a later solo era with the same stage name). Not a bug — left alone.
#
# Both sides have files AND overlapping album titles
# → The one case actually risky to automate: could mean real content is split
# across two entries and needs an actual merge, not a delete. Untouched,
# notified for manual review.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Never Delete Real Content
# Only the zero-tracked-file side of a pair is ever deleted. Anything with files is
# either left alone (disjoint albums) or flagged for a human (overlapping albums) —
# never auto-removed.
#
# Block Re-Addition At The Source
# A phantom that keeps coming back is worse than one that was never cleaned —
# Import List Exclusion is Lidarr's own mechanism for "never auto-add this again."
#
# Silent When Clean
# No duplicates found, or all duplicates are the simple phantom case — minimal
# output. Only ambiguous (overlapping-album) pairs produce a notification.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Required by the container interaction and state writes.
#
# Lock Acquisition
# acquire_lock prevents overlapping runs racing on the same artist IDs.
#
# Host Detection
# detect_hosts() aliases LIDARR_URL / LIDARR_API_KEY.
#
# jq Dependency Check
# Fails fast if jq is missing — duplicate detection and every file-count read
# depend on it, and an absent jq would evaluate counts to empty and make every
# artist look like a zero-file phantom.
#
# API Reachability + Version Gate
# check_api then check_arr_version against LIDARR_VERSION_MAJOR before any read.
#
# Empty Library Abort
# A response of 0 artists aborts. An empty list is indistinguishable from
# "no duplicates" and must never be read as a clean result.
#
# Tracked-Count Floor
# check_tracked_count_floor against the baseline shared with lidarr_cleanup.sh.
# A library-wide desync — mid full-rescan, for example — makes trackFileCount read
# far below reality for many artists at once. Confirmed 2026-07-16: without this,
# both sides of a genuinely-real duplicate (ROMES) read as 0-file phantoms and the
# wrong one would have been deleted. Deliberately reuses lidarr_cleanup.sh's own
# baseline so every script depending on tracked counts shares one answer to "is
# Lidarr's data trustworthy right now" rather than forming a separate opinion.
#
# Files Never Deleted
# Removal passes deleteFiles=false. Only the phantom Lidarr entry is dropped;
# nothing on disk is touched, so a wrong call costs a re-add, not media.
#
# Phantom-Only Deletion
# Only the zero-file side of a duplicate pair is ever removed. If both sides hold
# files, or neither does unambiguously, the pair is flagged for manual review
# instead — the script never picks a winner between two real artists.
#
# Manual Review Reporting
# Flagged pairs are named in the summary and notification so an ambiguous
# duplicate surfaces as a decision to make rather than disappearing silently.
#
# Dry Run Support
# --dry-run reports every deletion and exclusion and performs none.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY — aliased by detect_hosts()
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# lidarr_duplicate_artist_cleanup.sh — normal run
# lidarr_duplicate_artist_cleanup.sh --dry-run — preview, no deletions or exclusions
# lidarr_duplicate_artist_cleanup.sh --log — verbose per-pair output
# lidarr_duplicate_artist_cleanup.sh --status — show config and exit
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
notify "lidarr_duplicate_artist_cleanup failed on $(hostname) — jq not installed" \
"Lidarr Duplicate Cleanup" "warning"
exit 1
fi
acquire_lock "wait"
# detect_hosts() sets MY_ID and aliases LIDARR_URL, LIDARR_API_KEY
detect_hosts
if [[ -z "${LIDARR_URL:-}" ]] || [[ -z "${LIDARR_API_KEY:-}" ]]; then
info "Lidarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no artists will be deleted or excluded"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Lidarr URL: $LIDARR_URL"
echo "$ICON_GEAR Lidarr ver: v${LIDARR_VERSION_MAJOR:-3} expected"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Safety Checks ━━━
# ==============================================================================================
check_api "$LIDARR_URL" "Lidarr" 10 || {
notify "Lidarr duplicate cleanup aborted on $(hostname) — API unreachable" \
"Lidarr Duplicate Cleanup" "warning"
exit 1
}
check_arr_version "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "${LIDARR_VERSION_MAJOR:-3}" "Lidarr" || exit 1
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
# ==============================================================================================
# ━━━ Fetch Artists (cache-aware — waits out an active rescan rather than trusting a
# mid-scan number, falls back to cache if Lidarr's still busy after the strike limit) ━━━
# ==============================================================================================
ARTISTS=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")
if [[ -z "$ARTISTS" ]]; then
warn "Lidarr busy and no usable cache — deferring to next scheduled run"
exit 0
fi
ARTIST_COUNT=$(echo "$ARTISTS" | jq 'length' 2>/dev/null)
if [[ -z "$ARTIST_COUNT" || "$ARTIST_COUNT" -eq 0 ]]; then
error "API returned 0 artists — aborting to avoid acting on empty data"
notify "Lidarr duplicate cleanup aborted on $(hostname) — 0 artists returned" \
"Lidarr Duplicate Cleanup" "warning"
exit 1
fi
log "$ICON_GEAR Fetched $ARTIST_COUNT artists"
# Safety Layer — same shared baseline as lidarr_cleanup.sh. A library-wide desync (e.g. mid
# full-rescan) can make trackFileCount read far lower than reality for many artists at once —
# confirmed 2026-07-16, where this exact scenario would have made the script see both sides
# of a genuinely-real duplicate (ROMES) as 0-file phantoms and delete the wrong thing entirely.
# Reuses lidarr_cleanup.sh's own baseline file — one shared "is Lidarr's data trustworthy
# right now" answer for every script that depends on tracked counts, not a separate opinion
# per script.
TOTAL_TRACKED=$(echo "$ARTISTS" | jq '[.[].statistics.trackFileCount] | add' 2>/dev/null)
check_tracked_count_floor "${TOTAL_TRACKED:-0}" "$LIDARR_TRACKED_COUNT_FILE" "${LIDARR_MIN_TRACKED_PCT:-50}" "Lidarr Duplicate Cleanup"
# ==============================================================================================
# ━━━ Find Case-Insensitive Duplicate Names ━━━
# ==============================================================================================
DUP_NAMES=$(echo "$ARTISTS" | jq -r '.[].artistName' | tr '[:upper:]' '[:lower:]' | sort | uniq -d)
if [[ -z "$DUP_NAMES" ]]; then
echo "Lidarr — clean ✅ no duplicate artist names"
exit 0
fi
DUP_COUNT=$(echo "$DUP_NAMES" | grep -c .)
warn "Found $DUP_COUNT duplicate artist name(s)"
DELETED=0
EXCLUDED=0
LEFT_ALONE=0
FLAGGED=0
FLAGGED_NAMES=()
# ==============================================================================================
# ━━━ Resolve Each Duplicate ━━━
# ==============================================================================================
while IFS= read -r lname; do
[[ -z "$lname" ]] && continue
MEMBERS=$(echo "$ARTISTS" | jq -c --arg n "$lname" '[.[] | select((.artistName|ascii_downcase)==$n)]')
NONZERO_IDS=()
ZERO_MEMBERS_FILE="$TMP_DIR/zero_${RANDOM}.jsonl"
: > "$ZERO_MEMBERS_FILE"
while IFS= read -r member; do
[[ -z "$member" ]] && continue
fc=$(echo "$member" | jq -r '.statistics.trackFileCount // 0')
if [[ "$fc" -gt 0 ]]; then
NONZERO_IDS+=("$(echo "$member" | jq -r '.id')")
else
echo "$member" >> "$ZERO_MEMBERS_FILE"
fi
done < <(echo "$MEMBERS" | jq -c '.[]')
if [[ "${#NONZERO_IDS[@]}" -le 1 ]]; then
# Simple phantom case — delete every zero-file member, keep the real one (if any)
while IFS= read -r zmember; do
[[ -z "$zmember" ]] && continue
zid=$(echo "$zmember" | jq -r '.id')
zname=$(echo "$zmember" | jq -r '.artistName')
zmbid=$(echo "$zmember" | jq -r '.foreignArtistId')
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would delete phantom: $zname ($zid) and exclude MBID $zmbid"
continue
fi
if curl -sf --max-time 15 -X DELETE \
"${LIDARR_URL}/api/v1/artist/${zid}?deleteFiles=false" \
-H "X-Api-Key: $LIDARR_API_KEY" >/dev/null 2>&1; then
(( DELETED++ ))
log " $ICON_TRASH Deleted phantom: $zname ($zid)"
else
warn " Failed to delete phantom: $zname ($zid)"
continue
fi
EXCL_PAYLOAD=$(jq -c -n --arg fid "$zmbid" --arg name "$zname" \
'{foreignId:$fid, artistName:$name}')
if curl -sf --max-time 15 -X POST \
"${LIDARR_URL}/api/v1/importlistexclusion" \
-H "X-Api-Key: $LIDARR_API_KEY" -H "Content-Type: application/json" \
-d "$EXCL_PAYLOAD" >/dev/null 2>&1; then
(( EXCLUDED++ ))
log " Added to import list exclusions: $zmbid"
else
warn " Could not add exclusion for $zname ($zmbid) — may already exist"
fi
done < "$ZERO_MEMBERS_FILE"
# Nudge the surviving real artist so anything stuck on this ambiguity
# resolves now rather than waiting for Lidarr's own next check cycle.
if [[ "${#NONZERO_IDS[@]}" -eq 1 && "$DRY_RUN" != true ]]; then
curl -sf --max-time 15 -X POST \
"${LIDARR_URL}/api/v1/command" \
-H "X-Api-Key: $LIDARR_API_KEY" -H "Content-Type: application/json" \
-d "{\"name\":\"RefreshArtist\",\"artistId\":${NONZERO_IDS[0]}}" >/dev/null 2>&1
fi
else
# 2+ members have real content — same name, need to know if it's the same
# artist actually split (album overlap) or genuinely different acts (no overlap).
#
# Titles must be deduped WITHIN each artist before checking overlap ACROSS artists —
# a single artist can legitimately list the same album title twice (a reissue, a
# deluxe edition under an unchanged title). Confirmed 2026-07-16: Alice Cooper's own
# catalog has "Lace and Whiskey" and "School's Out" each listed twice under one
# artist ID — treating that as "overlap" false-flagged a genuinely disjoint pair
# (band-era vs. solo-era) as ambiguous when it wasn't.
OVERLAP=false
declare -A GLOBAL_TITLES
for nid in "${NONZERO_IDS[@]}"; do
declare -A THIS_ARTIST_TITLES
while IFS= read -r title; do
[[ -z "$title" ]] && continue
THIS_ARTIST_TITLES["$title"]=1
done < <(curl -sf --max-time 15 "${LIDARR_URL}/api/v1/album?artistId=${nid}" \
-H "X-Api-Key: $LIDARR_API_KEY" 2>/dev/null | jq -r '.[].title')
for title in "${!THIS_ARTIST_TITLES[@]}"; do
[[ -n "${GLOBAL_TITLES[$title]:-}" ]] && OVERLAP=true
GLOBAL_TITLES["$title"]=1
done
unset THIS_ARTIST_TITLES
done
unset GLOBAL_TITLES
if [[ "$OVERLAP" == true ]]; then
warn " $ICON_WARN Ambiguous duplicate needs manual review: $lname (both have files, albums overlap)"
FLAGGED_NAMES+=("$lname")
(( FLAGGED++ ))
else
log " $lname — different real artists sharing a name, no album overlap, no action"
(( LEFT_ALONE++ ))
fi
fi
done <<< "$DUP_NAMES"
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY LIDARR DUPLICATE ARTIST CLEANUP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TRASH Deleted: $DELETED phantom artist(s)"
echo "$ICON_GEAR Excluded: $EXCLUDED (blocked from future re-add)"
echo "$ICON_SKIP Left alone: $LEFT_ALONE (different real artists, no overlap)"
echo "$ICON_WARN Flagged: $FLAGGED (needs manual review)"
for n in "${FLAGGED_NAMES[@]}"; do
echo " - $n"
done
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$FLAGGED" -gt 0 ]]; then
notify "Lidarr duplicate cleanup on $(hostname)$DELETED phantom(s) removed, $FLAGGED artist(s) need manual review: ${FLAGGED_NAMES[*]}" \
"Lidarr Duplicate Cleanup" "warning"
elif [[ "$DELETED" -gt 0 ]]; then
echo "$ICON_DONE Status: cleaned $DELETED phantom artist(s), nothing needs review"
else
echo "$ICON_DONE Status: no action needed"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
@@ -24,6 +24,22 @@
# tags or renames media files. Only writes missing artwork files to existing
# album/artist directories.
#
# Cache-first, both layers (2026-07-17). The artist list comes from the shared tracked-data
# cache via arr_get_tracked_data() — fresh (kept warm every 30min by arr_cache_prefill.sh),
# live fetch as fallback. The per-artist track-file walk used to build the album→directory
# map now reads lidarr_cleanup.sh's write-through cache first (arr_get_cached_items() —
# lidarr_cleanup.sh runs earlier in the same nightly window and already does this exact
# walk for its own cleanup decisions), falling back to its own live per-artist walk only if
# that cache is missing or from outside the current window.
#
# Negative art cache (2026-07-28). fanart.tv has no cdart/back art for most of the long tail —
# roughly 80% of this library — so the nightly run was re-asking about the same ~11.8K albums
# every night and getting the same nothing back. Measured 2h41m for 3 images fetched. A flat TSV
# in DATA_DIR now remembers "upstream has no <art type> for <mbid>" and skips the API call
# entirely until LIDARR_ART_RECHECK_DAYS has passed, so new fanart.tv contributions are still
# picked up, just monthly instead of nightly. Only genuine no-art-upstream results are cached —
# a failed download of a URL that did exist stays retryable on the next run.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
@@ -56,6 +72,8 @@
# Parallel job cap — LIDARR_ART_MAX_PARALLEL — avoids hammering external APIs
# Download retries — LIDARR_ART_RETRIES attempts per image before giving up
# Rate limiting — LIDARR_ART_SLEEP_BETWEEN between fanart.tv API calls
# Negative cache — skips entities whose every missing target is a known upstream miss
# Cache expiry on merge — entries past the recheck window are dropped, file stays bounded
#
# ==============================================================================================
# CONFIGURATION
@@ -74,6 +92,8 @@
# LIDARR_ART_MAX_PARALLEL — concurrent background download jobs
# LIDARR_ART_RETRIES — download retry attempts per image
# LIDARR_ART_SLEEP_BETWEEN — seconds between fanart.tv API calls
# LIDARR_ART_RECHECK_DAYS — days before re-querying art upstream didn't have
# LIDARR_ART_MISS_CACHE — path to the negative cache TSV
#
# ==============================================================================================
# RUNTIME MODES
@@ -82,6 +102,7 @@
# lidarr_missing_art.sh — fetch all missing artwork
# lidarr_missing_art.sh --dry-run — preview without downloading
# lidarr_missing_art.sh --log — verbose per-item output
# lidarr_missing_art.sh --refresh — ignore the negative cache, re-query everything
# lidarr_missing_art.sh --status — show config and exit
#
# ==============================================================================================
@@ -92,6 +113,15 @@ source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# --refresh ignores the negative cache for this run — use after fanart.tv has had time to gain
# new contributions, or to re-prove a miss set by hand. Does not clear the cache; misses found
# this run simply overwrite their old stamps.
ART_REFRESH=false
for _arg in "${PARSED_ARGS[@]}"; do
[[ "$_arg" == "--refresh" ]] && ART_REFRESH=true
done
unset _arg
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
@@ -130,6 +160,13 @@ unset _key
info "$MY_ID ($LOCAL_SERVER_NAME) — tools OK"
log "$ICON_GEAR Config: url=${LIDARR_URL}"
# Defaulted here rather than relying solely on master.conf: Configurations/master.conf is
# gitignored, so these reach a node through conf_upgrade seeding them from
# Deployment/master.conf.template — which lands in the same window as this script, not before
# it. Same guard style as arr_corruption_scan.sh's state file.
LIDARR_ART_RECHECK_DAYS="${LIDARR_ART_RECHECK_DAYS:-30}"
LIDARR_ART_MISS_CACHE="${LIDARR_ART_MISS_CACHE:-$DATA_DIR/lidarr_art_miss_cache.tsv}"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
@@ -144,6 +181,8 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo "$ICON_GEAR Parallel: $LIDARR_ART_MAX_PARALLEL jobs"
echo "$ICON_RETRY Retries: $LIDARR_ART_RETRIES"
echo "$ICON_TIME API sleep: ${LIDARR_ART_SLEEP_BETWEEN}s"
echo "$ICON_GEAR Miss cache: $LIDARR_ART_MISS_CACHE ($([[ -f "$LIDARR_ART_MISS_CACHE" ]] && wc -l < "$LIDARR_ART_MISS_CACHE" || echo 0) entries)"
echo "$ICON_TIME Recheck: every ${LIDARR_ART_RECHECK_DAYS}d"
echo "$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${MY_DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
@@ -155,8 +194,21 @@ fi
# ── Temp dir for subshell fetch/fail counters ─────────────────────────────────────────────────
LIDARR_TMP=$(mktemp -d)
trap '_release_all_locks; rm -rf "$LIDARR_TMP"' EXIT
touch "$LIDARR_TMP/album_fetches" "$LIDARR_TMP/album_fails" \
"$LIDARR_TMP/artist_fetches" "$LIDARR_TMP/artist_fails"
touch "$LIDARR_TMP/album_fetches" "$LIDARR_TMP/album_nourl" "$LIDARR_TMP/album_dlfail" \
"$LIDARR_TMP/artist_fetches" "$LIDARR_TMP/artist_nourl" "$LIDARR_TMP/artist_dlfail" \
"$LIDARR_TMP/album_misses" "$LIDARR_TMP/artist_misses"
# ── Negative art cache state ──────────────────────────────────────────────────────────────────
NOW=$(date +%s)
ART_RECHECK_SECS=$(( LIDARR_ART_RECHECK_DAYS * 86400 ))
mkdir -p "$(dirname "$LIDARR_ART_MISS_CACHE")"
touch "$LIDARR_ART_MISS_CACHE"
declare -A ART_MISS
while IFS=$'\t' read -r _m_key _m_stamp; do
[[ -n "$_m_key" ]] && ART_MISS["$_m_key"]="$_m_stamp"
done < "$LIDARR_ART_MISS_CACHE"
unset _m_key _m_stamp
# ==============================================================================================
# ── FUNCTIONS ─────────────────────────────────────────────────────────────────────────────────
@@ -177,7 +229,12 @@ wait_for_slot() {
}
# Downloads URL to dest only if dest doesn't exist and downloaded size >= MIN_SIZE.
# Returns 0 on success or skip (file already exists), 1 on failure.
# Returns 0 on success or skip (file already exists), 1 when the source had no URL to offer,
# 2 when a URL existed but every download attempt failed.
#
# The 1-vs-2 split is what makes the negative cache safe: 1 means upstream genuinely has no
# such artwork and is worth remembering, 2 means a transient network/CDN problem that must
# stay retryable. Caching a 2 would suppress a legitimate retry for LIDARR_ART_RECHECK_DAYS.
download_if_valid() {
local url="$1"
local dest="$2"
@@ -206,7 +263,67 @@ download_if_valid() {
done
warn "Failed to fetch: $(basename "$dest")"
return 1
return 2
}
# ── Negative art cache ────────────────────────────────────────────────────────────────────────
# Same shape as arr_corruption_scan.sh's clean-file skip cache: a flat TSV of "<key>\t<epoch>"
# in DATA_DIR, slurped into an assoc array once at startup.
#
# Keyed per (entity, artwork filename) so an album that got cover.jpg from iTunes but has no
# cdart upstream still caches the cdart miss alone. MBID is the key where present because it
# survives a Lidarr DB rebuild; albums with no MBID fall back to the Lidarr id.
art_key() {
local mbid="$1" fallback_id="$2"
if [[ -n "$mbid" && "$mbid" != "null" ]]; then echo "$mbid"; else echo "lidarrid:$fallback_id"; fi
}
art_miss_fresh() {
local stamp="${ART_MISS[$1]:-}"
[[ -z "$stamp" ]] && return 1
(( NOW - stamp < ART_RECHECK_SECS ))
}
# True when every artwork target still missing from disk is a known-fresh upstream miss —
# i.e. this entity cannot possibly gain anything from another round of API calls right now.
# This is the check that skips the fanart.tv request entirely, which is where the time goes.
art_all_cached() {
local dir="$1"; local key="$2"; shift 2
local target
[[ "$ART_REFRESH" == true ]] && return 1
for target in "$@"; do
[[ -f "$dir/$target" ]] && continue
art_miss_fresh "${key}:${target}" || return 1
done
return 0
}
# Merges this run's fresh misses into the persistent cache, newest wins per key (fresh file is
# read first so awk's first-seen is always the newer stamp). Entries past the recheck window are
# dropped rather than carried: they would be re-queried on the next run anyway, so expiring them
# here is free and keeps the file from growing without bound as albums leave the library.
merge_art_misses() {
local fresh="$1" tmp
[[ "$DRY_RUN" == true ]] && return 0
[[ -s "$fresh" ]] || return 0
tmp=$(mktemp)
cat "$fresh" "$LIDARR_ART_MISS_CACHE" 2>/dev/null |
awk -F'\t' -v cutoff="$(( NOW - ART_RECHECK_SECS ))" \
'NF==2 && !seen[$1]++ && $2 >= cutoff' | sort > "$tmp"
mv "$tmp" "$LIDARR_ART_MISS_CACHE"
}
# Runs one single-source artwork target and classifies the outcome. Must be called from inside
# a fetch subshell — it updates that subshell's _fetches/_nourl/_dlfail/_miss_keys directly.
try_single() {
local dest="$1" url="$2" key="$3" rc
[[ -f "$dest" ]] && return 0
download_if_valid "$url" "$dest"; rc=$?
case "$rc" in
0) (( _fetches++ )) ;;
2) (( _dlfail++ )) ;;
*) (( _nourl++ )); _miss_keys+="${key}"$'\t'"${NOW}"$'\n' ;;
esac
}
deezer_artist_image() {
@@ -242,9 +359,11 @@ START=$(date +%s)
ALBUMS_CHECKED=0
ALBUMS_COMPLETE=0
ALBUMS_CACHED=0
ARTISTS_CHECKED=0
ARTISTS_COMPLETE=0
ARTISTS_CACHED=0
# ==============================================================================================
# ━━━ Build Album Directory Map ━━━
@@ -254,19 +373,39 @@ echo ""
echo "━━━ $ICON_SYNC Building Album Directory Map ━━━"
declare -A ALBUM_DIR_MAP
_artist_list=$(curl_json "$LIDARR_URL/api/v1/artist?apikey=$LIDARR_API_KEY")
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (now kept
# current every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to
# a live fetch when it's stale, and waits out an active rescan before either.
_artist_list=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")
_map_artist_count=$(echo "$_artist_list" | jq '. | length')
info "Fetching track files for $_map_artist_count artists..."
while IFS= read -r _artist_id; do
# Cache-first for the per-track data too — lidarr_cleanup.sh (runs earlier in the same nightly
# window) already does this exact per-artist walk for its own cleanup decisions and writes the
# result through via arr_item_cache_write(). Read that instead of repeating the walk; fall
# back to the live per-artist walk below only if it's missing or from outside this window.
# See arr_item_cache_write()/arr_get_cached_items() in common.sh. (2026-07-17)
_cached_tracks=$(arr_get_cached_items "lidarr")
if [[ -n "$_cached_tracks" ]]; then
info "Using cached track data from lidarr_cleanup.sh — skipping live per-artist walk"
while IFS=$'\t' read -r _album_id _track_path; do
[[ -z "$_album_id" || -z "$_track_path" || "$_track_path" == "null" ]] && continue
# Parameter expansion instead of external dirname — this loop runs once per track
# (127K+ on this library), and dirname forks a subprocess per call. Measured
# 2026-07-17: ~185x faster (0.39s vs 72.2s for 20K calls) for the identical result.
ALBUM_DIR_MAP["$_album_id"]="${_track_path%/*}"
done < <(echo "$_cached_tracks" | jq -r '.[] | [(.albumId | tostring), .path] | @tsv' 2>/dev/null)
else
while IFS= read -r _artist_id; do
[[ -z "$_artist_id" ]] && continue
while IFS=$'\t' read -r _album_id _track_path; do
[[ -z "$_album_id" || -z "$_track_path" || "$_track_path" == "null" ]] && continue
ALBUM_DIR_MAP["$_album_id"]=$(dirname "$_track_path")
ALBUM_DIR_MAP["$_album_id"]="${_track_path%/*}"
done < <(curl_json "$LIDARR_URL/api/v1/trackFile?artistId=${_artist_id}&apikey=$LIDARR_API_KEY" | \
jq -r '.[] | [(.albumId | tostring), .path] | @tsv' 2>/dev/null)
done < <(echo "$_artist_list" | jq -r '.[].id')
unset _artist_list _map_artist_count _artist_id _album_id _track_path
done < <(echo "$_artist_list" | jq -r '.[].id')
fi
unset _artist_list _map_artist_count _artist_id _album_id _track_path _cached_tracks
info "Mapped ${#ALBUM_DIR_MAP[@]} albums with local tracks"
@@ -305,10 +444,18 @@ while IFS=$'\t' read -r mbid artist_name album_name album_id; do
continue
fi
akey=$(art_key "$mbid" "$album_id")
if art_all_cached "$local_path" "$akey" cover.jpg cdart.png back.jpg; then
(( ALBUMS_CACHED++ ))
log " every missing target is a known upstream miss — skipping"
continue
fi
wait_for_slot
(
_fetches=0 _fails=0
_fetches=0 _nourl=0 _dlfail=0 _miss_keys=""
JSON=""
if [[ -n "$mbid" && "$mbid" != "null" ]]; then
@@ -316,44 +463,56 @@ while IFS=$'\t' read -r mbid artist_name album_name album_id; do
sleep "$LIDARR_ART_SLEEP_BETWEEN"
fi
# cover.jpg has a two-source chain, so it tracks whether *any* source offered a URL:
# only a clean no-URL-anywhere result is cacheable.
if [[ ! -f "$local_path/cover.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.[].albumcover[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/cover.jpg"; then
_saw_url=false
IMG=$(echo "$JSON" | jq -r '.albums[].albumcover[0].url // empty' 2>/dev/null)
download_if_valid "$IMG" "$local_path/cover.jpg"; _rc=$?
(( _rc == 2 )) && _saw_url=true
if (( _rc == 0 )); then
(( _fetches++ ))
else
query=$(printf "%s %s" "$artist_name" "$album_name" | sed 's/ /+/g')
itunes=$(curl_json "https://itunes.apple.com/search?term=$query&entity=album&limit=1" |
jq -r '.results[0].artworkUrl100 // empty' 2>/dev/null | sed 's/100x100/600x600/')
if download_if_valid "$itunes" "$local_path/cover.jpg"; then
download_if_valid "$itunes" "$local_path/cover.jpg"; _rc=$?
(( _rc == 2 )) && _saw_url=true
if (( _rc == 0 )); then
(( _fetches++ ))
elif [[ "$_saw_url" == true ]]; then
(( _dlfail++ ))
else
(( _fails++ ))
(( _nourl++ )); _miss_keys+="${akey}:cover.jpg"$'\t'"${NOW}"$'\n'
fi
fi
fi
if [[ ! -f "$local_path/cdart.png" ]]; then
IMG=$(echo "$JSON" | jq -r '.[].cdart[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/cdart.png"; then (( _fetches++ )); else (( _fails++ )); fi
fi
try_single "$local_path/cdart.png" \
"$(echo "$JSON" | jq -r '.albums[].cdart[0].url // empty' 2>/dev/null)" \
"${akey}:cdart.png"
if [[ ! -f "$local_path/back.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.[].albumback[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/back.jpg"; then (( _fetches++ )); else (( _fails++ )); fi
fi
try_single "$local_path/back.jpg" \
"$(echo "$JSON" | jq -r '.albums[].albumback[0].url // empty' 2>/dev/null)" \
"${akey}:back.jpg"
(( _fetches > 0 )) && printf '1\n' >> "$LIDARR_TMP/album_fetches"
(( _fails > 0 )) && printf '1\n' >> "$LIDARR_TMP/album_fails"
(( _nourl > 0 )) && printf '1\n' >> "$LIDARR_TMP/album_nourl"
(( _dlfail > 0 )) && printf '1\n' >> "$LIDARR_TMP/album_dlfail"
[[ -n "$_miss_keys" ]] && printf '%s' "$_miss_keys" >> "$LIDARR_TMP/album_misses"
) &
done < <(echo "$albums" | jq -r '.[] | [(.foreignAlbumId // ""), (.artist.artistName // ""), (.title // ""), (.id | tostring)] | @tsv')
wait
merge_art_misses "$LIDARR_TMP/album_misses"
ALBUM_FETCHED=$(wc -l < "$LIDARR_TMP/album_fetches" 2>/dev/null || echo 0)
ALBUM_FAILED=$(wc -l < "$LIDARR_TMP/album_fails" 2>/dev/null || echo 0)
ALBUM_NOART=$(wc -l < "$LIDARR_TMP/album_nourl" 2>/dev/null || echo 0)
ALBUM_DLFAIL=$(wc -l < "$LIDARR_TMP/album_dlfail" 2>/dev/null || echo 0)
ALBUM_MISSING=$(( ALBUMS_CHECKED - ALBUMS_COMPLETE ))
info "Checked: $ALBUMS_CHECKED | Complete: $ALBUMS_COMPLETE | Needed art: $ALBUM_MISSING | Fetched: $ALBUM_FETCHED | Failed: $ALBUM_FAILED"
info "Checked: $ALBUMS_CHECKED | Complete: $ALBUMS_COMPLETE | Needed art: $ALBUM_MISSING | Cached-skip: $ALBUMS_CACHED | Fetched: $ALBUM_FETCHED | No art upstream: $ALBUM_NOART | Fetch failed: $ALBUM_DLFAIL"
# ==============================================================================================
# ━━━ Artists ━━━
@@ -361,7 +520,9 @@ info "Checked: $ALBUMS_CHECKED | Complete: $ALBUMS_COMPLETE | Needed art: $ALBUM
echo ""
echo "━━━ $ICON_EMBY Artists ━━━"
artists=$(curl_json "$LIDARR_URL/api/v1/artist?apikey=$LIDARR_API_KEY")
# Cache-first — see the earlier album-map fetch above for why (arr_get_tracked_data serves
# the shared cache when fresh, live fetch as fallback, waits out an active rescan first).
artists=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")
if [[ -z "$artists" || "$artists" == "null" ]]; then
error "Lidarr artist API returned empty — aborting"
@@ -390,65 +551,96 @@ while IFS=$'\t' read -r local_path mbid name; do
continue
fi
if art_all_cached "$local_path" "$mbid" folder.jpg fanart.jpg clearlogo.png banner.jpg; then
(( ARTISTS_CACHED++ ))
log " every missing target is a known upstream miss — skipping"
continue
fi
wait_for_slot
(
_fetches=0 _fails=0
_fetches=0 _nourl=0 _dlfail=0 _miss_keys=""
JSON=$(curl_json "http://webservice.fanart.tv/v3/music/$mbid?api_key=$FANART_API_KEY")
sleep "$LIDARR_ART_SLEEP_BETWEEN"
# folder.jpg walks fanart → Deezer → Last.fm; only a no-URL result from all three is
# cacheable, same reasoning as the album cover chain.
if [[ ! -f "$local_path/folder.jpg" ]]; then
_saw_url=false
IMG=$(echo "$JSON" | jq -r '.artistthumb[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
download_if_valid "$IMG" "$local_path/folder.jpg"; _rc=$?
(( _rc == 2 )) && _saw_url=true
if (( _rc == 0 )); then
(( _fetches++ ))
else
IMG=$(deezer_artist_image "$name")
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
download_if_valid "$IMG" "$local_path/folder.jpg"; _rc=$?
(( _rc == 2 )) && _saw_url=true
if (( _rc == 0 )); then
(( _fetches++ ))
else
IMG=$(lastfm_artist_image "$name")
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
download_if_valid "$IMG" "$local_path/folder.jpg"; _rc=$?
(( _rc == 2 )) && _saw_url=true
if (( _rc == 0 )); then
(( _fetches++ ))
elif [[ "$_saw_url" == true ]]; then
(( _dlfail++ ))
else
(( _fails++ ))
(( _nourl++ )); _miss_keys+="${mbid}:folder.jpg"$'\t'"${NOW}"$'\n'
fi
fi
fi
fi
if [[ ! -f "$local_path/fanart.jpg" ]]; then
_saw_url=false
IMG=$(echo "$JSON" | jq -r '.artistbackground[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/fanart.jpg"; then
download_if_valid "$IMG" "$local_path/fanart.jpg"; _rc=$?
(( _rc == 2 )) && _saw_url=true
if (( _rc == 0 )); then
(( _fetches++ ))
else
IMG=$(deezer_artist_image "$name")
if download_if_valid "$IMG" "$local_path/fanart.jpg"; then (( _fetches++ )); else (( _fails++ )); fi
download_if_valid "$IMG" "$local_path/fanart.jpg"; _rc=$?
(( _rc == 2 )) && _saw_url=true
if (( _rc == 0 )); then
(( _fetches++ ))
elif [[ "$_saw_url" == true ]]; then
(( _dlfail++ ))
else
(( _nourl++ )); _miss_keys+="${mbid}:fanart.jpg"$'\t'"${NOW}"$'\n'
fi
fi
fi
if [[ ! -f "$local_path/clearlogo.png" ]]; then
IMG=$(echo "$JSON" | jq -r '.hdmusiclogo[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/clearlogo.png"; then (( _fetches++ )); else (( _fails++ )); fi
fi
try_single "$local_path/clearlogo.png" \
"$(echo "$JSON" | jq -r '.hdmusiclogo[0].url // empty' 2>/dev/null)" \
"${mbid}:clearlogo.png"
if [[ ! -f "$local_path/banner.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.musicbanner[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/banner.jpg"; then (( _fetches++ )); else (( _fails++ )); fi
fi
try_single "$local_path/banner.jpg" \
"$(echo "$JSON" | jq -r '.musicbanner[0].url // empty' 2>/dev/null)" \
"${mbid}:banner.jpg"
(( _fetches > 0 )) && printf '1\n' >> "$LIDARR_TMP/artist_fetches"
(( _fails > 0 )) && printf '1\n' >> "$LIDARR_TMP/artist_fails"
(( _nourl > 0 )) && printf '1\n' >> "$LIDARR_TMP/artist_nourl"
(( _dlfail > 0 )) && printf '1\n' >> "$LIDARR_TMP/artist_dlfail"
[[ -n "$_miss_keys" ]] && printf '%s' "$_miss_keys" >> "$LIDARR_TMP/artist_misses"
) &
done < <(echo "$artists" | jq -r '.[] | [.path, .foreignArtistId, .artistName] | @tsv')
wait
merge_art_misses "$LIDARR_TMP/artist_misses"
ARTIST_FETCHED=$(wc -l < "$LIDARR_TMP/artist_fetches" 2>/dev/null || echo 0)
ARTIST_FAILED=$(wc -l < "$LIDARR_TMP/artist_fails" 2>/dev/null || echo 0)
ARTIST_NOART=$(wc -l < "$LIDARR_TMP/artist_nourl" 2>/dev/null || echo 0)
ARTIST_DLFAIL=$(wc -l < "$LIDARR_TMP/artist_dlfail" 2>/dev/null || echo 0)
ARTIST_MISSING=$(( ARTISTS_CHECKED - ARTISTS_COMPLETE ))
info "Checked: $ARTISTS_CHECKED | Complete: $ARTISTS_COMPLETE | Needed art: $ARTIST_MISSING | Fetched: $ARTIST_FETCHED | Failed: $ARTIST_FAILED"
info "Checked: $ARTISTS_CHECKED | Complete: $ARTISTS_COMPLETE | Needed art: $ARTIST_MISSING | Cached-skip: $ARTISTS_CACHED | Fetched: $ARTIST_FETCHED | No art upstream: $ARTIST_NOART | Fetch failed: $ARTIST_DLFAIL"
END=$(date +%s)
@@ -459,8 +651,11 @@ echo ""
echo "━━━━━ $ICON_SUMMARY LIDARR MISSING ART SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $((END - START)))"
echo "$ICON_EMBY Albums: $ALBUMS_CHECKED checked | $ALBUMS_COMPLETE complete | $ALBUM_FETCHED fetched | $ALBUM_FAILED failed"
echo "$ICON_EMBY Artists: $ARTISTS_CHECKED checked | $ARTISTS_COMPLETE complete | $ARTIST_FETCHED fetched | $ARTIST_FAILED failed"
echo "$ICON_EMBY Albums: $ALBUMS_CHECKED checked | $ALBUMS_COMPLETE complete | $ALBUM_FETCHED fetched"
echo "$ICON_SKIP Albums: $ALBUMS_CACHED skipped (cached miss) | $ALBUM_NOART no art upstream | $ALBUM_DLFAIL fetch failed"
echo "$ICON_EMBY Artists: $ARTISTS_CHECKED checked | $ARTISTS_COMPLETE complete | $ARTIST_FETCHED fetched"
echo "$ICON_SKIP Artists: $ARTISTS_CACHED skipped (cached miss) | $ARTIST_NOART no art upstream | $ARTIST_DLFAIL fetch failed"
echo "$ICON_GEAR Cache: $([[ -f "$LIDARR_ART_MISS_CACHE" ]] && wc -l < "$LIDARR_ART_MISS_CACHE" || echo 0) known upstream misses | recheck every ${LIDARR_ART_RECHECK_DAYS}d"
if [[ "$DRY_RUN" == true ]]; then
echo "$ICON_WARN Status: DRY RUN — no files written"
@@ -34,6 +34,11 @@
# RefreshArtist is batched — one per artist, even if multiple albums were fixed.
# Lidarr handles the post-refresh rescan and import automatically.
#
# Cache-first artist list (2026-07-17) — comes from the shared tracked-data cache via
# arr_get_tracked_data(), fresh (kept warm every 30min by arr_cache_prefill.sh), live fetch
# as fallback. Everything below (per-album release lookups) still fetches live — that data
# isn't part of what's cached.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
@@ -283,17 +288,6 @@ read_file_mbid() {
esac
}
translate_container_path() {
local cpath="$1"
for cp in "${!ARR_PATH_MAP[@]}"; do
if [[ "$cpath" == "${cp}"* ]]; then
echo "${ARR_PATH_MAP[$cp]}${cpath#$cp}"
return
fi
done
echo "$cpath"
}
# ── Safety checks ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
@@ -313,7 +307,10 @@ echo "━━━ $ICON_SYNC Fetching artist paths ━━━"
declare -A ARTIST_PATH_CACHE
declare -A ARTIST_NAME_CACHE
ALL_ARTISTS=$(lidarr_api "artist") || {
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (now kept
# current every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to
# a live fetch when it's stale, and waits out an active rescan before either.
ALL_ARTISTS=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1") || {
error "Failed to fetch artists"
exit 1
}
@@ -322,7 +319,7 @@ while IFS= read -r artist; do
aid=$(echo "$artist" | jq -r '.id')
apath=$(echo "$artist" | jq -r '.path // empty')
aname=$(echo "$artist" | jq -r '.artistName // empty')
[[ -n "$apath" ]] && ARTIST_PATH_CACHE[$aid]=$(translate_container_path "$apath")
[[ -n "$apath" ]] && ARTIST_PATH_CACHE[$aid]=$(translate_path "$apath")
[[ -n "$aname" ]] && ARTIST_NAME_CACHE[$aid]="$aname"
done < <(echo "$ALL_ARTISTS" | jq -c '.[]' 2>/dev/null)
unset ALL_ARTISTS
@@ -16,7 +16,7 @@
# Goal: 05 meaningful Lidarr adds per week, not bulk imports.
#
# ==============================================================================================
# FLOW
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Fetch play completions from Emby activity log (last LOOKBACK_DAYS days)
@@ -33,6 +33,11 @@
# 8. Score candidates: affinity + breadth + popularity + quality
# 9. Take top MAX_ADDS above threshold → add to Lidarr
#
# Step 7's "already in Lidarr" check reads the shared tracked-data cache via
# arr_get_tracked_data() (cache-first, live fallback, 2026-07-17) instead of a live fetch —
# this runs weekly right after arr_full_rescan.sh, so it's reading the genuine post-rescan
# snapshot arr_full_rescan.sh just wrote.
#
# ==============================================================================================
# SCORING MODEL
# ==============================================================================================
@@ -53,11 +58,26 @@
# Max adds: LIDARR_DISCOVERY_MAX_ADDS (default 5) caps each stage
#
# ==============================================================================================
# REQUIREMENTS
# CONFIGURATION
# ==============================================================================================
#
# Last.fm API key — required for both stages
# Configure HOST*_LASTFM_API_KEY in host*.conf
# host*.conf (aliased by detect_hosts())
#
# HOST*_LASTFM_API_KEY Required for both stages — without it the run exits cleanly
# rather than adding anything unscored.
# LIDARR_URL / LIDARR_API_KEY Target arr
# EMBY_URL / EMBY_API_KEY Play history source for seed selection
#
# master.conf
#
# LIDARR_DISCOVERY_THRESHOLD Score required to accept a candidate (0-100)
# LIDARR_DISCOVERY_LOOKBACK_DAYS Emby play history window
# LIDARR_DISCOVERY_MIN_PLAYS Min plays in the window before an artist is evaluated
# LIDARR_DISCOVERY_USER_CAP_PCT Max % of the play score any one user can contribute,
# so a single heavy listener cannot drive the library
# LIDARR_DISCOVERY_MAX_ADDS Hard cap on artists added per run
# LIDARR_DISCOVERY_REJECT_COOLDOWN Days before a rejected artist is re-evaluated
# LIDARR_DISCOVERY_HISTORY Decision history DB — accepted and rejected
#
# ==============================================================================================
# DESIGN PRINCIPLES
@@ -516,9 +536,12 @@ fi
echo ""
echo "━━━ $ICON_SYNC Existing Libraries ━━━"
LIDARR_ARTISTS_JSON=$(_lidarr_get "artist") || { error "Could not fetch Lidarr artists"; exit 1; }
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (kept current
# every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to a live
# fetch when it's stale, and waits out an active rescan before either.
LIDARR_ARTISTS_JSON=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1") || { error "Could not fetch Lidarr artists"; exit 1; }
LIDARR_NAMES=$(echo "$LIDARR_ARTISTS_JSON" | jq -r '.[].artistName' 2>/dev/null)
LIDARR_COUNT=$(echo "$LIDARR_NAMES" | grep -c . 2>/dev/null || echo 0)
LIDARR_COUNT=$(echo "$LIDARR_NAMES" | grep -c . 2>/dev/null || true)
log "$LIDARR_COUNT artists in Lidarr"
EMBY_LIBRARY_JSON=$(emby_api "Items?IncludeItemTypes=MusicAlbum&Recursive=true&Fields=AlbumArtists&Limit=10000") || {
@@ -526,11 +549,28 @@ EMBY_LIBRARY_JSON=$(emby_api "Items?IncludeItemTypes=MusicAlbum&Recursive=true&F
EMBY_ARTIST_NAMES=""
}
EMBY_ARTIST_NAMES=$(echo "$EMBY_LIBRARY_JSON" | jq -r '.Items[] | .AlbumArtists[]?.Name' 2>/dev/null)
EMBY_ARTIST_COUNT=$(echo "$EMBY_ARTIST_NAMES" | grep -c . 2>/dev/null || echo 0)
EMBY_ARTIST_COUNT=$(echo "$EMBY_ARTIST_NAMES" | grep -c . 2>/dev/null || true)
log "$EMBY_ARTIST_COUNT album artists in Emby library"
_in_lidarr() { echo "$LIDARR_NAMES" | grep -iq "^${1}$"; }
_in_emby_library(){ [[ -n "$EMBY_ARTIST_NAMES" ]] && echo "$EMBY_ARTIST_NAMES" | grep -iq "^${1}$"; }
# MusicBrainz's canonical name for some artists (e.g. "blink182") uses a Unicode
# hyphen/dash rather than plain ASCII "-". Last.fm's candidate names are plain ASCII,
# so an exact-string match against Lidarr/Emby's names silently misses these artists
# every time — they never register as "already known" and get retried (and rejected
# as duplicates) on every future run. Normalize both sides before comparing.
_normalize_dashes() {
local n="$1"
n="${n///-}" # U+2010 HYPHEN
n="${n///-}" # U+2011 NON-BREAKING HYPHEN
n="${n///-}" # U+2012 FIGURE DASH
n="${n///-}" # U+2013 EN DASH
n="${n//—/-}" # U+2014 EM DASH
echo "$n"
}
LIDARR_NAMES=$(_normalize_dashes "$LIDARR_NAMES")
EMBY_ARTIST_NAMES=$(_normalize_dashes "$EMBY_ARTIST_NAMES")
_in_lidarr() { echo "$LIDARR_NAMES" | grep -iq "^$(_normalize_dashes "$1")$"; }
_in_emby_library(){ [[ -n "$EMBY_ARTIST_NAMES" ]] && echo "$EMBY_ARTIST_NAMES" | grep -iq "^$(_normalize_dashes "$1")$"; }
# ==============================================================================================
# ━━━ Score Stage 2 Candidates ━━━
@@ -16,7 +16,7 @@
# Goal: 05 meaningful Radarr adds per run, not bulk imports.
#
# ==============================================================================================
# FLOW
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Fetch recently watched movies from Emby (SEED_LIBRARIES, last LOOKBACK_DAYS days)
@@ -33,6 +33,11 @@
# 7. Score candidates: breadth + TMDB rating + vote count
# 8. Take top MAX_ADDS above threshold → add to Radarr
#
# Step 6's "already in Radarr" check reads the shared tracked-data cache via
# arr_get_tracked_data() (cache-first, live fallback, 2026-07-17) instead of a live fetch —
# this runs weekly right after arr_full_rescan.sh, so it's reading the genuine post-rescan
# snapshot arr_full_rescan.sh just wrote.
#
# ==============================================================================================
# SCORING MODEL
# ==============================================================================================
@@ -51,12 +56,28 @@
# Max adds: RADARR_DISCOVERY_MAX_ADDS (default 5)
#
# ==============================================================================================
# REQUIREMENTS
# CONFIGURATION
# ==============================================================================================
#
# TMDB API key — required for Stage 2 recommendations
# Configure HOST*_TMDB_API_KEY in host*.conf
# Free key at: https://www.themoviedb.org/settings/api
# host*.conf (aliased by detect_hosts())
#
# HOST*_TMDB_API_KEY Required for Stage 2 recommendations. Free key at
# https://www.themoviedb.org/settings/api — without it the run
# exits cleanly rather than adding anything unscored.
# RADARR_URL / RADARR_API_KEY Target arr
# EMBY_URL / EMBY_API_KEY Play history source for seed selection
#
# master.conf
#
# RADARR_DISCOVERY_THRESHOLD Score required to accept a candidate (0-100)
# RADARR_DISCOVERY_LOOKBACK_DAYS Emby watch history window
# RADARR_DISCOVERY_MAX_SEEDS Max seed movies taken from Stage 1
# RADARR_DISCOVERY_MAX_ADDS Hard cap on movies added per run
# RADARR_DISCOVERY_MIN_VOTE_COUNT Min TMDB votes for a candidate to be considered
# RADARR_DISCOVERY_MIN_RATING Min TMDB vote_average × 10
# RADARR_DISCOVERY_REJECT_COOLDOWN Days before a rejected movie is re-evaluated
# RADARR_DISCOVERY_SEED_LIBRARIES Emby libraries to draw seed movies from
# RADARR_DISCOVERY_HISTORY Decision history DB — accepted and rejected
#
# ==============================================================================================
# DESIGN PRINCIPLES
@@ -134,7 +155,6 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../Kernel/decision_engine.sh"
parse_args "$@"
@@ -185,7 +205,7 @@ log "$ICON_GEAR Config: threshold=${THRESHOLD} lookback=${LOOKBACK_DAYS}d max-se
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added to Radarr"
_fmt_rating() { local v="${1:-0}"; echo "${v::-1}.${v: -1}" 2>/dev/null || echo "$v"; }
# _fmt_rating() — provided by common.sh
# ==============================================================================================
# ━━━ Status ━━━
@@ -275,35 +295,7 @@ _freq_score() {
fi
}
# vote_avg_int = vote_average × 10 as integer (e.g. 7.8 → 78)
_rating_score_s2() {
local v="$1"
if (( v >= 80 )); then echo 40
elif (( v >= 75 )); then echo 32
elif (( v >= 70 )); then echo 25
elif (( v >= 65 )); then echo 18
elif (( v >= 60 )); then echo 12
else echo 5
fi
}
_votes_score() {
local c="$1"
if (( c >= 10000 )); then echo 20
elif (( c >= 5000 )); then echo 15
elif (( c >= 1000 )); then echo 10
elif (( c >= 200 )); then echo 5
else echo 2
fi
}
_breadth_score() {
local seeds="$1"
if (( seeds >= 3 )); then echo 40
elif (( seeds == 2 )); then echo 25
else echo 10
fi
}
# _rating_score_s2(), _votes_score(), _breadth_score() — provided by common.sh
# ==============================================================================================
# ━━━ Fetch Emby Libraries + Recently Watched Movies ━━━
@@ -514,7 +506,10 @@ fi
echo ""
echo "━━━ $ICON_SYNC Existing Libraries ━━━"
RADARR_MOVIES_JSON=$(_radarr_get "movie") || { error "Could not fetch Radarr library"; exit 1; }
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (kept current
# every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to a live
# fetch when it's stale, and waits out an active rescan before either.
RADARR_MOVIES_JSON=$(arr_get_tracked_data "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3") || { error "Could not fetch Radarr library"; exit 1; }
declare -A RADARR_TMDB # tmdb_id → 1
while read -r tmdb_id; do
@@ -17,7 +17,7 @@
# Goal: 03 meaningful Sonarr adds per run, not bulk imports.
#
# ==============================================================================================
# FLOW
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Fetch all Series from Emby SONARR_EMBY_LIBRARIES — build TMDB+TVDB index
@@ -37,6 +37,11 @@
# 11. Take top MAX_ADDS above threshold
# 12. Get TVDB ID via TMDB external_ids → Sonarr lookup → add + trigger SeriesSearch
#
# Step 9's "already in Sonarr" check reads the shared tracked-data cache via
# arr_get_tracked_data() (cache-first, live fallback, 2026-07-17) instead of a live fetch —
# this runs weekly right after arr_full_rescan.sh, so it's reading the genuine post-rescan
# snapshot arr_full_rescan.sh just wrote.
#
# ==============================================================================================
# SCORING MODEL
# ==============================================================================================
@@ -58,12 +63,29 @@
# Max adds: SONARR_DISCOVERY_MAX_ADDS (default 3) — TV is a larger commitment than movies
#
# ==============================================================================================
# REQUIREMENTS
# CONFIGURATION
# ==============================================================================================
#
# TMDB API key — required for Stage 2 recommendations and external_ids lookup
# Configure HOST*_TMDB_API_KEY in host*.conf
# Free key at: https://www.themoviedb.org/settings/api
# host*.conf (aliased by detect_hosts())
#
# HOST*_TMDB_API_KEY Required for Stage 2 recommendations and external_ids lookup.
# Free key at https://www.themoviedb.org/settings/api — without it
# the run exits cleanly rather than adding anything unscored.
# SONARR_URL / SONARR_API_KEY Target arr
# EMBY_URL / EMBY_API_KEY Play history source for seed selection
#
# master.conf
#
# SONARR_DISCOVERY_THRESHOLD Score required to accept a candidate (0-100)
# SONARR_DISCOVERY_LOOKBACK_DAYS Emby episode play history window
# SONARR_DISCOVERY_MAX_SEEDS Max seed series taken from Stage 1
# SONARR_DISCOVERY_MAX_ADDS Hard cap on shows added per run
# SONARR_DISCOVERY_MIN_VOTE_COUNT Min TMDB votes for a candidate to be considered
# SONARR_DISCOVERY_MIN_RATING Min TMDB vote_average × 10
# SONARR_DISCOVERY_REJECT_COOLDOWN Days before a rejected show is re-evaluated
# SONARR_DISCOVERY_USER_EPISODE_CAP Max episodes one user contributes to seed volume
# SONARR_DISCOVERY_MONITOR_MODE Sonarr monitor mode on add
# SONARR_DISCOVERY_HISTORY Decision history DB — accepted and rejected
#
# ==============================================================================================
# DESIGN PRINCIPLES
@@ -148,7 +170,6 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../Kernel/decision_engine.sh"
parse_args "$@"
@@ -205,7 +226,7 @@ log "$ICON_GEAR Config: threshold=${THRESHOLD} lookback=${LOOKBACK_DAYS}d max-se
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no shows will be added to Sonarr"
_fmt_rating() { local v="${1:-0}"; echo "${v::-1}.${v: -1}" 2>/dev/null || echo "$v"; }
# _fmt_rating() — provided by common.sh
# ==============================================================================================
# ━━━ Status ━━━
@@ -315,37 +336,7 @@ _volume_score() {
fi
}
# Stage 2: TMDB vote_average × 10 (0-40)
_rating_score_s2() {
local v="$1"
if (( v >= 80 )); then echo 40
elif (( v >= 75 )); then echo 32
elif (( v >= 70 )); then echo 25
elif (( v >= 65 )); then echo 18
elif (( v >= 60 )); then echo 12
else echo 5
fi
}
# Stage 2: vote count (0-20)
_votes_score() {
local c="$1"
if (( c >= 10000 )); then echo 20
elif (( c >= 5000 )); then echo 15
elif (( c >= 1000 )); then echo 10
elif (( c >= 200 )); then echo 5
else echo 2
fi
}
# Stage 2: seed breadth (0-40)
_breadth_score() {
local seeds="$1"
if (( seeds >= 3 )); then echo 40
elif (( seeds == 2 )); then echo 25
else echo 10
fi
}
# _rating_score_s2(), _votes_score(), _breadth_score() — provided by common.sh
# ==============================================================================================
# ━━━ Fetch Emby Series Library ━━━
@@ -622,7 +613,10 @@ fi
echo ""
echo "━━━ $ICON_SYNC Existing Libraries ━━━"
SONARR_SERIES_JSON=$(_sonarr_get "series") || { error "Could not fetch Sonarr library"; exit 1; }
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (kept current
# every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to a live
# fetch when it's stale, and waits out an active rescan before either.
SONARR_SERIES_JSON=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || { error "Could not fetch Sonarr library"; exit 1; }
declare -A SONARR_TVDB # tvdb_id → 1
declare -A SONARR_TMDB # tmdb_id → 1 (Sonarr v4 exposes tmdbId)
@@ -636,9 +630,6 @@ done < <(echo "$SONARR_SERIES_JSON" | jq -r '.[] |
log "${#SONARR_TVDB[@]} series in Sonarr | ${#EMBY_TMDB_IDS[@]} series in Emby"
_in_sonarr() { [[ "${SONARR_TVDB["$1"]+x}" || "${SONARR_TMDB["$2"]+x}" ]]; }
_in_emby() { [[ "${EMBY_TMDB_IDS["$1"]+x}" || "${EMBY_TVDB_IDS["$2"]+x}" ]]; }
# ==============================================================================================
# ━━━ Score Stage 2 Candidates ━━━
# ==============================================================================================
+648
View File
@@ -0,0 +1,648 @@
#!/bin/bash
# ==============================================================================================
# ============================ Radarr Content Classification Scan ==============================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Overseerr lets any user request a movie into the wrong root folder (kids content added to
# the general Movies share, anime added to Kids_Movies, etc.) and most users never notice or
# correct it. This script reads Radarr's tracked movie list and classifies every movie as
# anime / kids-only / regular using metadata signals alone (genre, certification, studio,
# original language) — then reports where a movie's computed classification disagrees with
# the root folder it's actually sitting in, in both directions:
#
# FORWARD — a movie classified as anime/kids is sitting outside its dedicated root
# REVERSE — a movie sitting inside the kids/anime root doesn't match that classification
#
# Report-only by default — no files are moved and no Radarr API writes happen unless a
# mode flag is given. Pass --move to relocate forward misplacements, or --remove-junk to
# delete and import-exclude bad-metadata entries (see OPERATIONAL MODEL below); without
# those flags this is purely a detection tool. Every rule below was validated against
# this library's real data before being
# adopted (see master.conf comments above the curated lists) — this is not a generic
# genre-matcher, it's tuned specifically against the false-positive traps that showed up
# when testing looser rules (documented per-rule below).
#
# ==============================================================================================
# CLASSIFICATION RULES
# ==============================================================================================
#
# is_anime:
# (genre Animation AND originalLanguage Japanese) OR studio in RADARR_ANIME_STUDIOS
# Always wins over kids when both could apply — explicit priority, not a tiebreak.
#
# is_kids ("kids will end up watching this alone" — NOT "family movie night"):
# not is_anime AND certification not in (R, NC-17) AND (
# (genre Animation AND certification != PG-13)
# OR studio in RADARR_KIDS_STUDIOS
# )
# Deliberately excludes bare "Family" genre and bare "G" certification — both genuinely
# traced back to live-action films the whole household watches together (Mrs. Doubtfire,
# Doctor Dolittle, National Treasure-style adventures, classic Westerns), not kids-only
# content. Family movie night stays in the general Movies root by design.
#
# The PG-13 exclusion on the Animation branch is load-bearing — without it this rule
# catches South Park movies, Sausage Party, "9", Resident Evil: Death Island, and (via
# the curated studio list) Warner Bros. Animation's R-rated Watchmen films, since that
# studio makes both kids content and adult content under the same name.
#
# is_junk (bad/thin TMDb match, not a real classification problem):
# hasFile == false AND imdbId == null AND tmdb votes < RADARR_JUNK_MIN_VOTES
# Caught live: two fake "X-Men"/"Wolverine" entries, two fake "Silent Hill" entries, one
# fake "The Purge" spinoff — all monitored placeholders with nothing behind them. The
# fix for these is removal from Radarr, not blocklist+redownload — there's no release to
# blocklist and likely nothing legitimate to redownload under that exact TMDb match.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Report pass (always):
# check_api → check_arr_version → arr_get_tracked_data (cache-first, one call)
# → classify every movie → report FORWARD, REVERSE and JUNK findings
#
# Remove-junk pass (--remove-junk, runs first when combined with --move):
# One entry at a time, halting on the first failure.
# DELETE with deleteFiles=false and addImportExclusion=true — the Radarr entry is
# removed and blocked from re-adding, files on disk are never touched. is_junk
# requires hasFile == false, so there is no file behind these entries anyway.
# Verified by re-fetching and requiring a 404 before counting as removed.
#
# Move pass (--move):
# One movie at a time, verified after each.
# hasFile == true → moveFiles=true, then poll the async MoveMovie command to
# "completed" (bounded by RADARR_MOVE_POLL_TIMEOUT) before the
# DB-field check — the DB flips instantly while the physical
# move is still queued.
# hasFile == false → correct rootFolderPath/path and trigger MoviesSearch instead;
# there is nothing to move.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Report by Default, Act Only on Request
# A bare run never calls Radarr's write API and never touches a file — every finding is
# just a candidate. Acting on them requires an explicit --move or --remove-junk flag, so
# the scan can be scheduled and re-run freely while the curated lists are being tuned
# without any risk of it rearranging the library on its own.
#
# Curated Lists, Not Bare Genre/Cert Matching
# Every signal used here failed at least once as a bare/standalone check during rule
# development (Family genre, G certification, blanket Animation genre, bare Anime genre
# tag, Disney+/general-platform networks) — see master.conf comments for what each
# curated list deliberately excludes and why.
#
# Cache-First, Never a Per-Movie Call
# Uses arr_get_tracked_data() same as radarr_cleanup.sh — Radarr's movie list already
# embeds everything this script needs per movie, so this is a single API call (or zero,
# if the shared cache is warm) regardless of library size.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Required by the container interaction and state writes.
#
# Lock Acquisition
# acquire_lock, plus acquire_lock "wait" around the write passes with an EXIT trap
# releasing all locks, so an interrupted run never leaves a lock behind.
#
# Host Detection
# detect_hosts() aliases RADARR_URL / RADARR_API_KEY / the root literals.
#
# curl + jq Dependency Check
# Fails fast if either is missing — every classification signal is parsed with jq.
#
# Report-Only Default
# No write happens without --move or --remove-junk.
#
# Required Var Check
# require_var on RADARR_URL and RADARR_API_KEY before any request.
#
# API Reachability + Version Gate
# check_api then check_arr_version against RADARR_VERSION_MAJOR. A major version bump
# can move or rename the fields every rule depends on, so a mismatch aborts rather
# than classifying against an unknown schema.
#
# Empty Library Abort
# A response of 0 movies aborts — an empty list is indistinguishable from a clean
# library and would otherwise report success during an API fault.
#
# Unconfigured Root Skip
# A blank kids/anime root skips that category's checks rather than comparing paths
# against an empty string.
#
# Files Never Deleted
# Junk removal passes deleteFiles=false. Only the Radarr entry is removed, and
# addImportExclusion=true stops it being re-added. is_junk additionally requires
# hasFile == false, so these entries have nothing on disk in the first place.
#
# One At A Time, Stop On First Failure
# Both write passes process one entry at a time and halt on the first failure rather
# than continuing through the library.
#
# Post-Write Verification
# Removal is confirmed by re-fetching and requiring a 404. Moves are confirmed by
# re-fetching and checking rootFolderPath and hasFile. The API response alone is
# never treated as proof.
#
# Async Move Completion Polling
# moveFiles=true flips the DB instantly while the physical move is a separate async
# MoveMovie command. Each move polls its own command to "completed" (bounded by
# RADARR_MOVE_POLL_TIMEOUT) before the DB-field check, so a batch cannot report
# everything moved while files are still queued at the old path.
#
# Junk Vote Threshold
# RADARR_JUNK_MIN_VOTES gates junk detection alongside hasFile == false and a null
# imdbId. All three must hold — a thin-metadata entry that actually has a file, or
# has an IMDb ID, is never treated as junk.
#
# Post-Write Cache Refresh
# The tracked-data cache is refreshed after writes so no other arr script reads a
# stale rootFolderPath or a movie that no longer exists.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
# RADARR_URL / RADARR_API_KEY / RADARR_MOVIES_ROOT — existing, aliased by detect_hosts()
# RADARR_KIDS_ROOT / RADARR_ANIME_ROOT — rootFolderPath literals as reported by the API
# (e.g. "/kids movies", "/ext-anime-movies") — leave blank on a host with no dedicated
# root for that category; the corresponding checks are skipped, not treated as an error.
#
# master.conf
# RADARR_ANIME_STUDIOS / RADARR_KIDS_STUDIOS — curated studio allowlists
# RADARR_JUNK_MIN_VOTES — TMDb vote threshold for the bad-metadata check
# RADARR_VERSION_MAJOR — expected API major version (reused from radarr_cleanup.sh)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# radarr_classification_scan.sh — normal run, prints report
# radarr_classification_scan.sh --log — verbose (per-movie TRACKED-style logging)
# radarr_classification_scan.sh --status — show config and exit
# radarr_classification_scan.sh --remove-junk — delete + import-exclude bad-metadata entries
# radarr_classification_scan.sh --move — relocate forward misplacements (moves the
# file if one exists; for hasFile=false
# entries, just corrects rootFolderPath/path
# and triggers an immediate MoviesSearch)
#
# --remove-junk and --move can be combined in one run — junk is cleared first, then the
# move pass runs against the remaining (now junk-free) classification.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# --move / --remove-junk are script-local flags, not ones parse_args recognizes — check the
# raw args before they get filtered into PARSED_ARGS.
MOVE_MODE=false
REMOVE_JUNK_MODE=false
for _arg in "$@"; do
[[ "$_arg" == "--move" ]] && MOVE_MODE=true
[[ "$_arg" == "--remove-junk" ]] && REMOVE_JUNK_MODE=true
done
unset _arg
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
exit 1
fi
detect_hosts
if [[ -z "${RADARR_URL:-}" ]] || [[ -z "${RADARR_API_KEY:-}" ]]; then
info "Radarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
exit 0
fi
require_var RADARR_URL
require_var RADARR_API_KEY
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Radarr URL: $RADARR_URL"
echo "$ICON_GEAR Movies root: $RADARR_MOVIES_ROOT"
echo "$ICON_GEAR General root: ${RADARR_GENERAL_ROOT:-<not configured>}"
echo "$ICON_GEAR Kids root: ${RADARR_KIDS_ROOT:-<not configured>}"
echo "$ICON_GEAR Anime root: ${RADARR_ANIME_ROOT:-<not configured>}"
echo "$ICON_GEAR Anime studios: ${#RADARR_ANIME_STUDIOS[@]} curated"
echo "$ICON_GEAR Kids studios: ${#RADARR_KIDS_STUDIOS[@]} curated"
echo "$ICON_GEAR Junk min votes: ${RADARR_JUNK_MIN_VOTES:-15}"
echo "$ICON_GEAR Move mode: $MOVE_MODE"
echo "$ICON_GEAR Remove-junk: $REMOVE_JUNK_MODE"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ $ICON_SYNC Fetching Radarr Library ━━━"
if ! check_api "$RADARR_URL" "Radarr" 10; then
exit 1
fi
check_arr_version "$RADARR_URL" "$RADARR_API_KEY" "v3" "$RADARR_VERSION_MAJOR" "Radarr" || exit 1
MOVIES_RESPONSE=$(arr_get_tracked_data "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3") || {
error "Failed to fetch movies from Radarr"
exit 1
}
MOVIE_COUNT=$(echo "$MOVIES_RESPONSE" | jq -r 'length' 2>/dev/null)
if [[ -z "$MOVIE_COUNT" ]] || [[ "$MOVIE_COUNT" -eq 0 ]]; then
error "API returned 0 movies — aborting"
exit 1
fi
info "$MOVIE_COUNT movies loaded"
# ==============================================================================================
# ━━━ Classify ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Classifying ━━━"
ANIME_STUDIOS_JSON=$(printf '%s\n' "${RADARR_ANIME_STUDIOS[@]}" | jq -R . | jq -s .)
KIDS_STUDIOS_JSON=$(printf '%s\n' "${RADARR_KIDS_STUDIOS[@]}" | jq -R . | jq -s .)
JUNK_MIN_VOTES="${RADARR_JUNK_MIN_VOTES:-15}"
RESULTS=$(echo "$MOVIES_RESPONSE" | jq \
--argjson animeStudios "$ANIME_STUDIOS_JSON" \
--argjson kidsStudios "$KIDS_STUDIOS_JSON" \
--arg animeRoot "${RADARR_ANIME_ROOT:-}" \
--arg kidsRoot "${RADARR_KIDS_ROOT:-}" \
--argjson junkMinVotes "$JUNK_MIN_VOTES" '
def is_anime:
(any(.genres[]?; . == "Animation") and .originalLanguage.name == "Japanese")
or (.studio as $s | $animeStudios | index($s) != null);
def not_adult: (.certification != "R") and (.certification != "NC-17");
def is_kids:
(is_anime | not) and not_adult and (
(any(.genres[]?; . == "Animation") and .certification != "PG-13")
or (.studio as $s | $kidsStudios | index($s) != null)
);
def is_junk:
(.hasFile == false) and (.imdbId == null)
and ((.ratings.tmdb.votes // 999999) < $junkMinVotes);
map(
{
title, id, studio, certification, rootFolderPath, genres, hasFile,
is_anime: is_anime,
is_kids: is_kids,
is_junk: is_junk
} |
. + {
forward_anime_miss: (.is_anime and $animeRoot != "" and .rootFolderPath != $animeRoot),
forward_kids_miss: (.is_kids and $kidsRoot != "" and .rootFolderPath != $kidsRoot),
reverse_anime_leak: ((.is_anime | not) and $animeRoot != "" and .rootFolderPath == $animeRoot and (.is_junk | not)),
reverse_kids_leak: ((.is_anime | not) and (.is_kids | not) and $kidsRoot != "" and .rootFolderPath == $kidsRoot
and (.is_junk | not)
and (.certification == "R" or .certification == "NC-17"
or (.certification == "PG-13"
and (any(.genres[]?; . == "Family") | not)
and (any(.genres[]?; . == "Animation") | not))))
}
)
')
FORWARD_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_anime_miss)] | length')
FORWARD_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_kids_miss)] | length')
REVERSE_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_anime_leak)] | length')
REVERSE_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_kids_leak)] | length')
JUNK_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.is_junk)] | length')
if [[ "$ENABLE_LOGGING" == true ]]; then
echo "$RESULTS" | jq -r '.[] | select(.forward_anime_miss or .forward_kids_miss or .reverse_anime_leak or .reverse_kids_leak or .is_junk) |
" [\(if .is_junk then "JUNK" elif .forward_anime_miss then "FORWARD-ANIME" elif .forward_kids_miss then "FORWARD-KIDS" elif .reverse_anime_leak then "REVERSE-ANIME" elif .reverse_kids_leak then "REVERSE-KIDS" else "?" end)] \(.title) (root: \(.rootFolderPath), studio: \(.studio // "n/a"), cert: \(.certification // "n/a"))"'
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR CLASSIFICATION SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Movies scanned: $MOVIE_COUNT"
echo "$ICON_TRASH Forward — anime miss: $FORWARD_ANIME_COUNT (classified anime, outside ${RADARR_ANIME_ROOT:-<unconfigured>})"
echo "$ICON_TRASH Forward — kids miss: $FORWARD_KIDS_COUNT (classified kids, outside ${RADARR_KIDS_ROOT:-<unconfigured>})"
echo "$ICON_WARN Reverse — anime leak: $REVERSE_ANIME_COUNT (in ${RADARR_ANIME_ROOT:-<unconfigured>}, no anime signal — review, may be deliberate style placement)"
echo "$ICON_WARN Reverse — kids leak: $REVERSE_KIDS_COUNT (in ${RADARR_KIDS_ROOT:-<unconfigured>}, adult-rated content)"
echo "$ICON_PROTECTED Bad metadata (junk): $JUNK_COUNT (hasFile=false, no imdbId, thin TMDb match — candidates for removal, not redownload)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ENABLE_LOGGING" != true ]] && echo " (run with --log for the per-title list)"
# ==============================================================================================
# ━━━ Remove-Junk Mode ━━━
# ==============================================================================================
# Junk entries are bad/thin TMDb matches with hasFile=false — there's no release to blocklist
# (nothing was ever grabbed) and no file to delete, only a bad monitored record. The fix is
# removing the record and adding it to Radarr's import exclusion list (same mechanism
# radarr_tmdb_removed.sh already uses via RADARR_DROPPED_ADD_EXCLUSION) so the same bad TMDb
# match can't get re-added by a future Overseerr request or list sync.
if [[ "$REMOVE_JUNK_MODE" == true ]]; then
echo ""
echo "━━━ $ICON_TRASH Remove-Junk Mode ━━━"
acquire_lock "wait"
trap "_release_all_locks" EXIT
JUNK_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select(.is_junk)]')
JUNK_TARGET_COUNT=$(echo "$JUNK_TARGETS" | jq 'length')
if [[ "$JUNK_TARGET_COUNT" -eq 0 ]]; then
info "No junk entries to remove"
else
warn "About to remove $JUNK_TARGET_COUNT junk entries — one at a time, verifying after each"
JUNK_REMOVED=0
JUNK_FAILED=0
while IFS= read -r item; do
id=$(echo "$item" | jq -r '.id')
title=$(echo "$item" | jq -r '.title')
http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
--max-time 15 \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/movie/${id}?deleteFiles=false&addImportExclusion=true" 2>/dev/null)
if [[ "$http_code" != "200" && "$http_code" != "202" ]]; then
error "$title — API returned HTTP $http_code — stopping (review before re-running)"
(( JUNK_FAILED++ ))
break
fi
sleep 1
# Verify — the movie should now be gone entirely (404).
verify_code=$(curl -sf -o /dev/null -w "%{http_code}" \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/movie/${id}" 2>/dev/null)
if [[ "$verify_code" == "404" ]]; then
echo " $ICON_SUCCESS $title — removed and excluded"
(( JUNK_REMOVED++ ))
else
error "$title — verification failed (still returns HTTP $verify_code) — stopping"
(( JUNK_FAILED++ ))
break
fi
done < <(echo "$JUNK_TARGETS" | jq -c '.[]')
if [[ "$JUNK_REMOVED" -gt 0 ]]; then
info "Refreshing shared tracked-data cache..."
fresh_movies=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr")
[[ -n "$fresh_movies" ]] && arr_cache_write "radarr" "$fresh_movies"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY REMOVE-JUNK SUMMARY ━━━━━"
echo "$ICON_SUCCESS Removed: $JUNK_REMOVED"
echo "$ICON_ERROR Failed: $JUNK_FAILED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
_release_all_locks
trap - EXIT
fi
# ==============================================================================================
# ━━━ Move Mode ━━━
# ==============================================================================================
# Acts on FORWARD misplacements (clear-cut: classified anime/kids, sitting in the wrong root)
# and on REVERSE-KIDS leaks (adult certification with zero Family/Animation genre sitting in
# the kids root — also clear-cut, moved back to RADARR_GENERAL_ROOT). Does NOT act on
# REVERSE-ANIME leaks — those are genuine judgment calls, since deliberate style placements
# like Castlevania/Legend of Korra legitimately live in the anime root without matching the
# anime signal — or on JUNK (those need removal from Radarr, not a file move).
#
# One movie at a time, verified after each. moveFiles=true flips the DB (rootFolderPath/
# hasFile) instantly, but the physical move is a separate async MoveMovie command Radarr's
# own MoveMovieService drains one at a time internally — same architecture that raced on the
# Sonarr side (episodeFileCount reported at the new path via API while the real files were
# still sitting at the old one, MoveSeries command queued behind ~20 others). Never confirmed
# live on the Radarr side, but the same DB-write-is-instant/move-is-async split applies, so
# each move here polls its own MoveMovie command to "completed" before the DB-field check
# runs, mirroring the Sonarr fix.
if [[ "$MOVE_MODE" == true ]]; then
echo ""
echo "━━━ $ICON_SYNC Move Mode — Forward Misplacements ━━━"
acquire_lock "wait"
trap "_release_all_locks" EXIT
build_arr_path_map "RADARR"
# hasFile==false entries (monitored but never downloaded) have nothing to physically
# move — confirmed live: "Biohazard 4: Incubate" had hasFile=false even in the cache from
# before any of this ran, not something this script broke. There's still real value in
# fixing them, though: correct the DB pointer now (so Radarr saves to the right root
# whenever it does find a release) and kick off an immediate search rather than waiting
# for the next scheduled one. Junk entries are still excluded entirely — nothing to
# search for there, they need removal instead.
#
# reverse_kids_leak is included here (unlike reverse_anime_leak) because its signal is
# specifically "adult certification with zero Family/Animation genre" — there's no
# legitimate stylistic reason for that combination to sit in a curated kids library, unlike
# the anime side where deliberate style placements (Castlevania, Legend of Korra) are
# common and valid. Confirmed live: the 4 titles this caught (The Addams Family, Saving
# Mr. Banks, Dark Shadows, The DUFF) are all genuinely non-kids content, not judgment calls.
MOVE_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select((.forward_anime_miss or .forward_kids_miss or .reverse_kids_leak) and (.is_junk | not))]')
MOVE_COUNT=$(echo "$MOVE_TARGETS" | jq 'length')
if [[ "$MOVE_COUNT" -eq 0 ]]; then
info "Nothing to move"
exit 0
fi
warn "About to process $MOVE_COUNT movies — one at a time, verifying after each"
MOVED=0
RELOCATED_SEARCH=0
FAILED=0
while IFS= read -r item; do
id=$(echo "$item" | jq -r '.id')
title=$(echo "$item" | jq -r '.title')
is_anime_flag=$(echo "$item" | jq -r '.is_anime')
is_forward_kids=$(echo "$item" | jq -r '.forward_kids_miss')
had_file=$(echo "$item" | jq -r '.hasFile')
if [[ "$is_anime_flag" == "true" ]]; then
target_root="$RADARR_ANIME_ROOT"
elif [[ "$is_forward_kids" == "true" ]]; then
target_root="$RADARR_KIDS_ROOT"
else
target_root="$RADARR_GENERAL_ROOT"
fi
if [[ -z "$target_root" ]]; then
error "$title — target root not configured (RADARR_GENERAL_ROOT blank), skipping"
(( FAILED++ ))
continue
fi
# RESULTS only carries the reduced report fields — Radarr's PUT expects the complete
# resource representation, so fetch a fresh full movie record to modify and send back.
full_movie=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie/$id" "Radarr")
if [[ -z "$full_movie" ]]; then
error "$title — could not fetch full movie record, skipping"
(( FAILED++ ))
continue
fi
old_path=$(echo "$full_movie" | jq -r '.path')
folder_name="${old_path##*/}"
# A literal "/" in the folder name would build a broken nested directory instead of
# moving to one clean folder — bit us once already doing this by hand for Sonarr.
if [[ "$folder_name" == *"/"* ]]; then
error "$title — folder name contains '/', skipping (needs manual handling)"
(( FAILED++ ))
continue
fi
new_path="${target_root}/${folder_name}"
if [[ "$had_file" == "true" ]]; then
info "$title: $old_path$new_path (moving file)"
move_qs="?moveFiles=true"
else
info "$title: $old_path$new_path (no file — relocating + search)"
move_qs=""
fi
updated_movie=$(echo "$full_movie" | jq --arg root "$target_root" --arg path "$new_path" \
'.rootFolderPath = $root | .path = $path')
http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X PUT \
--max-time 30 \
-H "X-Api-Key: $RADARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$updated_movie" \
"${RADARR_URL}/api/v3/movie/${id}${move_qs}" 2>/dev/null)
if [[ "$http_code" != "200" && "$http_code" != "202" ]]; then
error "$title — API returned HTTP $http_code — stopping (review before re-running)"
(( FAILED++ ))
break
fi
# moveFiles=true flips rootFolderPath/hasFile in the DB instantly, but the actual
# physical move is a separate async MoveMovie command that Radarr's MoveMovieService
# drains one at a time internally — mirrors the confirmed Sonarr race (see header
# comment above Move Mode). Poll the actual command to completion before trusting the
# DB-field check below.
if [[ -n "$move_qs" ]]; then
move_cmd_id=""
for _ in 1 2 3 4 5; do
move_cmd_id=$(curl -sf --max-time 10 -H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/command" 2>/dev/null | \
jq -r --argjson mid "$id" \
'[.[] | select(.name == "MoveMovie" and .body.movieId == $mid)] | sort_by(.id) | last | .id // empty' \
2>/dev/null)
[[ -n "$move_cmd_id" ]] && break
sleep 1
done
if [[ -z "$move_cmd_id" ]]; then
error "$title — could not locate the MoveMovie command — stopping (review before re-running)"
(( FAILED++ ))
break
fi
info "$title: MoveMovie command $move_cmd_id queued, waiting for completion..."
move_status="" move_polled=0
while [[ "$move_polled" -lt "$RADARR_MOVE_POLL_TIMEOUT" ]]; do
move_status=$(curl -sf --max-time 10 -H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/command/${move_cmd_id}" 2>/dev/null | \
jq -r '.status // empty' 2>/dev/null)
[[ "$move_status" == "completed" || "$move_status" == "failed" ]] && break
sleep 10
(( move_polled += 10 ))
[[ $(( move_polled % 60 )) -eq 0 ]] && log " still moving $title... (${move_polled}s elapsed)"
done
if [[ "$move_status" != "completed" ]]; then
error "$title — MoveMovie command $move_cmd_id ended as '${move_status:-timed out after ${RADARR_MOVE_POLL_TIMEOUT}s}' — stopping"
(( FAILED++ ))
break
fi
fi
sleep 3
# Never trust the PUT response alone — re-fetch and confirm the change actually landed.
verify_movie=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie/$id" "Radarr")
verify_root=$(echo "$verify_movie" | jq -r '.rootFolderPath')
verify_hasfile=$(echo "$verify_movie" | jq -r '.hasFile')
if [[ "$verify_root" != "$target_root" ]]; then
error "$title — verification failed (root: $verify_root) — stopping"
(( FAILED++ ))
break
fi
if [[ "$had_file" == "true" ]]; then
if [[ "$verify_hasfile" == "true" ]]; then
echo " $ICON_SUCCESS $title — moved and verified"
(( MOVED++ ))
else
error "$title — verification failed (root updated but hasFile now false) — stopping"
(( FAILED++ ))
break
fi
else
search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
--max-time 30 \
-H "X-Api-Key: $RADARR_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\":\"MoviesSearch\",\"movieIds\":[${id}]}" \
"${RADARR_URL}/api/v3/command" 2>/dev/null)
if [[ "$search_code" == "200" || "$search_code" == "201" ]]; then
echo " $ICON_SUCCESS $title — relocated, search triggered"
else
warn " $title — relocated but search trigger returned HTTP $search_code (will pick up on next scheduled search)"
fi
(( RELOCATED_SEARCH++ ))
fi
done < <(echo "$MOVE_TARGETS" | jq -c '.[]')
# arr_get_tracked_data() is cache-first — every write above changed rootFolderPath, so the
# shared cache is now stale until the next scheduled arr_cache_prefill run (up to 30min).
# Every other script reading this cache (cleanup, discovery, etc.) would see wrong data
# until then — refresh it now with one more live fetch rather than leave that window open.
if [[ "$(( MOVED + RELOCATED_SEARCH ))" -gt 0 ]]; then
info "Refreshing shared tracked-data cache..."
fresh_movies=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr")
[[ -n "$fresh_movies" ]] && arr_cache_write "radarr" "$fresh_movies"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY MOVE SUMMARY ━━━━━"
echo "$ICON_SUCCESS Moved (file relocated): $MOVED"
echo "$ICON_SUCCESS Relocated + search triggered: $RELOCATED_SEARCH"
echo "$ICON_ERROR Failed: $FAILED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
exit 0
+696
View File
@@ -0,0 +1,696 @@
#!/bin/bash
# ==============================================================================================
# ================================= Radarr Cleanup =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Delete orphaned movie files not tracked by Radarr. Queries the API for all
# tracked movie file paths, walks the library on disk, and removes anything
# untracked that is old enough to be past the import window. Triggers an Emby
# library clean after each deletion run so ghost entries disappear immediately.
#
# The tracked-count floor check (Safety Layer 6) is rescan-aware: if Radarr's own
# RescanMovie/DownloadedMoviesScan is active (independently of this script's own
# lighter ProcessMonitoredDownloads pre-flight), a genuinely low mid-scan count gets
# waited out (calibrated to that command's historical duration via
# arr_get_rescan_duration(), up to 3 strikes) and re-fetched rather than triggering a
# false-alarm abort. Mirrors the same fix built for lidarr_cleanup.sh 2026-07-16 after
# a whole-library rescan there made trackFileCount read 22% of normal mid-scan.
#
# Cache-first movie list (2026-07-17), batched moviefile fetch (2026-07-19). The movie list
# comes from the shared tracked-data cache via arr_get_tracked_data() — fresh (kept warm
# every 30min by arr_cache_prefill.sh), live fetch as fallback. Radarr's movie list embeds
# movieFile.path directly on every hasFile=true entry, but that's only the *primary* file —
# Radarr 6+ supports a second tracked file per movie (alternate editions/extras) that never
# shows up there, so relying on it alone misclassified a movie's second edition as an orphan
# (confirmed live 2026-07-19: The Crash, They Will Kill You, The Drama, Lee Cronin's The
# Mummy, and Ready or Not: Here I Come all had a legitimately-tracked second file deleted-flagged
# this way). /moviefile?movieId=X returns every file for a movie, including secondaries, and
# accepts movieId as a repeated query param for a bulk fetch — but the whole library in one
# request 414s (Request-URI Too Long, confirmed live), so _fetch_tracked_files() batches
# movieId params BATCH_SIZE at a time instead: ~14 requests for a ~2800-movie library rather
# than the up-to-2896 individual per-movie calls the 2026-07-17 optimization eliminated, and
# rather than the one-shot list read that missed secondary files. The filesystem is walked
# once per run, not twice — classification records which paths are eligible for deletion as
# it goes, and the delete pass (once the size-threshold check below passes) just acts on that
# list instead of re-walking and re-classifying the whole tree. That single walk also gets
# size+ctime straight from find -printf instead of a separate stat fork per file — find
# already has to stat() every entry to know it's -type f, so this is free by comparison.
# Measured ~130x faster per file (0.033ms vs 4.3ms).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Every file encountered on disk is classified into one of five categories:
#
# TRACKED — Radarr API knows this exact path → leave it alone
# PROTECTED — matches RADARR_PROTECTED_PATTERNS → never delete
# ORPHAN — video file, not tracked, older than RADARR_ORPHAN_AGE → delete
# JUNK — not a video extension, not protected → delete regardless of age
# RECENT — not tracked, under RADARR_ORPHAN_AGE → skip (may be mid-import)
#
# Radarr generates movie artwork (*.jpg), metadata (*.nfo), and manages subtitles
# (*.srt, *.sub, *.ass) but does NOT include these in its tracked file API response.
# Without PROTECTED classification these would be deleted — breaking Radarr and
# Emby metadata display.
#
# After deletions: notify_emby_scan() triggers Emby "Clean Missing Files" task.
# Emby removes ghost entries immediately — no user-facing file-not-found errors.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# API as Ground Truth
# What Radarr tracks is authoritative. Files not in the API response are
# orphans — Radarr has no record of them and they serve no purpose.
# The script never infers ownership from directory structure alone.
#
# Age Gate Before Deletion
# Files under RADARR_ORPHAN_AGE are left alone regardless of tracked status.
# Radarr's import pipeline writes files before registering them — acting
# immediately would delete files mid-import.
# Age is measured from ctime, not mtime — an import preserves the release's original
# mtime, so a file that landed today can read as years old and skip this gate. Depends
# on media_shares_permissions.sh touching only entries that are actually wrong.
#
# Emby Cleanup Is Part of the Job
# Deleting a file without telling Emby leaves ghost entries that show as
# broken items. Triggering the Emby clean is not optional — it completes
# the deletion from the user's perspective.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Seven gates — ALL must pass before any file is touched:
# 1. Container running and not starting/unhealthy
# 2. API reachable
# 3. API version matches RADARR_VERSION_MAJOR in master.conf
# 4. Movie count > 0
# 5. Tracked file count > 0
# 6. Tracked count >= RADARR_MIN_TRACKED_PCT % of last known count
# 7. Deletion size < RADARR_MAX_DELETE_GB — or --i-know-what-im-doing required
#
# acquire_lock "wait" — large scans take time, wait for previous run to finish
# jq + curl validation — exits if either tool missing
# ARR_DOCKER_TIMEOUT — container checks protected against daemon hangs (script-local, not common.sh's DOCKER_TIMEOUT)
# notify_emby_scan() — triggers Emby clean after deletion
# Silent by default — orphans/junk warn(), clean library logs silently
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# RADARR_TRACKED_COUNT_FILE — persistent baseline for the tracked % safety check (gate 6)
# Updated after each successful run. Protects against misconfigured root path
# returning an empty API response and deleting the entire library.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_RADARR_URL / HOST*_RADARR_API_KEY / HOST*_RADARR_MOVIES_ROOT
# HOST*_RADARR_PATH_MAP — container path → host path translation
# All aliased by detect_hosts() — script uses unprefixed names
#
# master.conf
#
# RADARR_ORPHAN_AGE — days before untracked file eligible for deletion
# RADARR_MAX_DELETE_GB — require --i-know-what-im-doing above this
# RADARR_MIN_TRACKED_PCT — abort if tracked count drops below this % of last run
# RADARR_TRACKED_COUNT_FILE — persistent baseline file path
# RADARR_EXTENSIONS — video file extensions for orphan classification
# RADARR_PROTECTED_PATTERNS — file patterns never deleted
# RADARR_VERSION_MAJOR — expected Radarr major version for API safety check
# RADARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)
# ARR_CLEANUP_STATS — stats file path (read by coffee report)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# radarr_cleanup.sh — normal run
# radarr_cleanup.sh --dry-run — preview, no deletions
# radarr_cleanup.sh --log — verbose output
# radarr_cleanup.sh --status — show config and exit
# radarr_cleanup.sh --i-know-what-im-doing — bypass size threshold
# radarr_cleanup.sh --i-know-what-im-doing --skip-age-check — NUCLEAR MODE
#
# NUCLEAR MODE: both flags bypass age check AND size threshold. User accepts full
# responsibility — the flag name is long and annoying by design.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Special flag pre-processing ───────────────────────────────────────────────────────────────
parse_destructive_flags "$@"
parse_args "${FILTERED_ARGS[@]}"
# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────
nuclear_mode_warning
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for Radarr API calls"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
notify "Radarr cleanup failed on $(hostname) — jq not installed" "Radarr Cleanup" "warning"
exit 1
fi
acquire_lock "wait"
TMP_DIR="/tmp/radarr_cleanup_$$"
mkdir -p "$TMP_DIR"
trap "_release_all_locks; rm -rf $TMP_DIR" EXIT
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID and aliases RADARR_URL, RADARR_API_KEY, RADARR_MOVIES_ROOT
detect_hosts
# Skip if Radarr is not configured on this host
if [[ -z "${RADARR_URL:-}" ]] || [[ -z "${RADARR_API_KEY:-}" ]]; then
info "Radarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
exit 0
fi
ARR_DOCKER_TIMEOUT=15
RADARR_CONTAINER="Radarr"
# Build path map from MY_ID's Radarr path map
build_arr_path_map "RADARR"
require_var RADARR_URL
require_var RADARR_API_KEY
require_var RADARR_MOVIES_ROOT
if [[ ! -d "$RADARR_MOVIES_ROOT" ]]; then
error "Movies root not found: $RADARR_MOVIES_ROOT"
notify "Radarr cleanup failed on $(hostname) — movies root not found: $RADARR_MOVIES_ROOT" \
"Radarr Cleanup" "warning"
exit 1
fi
log "$ICON_GEAR Config: url=${RADARR_URL} root=${RADARR_MOVIES_ROOT}"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $RADARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
[[ "$SKIP_AGE_CHECK" == true ]] && warn "OVERRIDE — --skip-age-check active — age check bypassed"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Radarr URL: $RADARR_URL"
echo "$ICON_GEAR Movies root: $RADARR_MOVIES_ROOT"
echo "$ICON_TIME Orphan age: ${RADARR_ORPHAN_AGE} days"
echo "$ICON_GEAR Max delete: ${RADARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)"
echo "$ICON_GEAR Min tracked %: ${RADARR_MIN_TRACKED_PCT}%"
echo "$ICON_GEAR Radarr ver: v${RADARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Extensions: ${RADARR_EXTENSIONS[*]}"
echo "$ICON_GEAR Protected patterns: ${RADARR_PROTECTED_PATTERNS[*]}"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "$ICON_GEAR I know: $I_KNOW"
echo "$ICON_GEAR Skip age check: $SKIP_AGE_CHECK"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Safety Layer 1 — Container Health ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
check_container_health "$RADARR_CONTAINER" "$ARR_DOCKER_TIMEOUT" "Radarr Cleanup"
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# check_container_health(), arr_api(), has_extension(), matches_pattern_list(), format_bytes() — common.sh
# ==============================================================================================
# ━━━ Pre-flight: Radarr Import Scan ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Pre-flight: Radarr Import Scan ━━━"
# Fetch root folders from Radarr API and translate container paths to host paths
mapfile -t SCAN_ROOTS < <(
arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "rootfolder" "Radarr" | \
jq -r '.[].path' 2>/dev/null | \
while IFS= read -r cp; do translate_path "$cp"; done
)
if [[ "${#SCAN_ROOTS[@]}" -eq 0 ]]; then
error "No root folders returned from Radarr API — aborting"
notify "Radarr cleanup aborted on $(hostname) — no root folders from API" \
"Radarr Cleanup" "warning"
exit 1
fi
info "Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}"
info "Triggering ProcessMonitoredDownloads pre-flight"
SCAN_PAYLOAD='{"name": "ProcessMonitoredDownloads"}'
trigger_and_await_command "$RADARR_URL" "$RADARR_API_KEY" "v3" "$SCAN_PAYLOAD" "${RADARR_IMPORT_SCAN_TIMEOUT:-600}" "radarr"
# ==============================================================================================
# ━━━ Fetch Radarr Tracked Files ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Fetching Radarr Tracked Files ━━━"
# Safety Layer 2 — API reachability
if ! check_api "$RADARR_URL" "Radarr" 10; then
notify "Radarr cleanup aborted on $(hostname) — API unreachable" "Radarr Cleanup" "warning"
exit 1
fi
# Safety Layer 3 — API version check
check_arr_version "$RADARR_URL" "$RADARR_API_KEY" "v3" "$RADARR_VERSION_MAJOR" "Radarr" || exit 1
info "Querying Radarr API..."
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (now kept
# current every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to
# a live fetch when it's stale, and waits out an active rescan before either. Only the movie
# list itself is cached — the per-movie moviefile data below is never cached and always live,
# since that's the actual disk-truth this script's cleanup decisions depend on.
MOVIES_RESPONSE=$(arr_get_tracked_data "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3") || {
error "Failed to fetch movies from Radarr"
notify "Radarr cleanup failed on $(hostname) — could not fetch movies" \
"Radarr Cleanup" "warning"
exit 1
}
MOVIE_IDS=$(echo "$MOVIES_RESPONSE" | jq -r '.[].id' 2>/dev/null)
MOVIE_COUNT=$(echo "$MOVIE_IDS" | grep -c "." 2>/dev/null || true)
# Safety Layer 4 — movie count > 0
if [[ "$MOVIE_COUNT" -eq 0 ]]; then
error "API returned 0 movies — aborting to prevent mass deletion"
notify "Radarr cleanup aborted on $(hostname) — 0 movies returned" \
"Radarr Cleanup" "warning"
exit 1
fi
info "Found $MOVIE_COUNT movies — fetching movie files..."
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
> "$TRACKED_FILE"
# Fetches every movie's file path(s) fresh into TRACKED_FILE/TRACKED_MAP/TRACKED_COUNT.
# Pulled into a function so the rescan-aware retry below can re-fetch after waiting without
# duplicating this whole loop inline.
#
# Batched, not per-movie and not a single one-shot list read (2026-07-19) — see the header
# comment above for why movie.movieFile.path alone misses secondary edition files. Still a
# fresh live fetch on every call (not cache-first) — this function's whole purpose during the
# rescan-aware retry below is to see Radarr's progress as the rescan updates hasFile/
# movieFile, so it needs genuinely current data each time, not a stale snapshot.
_fetch_tracked_files() {
> "$TRACKED_FILE"
local movies_now
movies_now=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr" 2>/dev/null)
local _ids=() _id _qs="" _batch_count=0
local BATCH_SIZE=200 # 250 confirmed working live 2026-07-19; kept under that for margin
mapfile -t _ids < <(echo "$movies_now" | jq -r '.[] | select(.hasFile==true) | .id' 2>/dev/null)
{
for _id in "${_ids[@]}"; do
_qs+="movieId=${_id}&"
(( _batch_count++ ))
if [[ "$_batch_count" -ge "$BATCH_SIZE" ]]; then
arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" 2>/dev/null | \
jq -r '.[].path' 2>/dev/null
_qs=""
_batch_count=0
fi
done
if [[ -n "$_qs" ]]; then
arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" 2>/dev/null | \
jq -r '.[].path' 2>/dev/null
fi
} | while IFS= read -r api_path; do
[[ -z "$api_path" ]] && continue
translate_path "$api_path" >> "$TRACKED_FILE"
done
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
# Eliminates the main performance bottleneck for large libraries
unset TRACKED_MAP
declare -gA TRACKED_MAP
while IFS= read -r _tracked_path; do
[[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1
done < "$TRACKED_FILE"
unset _tracked_path
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
}
_fetch_tracked_files
info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths"
# Safety Layer 5 — tracked count > 0
if [[ "$TRACKED_COUNT" -eq 0 ]]; then
error "API returned 0 tracked files — aborting to prevent mass deletion"
notify "Radarr cleanup aborted on $(hostname) — 0 tracked files returned" \
"Radarr Cleanup" "warning"
exit 1
fi
info "$MOVIE_COUNT movies | $TRACKED_COUNT tracked movie files"
# Safety Layer 6 — percentage drop vs last known count, with rescan-aware retry.
# ProcessMonitoredDownloads (this script's own pre-flight) is a different, lighter operation
# than a full library rescan — but Radarr's own RescanMovie/DownloadedMoviesScan can be
# triggered independently and would cause the exact same mid-scan count dip confirmed on
# Lidarr 2026-07-16. Wait it out (calibrated to that command's own historical duration)
# before treating a drop as genuine.
_last_known=$(cat "$RADARR_TRACKED_COUNT_FILE" 2>/dev/null || echo 0)
if [[ "$_last_known" -gt 0 ]]; then
_strike=1
while [[ "$_strike" -le 3 ]]; do
_pct=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $_last_known) * 100}")
[[ "$_pct" -ge "${RADARR_MIN_TRACKED_PCT:-50}" ]] && break
_active_cmd=$(arr_active_rescan_command "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3")
[[ -z "$_active_cmd" ]] && break # low count, nothing rescanning — genuine, don't retry
_wait=$(( $(arr_get_rescan_duration "radarr" "$_active_cmd" 300) / 2 ))
[[ "$_wait" -lt 30 ]] && _wait=30
warn "Tracked count ${_pct}% of last run, but $_active_cmd active — waiting ${_wait}s (strike ${_strike}/3)"
sleep "$_wait"
_fetch_tracked_files
(( _strike++ ))
done
if [[ "$_strike" -gt 3 ]]; then
_active_cmd=$(arr_active_rescan_command "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3")
if [[ -n "$_active_cmd" ]]; then
warn "Radarr still busy ($_active_cmd) after 3 strikes — deferring to next scheduled run"
exit 0
fi
fi
fi
check_tracked_count_floor "$TRACKED_COUNT" "$RADARR_TRACKED_COUNT_FILE" "$RADARR_MIN_TRACKED_PCT" "Radarr Cleanup"
# ==============================================================================================
# ━━━ Scan Movies Root ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning Movies Root ━━━"
info "Root: $RADARR_MOVIES_ROOT | Orphan age: ${RADARR_ORPHAN_AGE} days"
START=$(date +%s)
ORPHAN_COUNT=0
JUNK_COUNT=0
RECENT_COUNT=0
PROTECTED_COUNT=0
ORPHAN_BYTES=0
JUNK_BYTES=0
AGE_SECONDS=$(( RADARR_ORPHAN_AGE * 86400 ))
NOW=$(date +%s)
# Files classified ORPHAN/JUNK below get their path recorded here, so the deletion pass can
# just delete them directly instead of re-walking and re-classifying every SCAN_ROOTS entry a
# second time (2026-07-17) — the size-threshold check below needs to know the total before
# deleting anything, not before knowing what to delete.
# Carries size and ctime alongside the path now, because the budget pass below has to order by
# age and stop at a byte ceiling — neither of which a bare path list can answer.
TO_DELETE_FILE="$TMP_DIR/to_delete_paths.txt"
> "$TO_DELETE_FILE"
# ── Orphan strikes ────────────────────────────────────────────────────────────────────────────
# A file must classify for deletion on RADARR_ORPHAN_STRIKE_LIMIT consecutive runs before it is
# actually removed. Gate 6 already refuses a run whose tracked count collapsed; this covers the
# partial failure underneath that threshold — one root folder failing to enumerate makes its
# movies look orphaned while the overall percentage still looks fine, and a transient fault will
# not reproduce on the next run.
#
# The file is REBUILT from this run's classifications rather than edited in place, which is what
# prunes it: anything that stopped being an orphan simply is not written again, so a file that
# Radarr re-adopts loses its strikes without needing a reset pass to find it.
#
# Keyed by host path, which is why this could not have worked before 2026-08-26 — wd_state_set
# built a regex from the key, and a release tag like [Bluray-1080p] holds the reversed range 1-0,
# so every write truncated the store to one line. See common.sh.
RADARR_ORPHAN_STRIKE_LIMIT="${RADARR_ORPHAN_STRIKE_LIMIT:-2}"
STRIKES_FILE="${RADARR_ORPHAN_STRIKES_FILE:-$DB_DIR/radarr_orphan_strikes.tsv}"
mkdir -p "$(dirname "$STRIKES_FILE")" 2>/dev/null || true
touch "$STRIKES_FILE" 2>/dev/null || true
STRIKES_NEW="$TMP_DIR/strikes_new.tsv"
> "$STRIKES_NEW"
HELD_COUNT=0
HELD_BYTES=0
# Records this run's strike for a file and says whether it has served enough of them.
# Returns 0 when the file may be deleted, 1 when it is still accruing.
orphan_strike_ok() {
local path="$1" prev strikes
prev=$(wd_state_get "$path" "$STRIKES_FILE"); prev="${prev//[^0-9]/}"
strikes=$(( ${prev:-0} + 1 ))
printf '%s:%s\n' "$path" "$strikes" >> "$STRIKES_NEW"
(( strikes >= RADARR_ORPHAN_STRIKE_LIMIT )) && return 0
warn " strike $strikes/$RADARR_ORPHAN_STRIKE_LIMIT — not removing yet: $path"
return 1
}
while read -r FILE_SIZE FILE_CTIME filepath; do
[[ -z "$filepath" ]] && continue
FILE_CTIME="${FILE_CTIME%%.*}"
if [[ -n "${TRACKED_MAP[$filepath]:-}" ]]; then
log "TRACKED: $filepath"
continue
fi
if matches_pattern_list "$filepath" "${RADARR_PROTECTED_PATTERNS[@]}"; then
log "$ICON_PROTECTED PROTECTED: $filepath"
(( PROTECTED_COUNT++ ))
continue
fi
if has_extension "$filepath" "${RADARR_EXTENSIONS[@]}"; then
# ctime, not mtime — an import preserves the release's original mtime, so a file
# Radarr moved in today can read as years old and skip this gate entirely.
# Measured 2026-07-27: 400 of 400 files imported that week had mtimes over 7
# days, one of them 9613 days. ctime is stamped when the file lands on this
# filesystem and cannot be carried in from an archive. This only holds because
# media_shares_permissions.sh applies owner/mode conditionally — a blanket
# chown/chmod restamps every inode nightly and would peg every file at age 0.
FILE_AGE=$(( NOW - FILE_CTIME ))
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_AGE_CHECK" != true ]]; then
log "RECENT (skipping): $filepath"
(( RECENT_COUNT++ ))
continue
fi
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
fi
# -printf gets size + mtime directly from find's own stat() during the walk, instead of a
# separate stat fork per file (2026-07-17) — measured ~130x faster per file (0.033ms vs
# 4.3ms), since find already has to stat() every entry anyway to know it's -type f.
done < <(
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && find "$host_path" -type f -printf '%s %C@ %p\n' 2>/dev/null
done | sort -u
)
# Eligible, not classified. A file still serving its strikes was counted as an orphan above — it
# is one — but it is not going to be deleted this run, so it must not appear in the denominator
# the budget reports against or the run claims to have skipped work it never queued.
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES - HELD_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT - HELD_COUNT ))
# Rebuilt, never edited: a path absent from this run is absent from the file, so a file Radarr
# re-adopts drops its strikes with no reset pass needed. Skipped on a dry run — a preview that
# advanced real strike counters would make the next real run delete a run early.
if [[ "$DRY_RUN" == false ]]; then
mv "$STRIKES_NEW" "$STRIKES_FILE" 2>/dev/null || warn "Could not update $STRIKES_FILE"
fi
# ==============================================================================================
# ━━━ Safety Layer 7 — Deletion Size Threshold ━━━
# ==============================================================================================
# The ceiling is a per-run budget, not a veto. It still means what it always meant — no single run
# removes more than RADARR_MAX_DELETE_GB — but a backlog larger than the ceiling now drains over
# consecutive nights instead of failing the orchestrator forever on a queue it cannot clear.
# ── AI note (AI_ASSIST_CLEANUP) ───────────────────────────────────────────────────────────────
# Describes the shape of what was classified. It decides nothing: the eligible set, the budget and
# the strikes are all settled above and none of them read this. Switch AI_ASSIST_CLEANUP off and
# the run removes exactly the same files — the log just loses a paragraph.
#
# ctime clustering is the signal worth surfacing. A normal upgrade cycle dribbles in over weeks; a
# lump sharing one narrow ctime window with mtimes spread across months is a bulk write-back, which
# is what a partnership merge against a partner holding older copies produces. That distinction
# took a person an evening on 2026-08-26 and is the whole reason this note exists.
if [[ "$ORPHAN_COUNT" -gt 0 ]] && [[ -s "$TO_DELETE_FILE" ]]; then
_ai_ev=$(awk -F'\t' '
{ n++; bytes += $1
c = int($2)
if (cmin == 0 || c < cmin) cmin = c
if (c > cmax) cmax = c
bucket[int(c / 21600)]++ }
END {
for (b in bucket) if (bucket[b] > top) { top = bucket[b] }
printf "files=%d bytes_gb=%.1f ctime_span_hours=%.1f largest_6h_ctime_bucket=%d\n",
n, bytes/1073741824, (cmax-cmin)/3600, top
}' "$TO_DELETE_FILE")
_ai_mt=$(cut -d"$(printf '\t')" -f3 "$TO_DELETE_FILE" | head -8 \
| while IFS= read -r p; do [[ -f "$p" ]] && \
printf '%s %s\n' "$(stat -c %y "$p" 2>/dev/null | cut -c1-7)" "$(basename "$p")"; done)
_ai_note=$(ai_assist_note AI_ASSIST_CLEANUP "You are looking at files an automated media-library cleanup has classified for deletion on an Unraid server. They are files on disk that the Radarr database no longer references.
EVIDENCE
$_ai_ev
sample (modification month, then path):
$_ai_mt
A normal quality-upgrade cycle produces orphans whose ctimes are spread out over weeks, because each upgrade happens on its own day. A bulk event - a sync or restore writing files back onto this host - produces orphans sharing one narrow ctime window while their modification times stay spread across months, because the copy preserves modification time but resets ctime.
In no more than three sentences, say which of those two this looks like and name the numbers above that support it. Do not recommend an action. Do not speculate beyond the evidence given.") || _ai_note=""
if [[ -n "$_ai_note" ]]; then
echo ""
echo "━━━ $ICON_GEAR AI note on this classification ━━━"
printf '%s\n' "$_ai_note"
fi
unset _ai_ev _ai_mt
fi
BUDGET_FILE="$TMP_DIR/to_delete_budgeted.txt"
if [[ "$I_KNOW" == true ]]; then
warn "OVERRIDE — --i-know-what-im-doing active, per-run budget not applied"
cut -d"$(printf '\t')" -f3- "$TO_DELETE_FILE" > "$BUDGET_FILE"
_BUDGET_KEPT_COUNT=$TOTAL_REMOVED; _BUDGET_KEPT_BYTES=$TOTAL_DELETE_BYTES
_BUDGET_DEFERRED_COUNT=0; _BUDGET_DEFERRED_BYTES=0; _BUDGET_STUCK=""
else
apply_delete_budget "$TO_DELETE_FILE" "$BUDGET_FILE" "$RADARR_MAX_DELETE_GB"
if [[ -n "$_BUDGET_STUCK" ]]; then
# One file larger than the whole budget can never fit, so it would be re-found and
# re-deferred every night. Name it rather than loop on it silently.
error "Single file exceeds the ${RADARR_MAX_DELETE_GB}GB budget on its own — nothing removed this run"
error " $_BUDGET_STUCK"
error "Raise RADARR_MAX_DELETE_GB or clear this one with --i-know-what-im-doing"
notify "Radarr cleanup stalled on $(hostname) — one file exceeds the ${RADARR_MAX_DELETE_GB}GB budget" \
"Radarr Cleanup" "warning"
elif [[ "$_BUDGET_DEFERRED_COUNT" -gt 0 ]]; then
warn "Budget ${RADARR_MAX_DELETE_GB}GB — removing $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED ($(format_bytes "$_BUDGET_KEPT_BYTES")), deferring $_BUDGET_DEFERRED_COUNT ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) to the next run"
warn "Oldest first — the deferred files are the newest and are re-evaluated tomorrow"
notify "Radarr cleanup removed $(format_bytes "$_BUDGET_KEPT_BYTES") of $(format_bytes "$TOTAL_DELETE_BYTES") on $(hostname)$_BUDGET_DEFERRED_COUNT file(s) deferred to the next run" \
"Radarr Cleanup" "normal"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
# Reuses TO_DELETE_FILE from the classification pass above instead of re-walking and
# re-classifying every SCAN_ROOTS entry again.
if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < "$BUDGET_FILE"
info "Cleaning up empty folders..."
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && \
find "$host_path" -mindepth 1 -type d -empty -delete 2>/dev/null
done
info "Empty folders removed"
fi
END=$(date +%s)
ORPHAN_HUMAN=$(format_bytes "$ORPHAN_BYTES")
JUNK_HUMAN=$(format_bytes "$JUNK_BYTES")
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR CLEANUP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Tracked: $TRACKED_COUNT files ($MOVIE_COUNT movies)"
echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles, metadata)"
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${RADARR_ORPHAN_AGE} days)"
[[ "${HELD_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Held (strikes): $HELD_COUNT files ($(format_bytes "$HELD_BYTES")) — under ${RADARR_ORPHAN_STRIKE_LIMIT} consecutive runs"
[[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Deferred: $_BUDGET_DEFERRED_COUNT files ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) — over the ${RADARR_MAX_DELETE_GB}GB run budget"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no files deleted"
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
# What was actually removed, not what was classified. With a budget in force those differ, and
# reporting the classification as the outcome is the oldest bug shape in this codebase.
warn "$ICON_DONE Removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED classified files ($(format_bytes "$_BUDGET_KEPT_BYTES"))"
notify "Radarr cleanup on $(hostname) — removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED classified files ($(format_bytes "$_BUDGET_KEPT_BYTES"))$([[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && echo ", $_BUDGET_DEFERRED_COUNT deferred")" \
"Radarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Write stats for sunday_morning_coffee_report.sh
if [[ "$DRY_RUN" == false ]] && [[ -n "${ARR_CLEANUP_STATS:-}" ]]; then
echo "$(date '+%Y-%m-%d')|radarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}" \
>> "$ARR_CLEANUP_STATS" 2>/dev/null || true
fi
exit 0
@@ -10,6 +10,10 @@
# or downloaded. Most are announced-but-never-released films delisted before
# release.
#
# Cache-first movie list (2026-07-17) — comes from the shared tracked-data cache via
# arr_get_tracked_data(), fresh (kept warm every 30min by arr_cache_prefill.sh), live fetch
# as fallback.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
@@ -153,8 +157,10 @@ if ! curl -sf --connect-timeout 5 --max-time 10 \
exit 1
fi
MOVIES=$(curl -sf --connect-timeout 5 --max-time 30 \
"$RADARR_URL/api/v3/movie?apikey=$RADARR_API_KEY" 2>/dev/null)
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (kept current
# every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to a live
# fetch when it's stale, and waits out an active rescan before either.
MOVIES=$(arr_get_tracked_data "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3")
if [[ -z "$MOVIES" || "$MOVIES" == "null" ]]; then
error "Radarr movie API returned empty"
+557
View File
@@ -0,0 +1,557 @@
#!/bin/bash
# ==============================================================================================
# ============================ Sonarr Content Classification Scan ==============================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Same problem as radarr_classification_scan.sh, TV side: Overseerr lets any user request a
# show into the wrong root folder (kids shows added to the general TV share, anime added to
# Kids_Tv_Shows, etc.). This script reads Sonarr's tracked series list and classifies every
# series as anime / kids-only / regular using metadata signals alone (genre, certification,
# network, original language) — then reports where a series' computed classification
# disagrees with the root folder it's actually sitting in, in both directions:
#
# FORWARD — a series classified as anime/kids is sitting outside its dedicated root
# REVERSE — a series sitting inside the kids/anime root doesn't match that classification
#
# Report-only by default. Every rule below was validated against this library's real data
# before being adopted — see the companion comment block in master.conf above the curated
# lists. Pass --move to actually act (see MOVE MODE below) — nothing writes to Sonarr unless
# that flag is given.
#
# ==============================================================================================
# CLASSIFICATION RULES — DIFFERENT FIELD MODEL THAN RADARR, NOT A COPY-PASTE
# ==============================================================================================
#
# TV metadata (TheTVDB, via Sonarr) shapes these signals differently than movie metadata
# (TMDb, via Radarr) — every difference below was confirmed live, not assumed:
# - Sonarr has an explicit "Anime" genre tag; Radarr does not.
# - Sonarr uses a single "network" field (TheTVDB's broadcaster), not "studio".
# - Certification is on the US TV Parental Guidelines scale (TV-Y/TV-Y7/TV-G/TV-PG/
# TV-14/TV-MA), not the MPAA scale — the tiers do not mean the same thing at the same
# position (TV-G is "general audience", not "for children", unlike movie G).
#
# is_anime:
# genre "Anime" (corroborated by Japanese language OR a Japan network — the bare tag alone
# produced a real false positive: "Craig of the Creek", an all-American Cartoon Network
# show, carries an "Anime" genre tag on TheTVDB for no discernible reason)
# OR (genre Animation AND originalLanguage Japanese)
# OR network in SONARR_ANIME_NETWORKS
# Always wins over kids when both could apply — explicit priority, not a tiebreak.
#
# is_kids ("kids will end up watching this alone" — NOT "family show night"):
# not is_anime AND (
# genre "Children" (NOT "Family" — see below)
# OR certification in (TV-Y, TV-Y7) (NOT TV-G — see below)
# OR network in SONARR_KIDS_NETWORKS
# )
# "Family" genre and "TV-G" certification were both tested standalone and rejected —
# both catch general-audience live-action content the whole household watches together
# (I Love Lucy, The Brady Bunch, Full House, Homestead Rescue), not kids-only content.
# Blanket "Animation" genre was also tested and rejected — it's dominated on TV by adult
# animated sitcoms (Rick and Morty, BoJack Horseman, Family Guy, South Park), unlike the
# movie side where it's a usable (gated) signal.
#
# No junk-detection tier here (unlike Radarr) — TheTVDB's ratings/imdbId data is far
# sparser than TMDb's even for completely legitimate shows (confirmed live: "The Pussycat
# Dolls Present: The Search for the Next Doll", a real 2007 MTV show, has ratings.votes=0
# and imdbId=null) — the vote-count heuristic that works for Radarr would flag real content
# for removal here, so it's deliberately not reused. --remove-junk from the Radarr script has
# no Sonarr equivalent for the same reason.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Report pass (always):
# check_api → check_arr_version → arr_get_tracked_data (cache-first, one call)
# → classify every series → report FORWARD and REVERSE disagreements → exit
#
# Move pass (--move only), described in detail below:
#
# Acts on FORWARD misplacements (classified anime/kids, sitting in the wrong root) and on
# REVERSE-KIDS leaks (adult certification sitting in the kids root — moved back to
# SONARR_GENERAL_ROOT). Does NOT act on REVERSE-ANIME leaks — those are genuine judgment
# calls, since deliberate style placements (Castlevania-type Western/Chinese animation
# grouped with anime by choice) legitimately live in the anime root without matching the
# anime signal.
#
# episodeFileCount is Sonarr's equivalent of Radarr's hasFile — a series can have 0 files
# (fully monitored, nothing downloaded) even while correctly classified. Those get their
# rootFolderPath/path corrected and an immediate SeriesSearch triggered rather than a file
# move (mirrors radarr_classification_scan.sh's handling of hasFile=false movies).
#
# One series at a time, verified after each. moveFiles=true flips the DB (rootFolderPath/
# episodeFileCount) instantly, but the physical move is a separate async MoveSeries command
# Sonarr drains one at a time internally — DB fields alone can report "moved" while the real
# files are still sitting at the old path behind other queued moves (confirmed live: "Full
# House" reported episodeFileCount:192 at the new path via API while the actual 75GB/192
# files hadn't moved yet). Each move polls its own MoveSeries command to "completed" before
# the DB-field check runs, so a batch can't compound the race the way a bare sleep-and-check did.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Curated Lists, Not Bare Genre/Cert Matching — see master.conf comments for exclusions.
# Cache-First — arr_get_tracked_data() same as sonarr_cleanup.sh, single call regardless
# of library size. Refreshed after --move writes so no other script reads stale data.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Required by the container interaction and state writes.
#
# Lock Acquisition
# acquire_lock prevents a scheduled run overlapping a manual --move.
#
# Host Detection
# detect_hosts() aliases SONARR_URL / SONARR_API_KEY / the root literals.
#
# curl + jq Dependency Check
# Fails fast if either is missing — every classification signal is parsed with jq,
# and a missing jq would evaluate each signal to empty and classify nothing.
#
# Report-Only Default
# Nothing is written to Sonarr without --move. The scan is safe to schedule and
# safe to run repeatedly while tuning the curated lists.
#
# Required Var Check
# require_var on SONARR_URL and SONARR_API_KEY before any request.
#
# API Reachability + Version Gate
# check_api then check_arr_version against SONARR_VERSION_MAJOR. A major version
# bump can move or rename the fields every rule here depends on, so a mismatch
# aborts rather than classifying against an unknown schema.
#
# Empty Library Abort
# A response of 0 series aborts. An empty list is indistinguishable from "nothing
# is misplaced" and would otherwise report a clean library during an API fault.
#
# Unconfigured Root Skip
# A blank SONARR_GENERAL_ROOT / KIDS_ROOT / ANIME_ROOT skips that category's
# checks rather than erroring — a host with no dedicated root is a valid setup,
# and a blank value must never be compared against as if it were a real path.
#
# One At A Time, Stop On First Failure
# Series are moved individually and the batch halts on the first failure rather
# than continuing. A misclassified root or a failing move is a condition to
# review, not to repeat across the library.
#
# Async Move Completion Polling
# moveFiles=true flips the DB instantly while the physical move is a separate
# async MoveSeries command Sonarr drains one at a time. Each move locates its own
# command and polls it to "completed" (bounded by SONARR_MOVE_POLL_TIMEOUT) before
# anything else is checked. Without this a batch reports every series moved while
# the files are still queued at the old path — and downstream orphan cleanup can
# act on that gap.
#
# Post-Move Re-Verification
# The PUT response is never trusted. The series is re-fetched and both
# rootFolderPath and episodeFileCount are confirmed against expectations before
# the move counts as successful.
#
# Reverse-Anime Leaks Excluded From Moves
# Deliberate style placements (Western/Chinese animation grouped with anime by
# choice) legitimately sit in the anime root. Those are reported, never moved.
#
# Post-Write Cache Refresh
# The tracked-data cache is refreshed after --move writes so no other arr script
# reads a stale rootFolderPath.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
# SONARR_URL / SONARR_API_KEY / SONARR_TV_ROOT — existing, aliased by detect_hosts()
# SONARR_GENERAL_ROOT / SONARR_KIDS_ROOT / SONARR_ANIME_ROOT — rootFolderPath literals as
# reported by the API (e.g. "/tv", "/kids tv", "/ext-anime-shows") — leave blank on a
# host with no dedicated root for that category; the corresponding checks are skipped,
# not treated as an error.
#
# master.conf
# SONARR_ANIME_NETWORKS / SONARR_KIDS_NETWORKS — curated network allowlists
# SONARR_VERSION_MAJOR — expected API major version (reused from sonarr_cleanup.sh)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# sonarr_classification_scan.sh — normal run, prints report
# sonarr_classification_scan.sh --log — verbose (per-series list)
# sonarr_classification_scan.sh --status — show config and exit
# sonarr_classification_scan.sh --move — act on forward misplacements + reverse-kids-leak
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# --move is a script-local flag, not one parse_args recognizes — check the raw args before
# they get filtered into PARSED_ARGS.
MOVE_MODE=false
for _arg in "$@"; do
[[ "$_arg" == "--move" ]] && MOVE_MODE=true
done
unset _arg
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
exit 1
fi
detect_hosts
if [[ -z "${SONARR_URL:-}" ]] || [[ -z "${SONARR_API_KEY:-}" ]]; then
info "Sonarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
exit 0
fi
require_var SONARR_URL
require_var SONARR_API_KEY
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Sonarr URL: $SONARR_URL"
echo "$ICON_GEAR TV root: $SONARR_TV_ROOT"
echo "$ICON_GEAR General root: ${SONARR_GENERAL_ROOT:-<not configured>}"
echo "$ICON_GEAR Kids root: ${SONARR_KIDS_ROOT:-<not configured>}"
echo "$ICON_GEAR Anime root: ${SONARR_ANIME_ROOT:-<not configured>}"
echo "$ICON_GEAR Anime networks: ${#SONARR_ANIME_NETWORKS[@]} curated"
echo "$ICON_GEAR Kids networks: ${#SONARR_KIDS_NETWORKS[@]} curated"
echo "$ICON_GEAR Move mode: $MOVE_MODE"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ $ICON_SYNC Fetching Sonarr Library ━━━"
if ! check_api "$SONARR_URL" "Sonarr" 10; then
exit 1
fi
check_arr_version "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SONARR_VERSION_MAJOR" "Sonarr" || exit 1
SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || {
error "Failed to fetch series from Sonarr"
exit 1
}
SERIES_COUNT=$(echo "$SERIES_RESPONSE" | jq -r 'length' 2>/dev/null)
if [[ -z "$SERIES_COUNT" ]] || [[ "$SERIES_COUNT" -eq 0 ]]; then
error "API returned 0 series — aborting"
exit 1
fi
info "$SERIES_COUNT series loaded"
# ==============================================================================================
# ━━━ Classify ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Classifying ━━━"
ANIME_NETWORKS_JSON=$(printf '%s\n' "${SONARR_ANIME_NETWORKS[@]}" | jq -R . | jq -s .)
KIDS_NETWORKS_JSON=$(printf '%s\n' "${SONARR_KIDS_NETWORKS[@]}" | jq -R . | jq -s .)
# Not just the US TV-MA/TV-14 tiers — non-US certification scales use different labels for the
# same "clearly adult" tier (confirmed live: "Tomb Raider: The Legend of Lara Croft" is "16+").
ADULT_CERT_JSON='["TV-MA","TV-14","MA15+","16","16+","18","15","14"]'
RESULTS=$(echo "$SERIES_RESPONSE" | jq \
--argjson animeNetworks "$ANIME_NETWORKS_JSON" \
--argjson kidsNetworks "$KIDS_NETWORKS_JSON" \
--argjson adultCert "$ADULT_CERT_JSON" \
--arg animeRoot "${SONARR_ANIME_ROOT:-}" \
--arg kidsRoot "${SONARR_KIDS_ROOT:-}" '
def is_anime:
(any(.genres[]?; . == "Anime")
and (.originalLanguage.name == "Japanese" or (.network as $n | $animeNetworks | index($n) != null)))
or (any(.genres[]?; . == "Animation") and .originalLanguage.name == "Japanese")
or (.network as $n | $animeNetworks | index($n) != null);
def is_kids:
(is_anime | not) and (
any(.genres[]?; . == "Children")
or (.certification as $c | ["TV-Y","TV-Y7"] | index($c) != null)
or (.network as $n | $kidsNetworks | index($n) != null)
);
map(
{
title, id, network, certification, rootFolderPath,
episodeFileCount: (.statistics.episodeFileCount // 0),
is_anime: is_anime,
is_kids: is_kids
} |
. + {
forward_anime_miss: (.is_anime and $animeRoot != "" and .rootFolderPath != $animeRoot),
forward_kids_miss: (.is_kids and $kidsRoot != "" and .rootFolderPath != $kidsRoot),
reverse_anime_leak: ((.is_anime | not) and $animeRoot != "" and .rootFolderPath == $animeRoot),
reverse_kids_leak: ((.is_anime | not) and (.is_kids | not) and $kidsRoot != "" and .rootFolderPath == $kidsRoot
and (.certification as $c | $adultCert | index($c) != null))
}
)
')
FORWARD_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_anime_miss)] | length')
FORWARD_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_kids_miss)] | length')
REVERSE_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_anime_leak)] | length')
REVERSE_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_kids_leak)] | length')
if [[ "$ENABLE_LOGGING" == true ]]; then
echo "$RESULTS" | jq -r '.[] | select(.forward_anime_miss or .forward_kids_miss or .reverse_anime_leak or .reverse_kids_leak) |
" [\(if .forward_anime_miss then "FORWARD-ANIME" elif .forward_kids_miss then "FORWARD-KIDS" elif .reverse_anime_leak then "REVERSE-ANIME" elif .reverse_kids_leak then "REVERSE-KIDS" else "?" end)] \(.title) (root: \(.rootFolderPath), network: \(.network // "n/a"), cert: \(.certification // "n/a"))"'
fi
# ── The reverse leaks, written down ───────────────────────────────────────────────────────────
# This block's own header says it does not act on REVERSE-ANIME leaks because they are genuine
# judgement calls. That is right, and it is also why they are the one result worth persisting:
# every other bucket either self-resolves or is acted on by --move, while these accumulate as a
# number in a summary nobody can do anything with. Seventeen of them hid two live-action dramas
# filed under anime for as long as the count stayed a count.
#
# Written as the script's own verdict so anything reading it — the triage that reads this next —
# inherits the classification rather than computing a second opinion from the same metadata.
# Report-only: this records what was found, it does not change what happens to any of it.
if [[ -n "${STATE_DIR:-}" ]] && [[ "$DRY_RUN" != true ]]; then
_review_file="$STATE_DIR/arr_classification_review.json"
echo "$RESULTS" | jq -c --arg host "$MY_ID" --argjson ts "$(date +%s)" '
{ host: $host, ts: $ts, arr: "sonarr",
reverse_anime: [ .[] | select(.reverse_anime_leak) |
{ title, root: .rootFolderPath, network: (.network // ""), cert: (.certification // ""),
lang: (.originalLanguage.name // .originalLanguage // ""), id: .id } ],
reverse_kids: [ .[] | select(.reverse_kids_leak) |
{ title, root: .rootFolderPath, network: (.network // ""), cert: (.certification // "") } ] }
' > "$_review_file" 2>/dev/null \
&& log "$ICON_GEAR Review list written — $REVERSE_ANIME_COUNT anime leak(s) for triage" \
|| warn "Could not write $_review_file — triage will have nothing to read"
unset _review_file
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY SONARR CLASSIFICATION SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Series scanned: $SERIES_COUNT"
echo "$ICON_TRASH Forward — anime miss: $FORWARD_ANIME_COUNT (classified anime, outside ${SONARR_ANIME_ROOT:-<unconfigured>})"
echo "$ICON_TRASH Forward — kids miss: $FORWARD_KIDS_COUNT (classified kids, outside ${SONARR_KIDS_ROOT:-<unconfigured>})"
echo "$ICON_WARN Reverse — anime leak: $REVERSE_ANIME_COUNT (in ${SONARR_ANIME_ROOT:-<unconfigured>}, no anime signal — review, may be deliberate style placement)"
echo "$ICON_WARN Reverse — kids leak: $REVERSE_KIDS_COUNT (in ${SONARR_KIDS_ROOT:-<unconfigured>}, adult-rated content)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ENABLE_LOGGING" != true ]] && echo " (run with --log for the per-title list)"
# ==============================================================================================
# ━━━ Move Mode ━━━
# ==============================================================================================
# See MOVE MODE in the header for scope (forward + reverse-kids-leak, not reverse-anime-leak).
if [[ "$MOVE_MODE" == true ]]; then
echo ""
echo "━━━ $ICON_SYNC Move Mode ━━━"
acquire_lock "wait"
trap "_release_all_locks" EXIT
build_arr_path_map "SONARR"
MOVE_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select(.forward_anime_miss or .forward_kids_miss or .reverse_kids_leak)]')
MOVE_COUNT=$(echo "$MOVE_TARGETS" | jq 'length')
if [[ "$MOVE_COUNT" -eq 0 ]]; then
info "Nothing to move"
exit 0
fi
warn "About to process $MOVE_COUNT series — one at a time, verifying after each"
MOVED=0
RELOCATED_SEARCH=0
FAILED=0
while IFS= read -r item; do
id=$(echo "$item" | jq -r '.id')
title=$(echo "$item" | jq -r '.title')
is_anime_flag=$(echo "$item" | jq -r '.is_anime')
is_forward_kids=$(echo "$item" | jq -r '.forward_kids_miss')
had_files_count=$(echo "$item" | jq -r '.episodeFileCount')
if [[ "$is_anime_flag" == "true" ]]; then
target_root="$SONARR_ANIME_ROOT"
elif [[ "$is_forward_kids" == "true" ]]; then
target_root="$SONARR_KIDS_ROOT"
else
target_root="$SONARR_GENERAL_ROOT"
fi
if [[ -z "$target_root" ]]; then
error "$title — target root not configured (SONARR_GENERAL_ROOT blank), skipping"
(( FAILED++ ))
continue
fi
# RESULTS only carries the reduced report fields — Sonarr's PUT expects the complete
# resource representation, so fetch a fresh full series record to modify and send back.
full_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series/$id" "Sonarr")
if [[ -z "$full_series" ]]; then
error "$title — could not fetch full series record, skipping"
(( FAILED++ ))
continue
fi
old_path=$(echo "$full_series" | jq -r '.path')
folder_name="${old_path##*/}"
# A literal "/" in the folder name would build a broken nested directory instead of
# moving to one clean folder — this is exactly the self-inflicted bug hit doing the
# Fate/Zero and Fate/Stay Night moves by hand earlier this session.
if [[ "$folder_name" == *"/"* ]]; then
error "$title — folder name contains '/', skipping (needs manual handling)"
(( FAILED++ ))
continue
fi
new_path="${target_root}/${folder_name}"
if [[ "$had_files_count" -gt 0 ]]; then
info "$title: $old_path$new_path (moving $had_files_count episode file(s))"
move_qs="?moveFiles=true"
else
info "$title: $old_path$new_path (no files — relocating + search)"
move_qs=""
fi
updated_series=$(echo "$full_series" | jq --arg root "$target_root" --arg path "$new_path" \
'.rootFolderPath = $root | .path = $path')
http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X PUT \
--max-time 30 \
-H "X-Api-Key: $SONARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$updated_series" \
"${SONARR_URL}/api/v3/series/${id}${move_qs}" 2>/dev/null)
if [[ "$http_code" != "200" && "$http_code" != "202" ]]; then
error "$title — API returned HTTP $http_code — stopping (review before re-running)"
(( FAILED++ ))
break
fi
# moveFiles=true flips rootFolderPath/episodeFileCount in the DB instantly, but the actual
# physical move is a separate async MoveSeries command that Sonarr drains one at a time
# internally — confirmed live: "Full House" showed episodeFileCount:192 at the new path via
# API while the real 75GB/192 files were still sitting at the old path, MoveSeries queued
# behind ~20 others. The DB-field check below cannot see that: poll the actual command to
# completion first, or a batch run can report every series "moved" while most are still
# mid-drain.
if [[ -n "$move_qs" ]]; then
move_cmd_id=""
for _ in 1 2 3 4 5; do
move_cmd_id=$(curl -sf --max-time 10 -H "X-Api-Key: $SONARR_API_KEY" \
"${SONARR_URL}/api/v3/command" 2>/dev/null | \
jq -r --argjson sid "$id" \
'[.[] | select(.name == "MoveSeries" and .body.seriesId == $sid)] | sort_by(.id) | last | .id // empty' \
2>/dev/null)
[[ -n "$move_cmd_id" ]] && break
sleep 1
done
if [[ -z "$move_cmd_id" ]]; then
error "$title — could not locate the MoveSeries command — stopping (review before re-running)"
(( FAILED++ ))
break
fi
info "$title: MoveSeries command $move_cmd_id queued, waiting for completion..."
move_status="" move_polled=0
while [[ "$move_polled" -lt "$SONARR_MOVE_POLL_TIMEOUT" ]]; do
move_status=$(curl -sf --max-time 10 -H "X-Api-Key: $SONARR_API_KEY" \
"${SONARR_URL}/api/v3/command/${move_cmd_id}" 2>/dev/null | \
jq -r '.status // empty' 2>/dev/null)
[[ "$move_status" == "completed" || "$move_status" == "failed" ]] && break
sleep 10
(( move_polled += 10 ))
[[ $(( move_polled % 60 )) -eq 0 ]] && log " still moving $title... (${move_polled}s elapsed)"
done
if [[ "$move_status" != "completed" ]]; then
error "$title — MoveSeries command $move_cmd_id ended as '${move_status:-timed out after ${SONARR_MOVE_POLL_TIMEOUT}s}' — stopping"
(( FAILED++ ))
break
fi
fi
sleep 3
# Never trust the PUT response alone — re-fetch and confirm the change actually landed.
# This exact check is what caught the earlier race condition doing this by hand: two
# series reported "success" while episodeFileCount had silently dropped to 0.
verify_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series/$id" "Sonarr")
verify_root=$(echo "$verify_series" | jq -r '.rootFolderPath')
verify_filecount=$(echo "$verify_series" | jq -r '.statistics.episodeFileCount // 0')
if [[ "$verify_root" != "$target_root" ]]; then
error "$title — verification failed (root: $verify_root) — stopping"
(( FAILED++ ))
break
fi
if [[ "$had_files_count" -gt 0 ]]; then
if [[ "$verify_filecount" -eq "$had_files_count" ]]; then
echo " $ICON_SUCCESS $title — moved and verified ($verify_filecount files)"
(( MOVED++ ))
else
error "$title — verification failed (root updated but episode count $verify_filecount != expected $had_files_count) — stopping"
(( FAILED++ ))
break
fi
else
search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
--max-time 30 \
-H "X-Api-Key: $SONARR_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\":\"SeriesSearch\",\"seriesId\":${id}}" \
"${SONARR_URL}/api/v3/command" 2>/dev/null)
if [[ "$search_code" == "200" || "$search_code" == "201" ]]; then
echo " $ICON_SUCCESS $title — relocated, search triggered"
else
warn " $title — relocated but search trigger returned HTTP $search_code (will pick up on next scheduled search)"
fi
(( RELOCATED_SEARCH++ ))
fi
done < <(echo "$MOVE_TARGETS" | jq -c '.[]')
# arr_get_tracked_data() is cache-first — every write above changed rootFolderPath, so the
# shared cache is now stale until the next scheduled arr_cache_prefill run. Refresh it now
# rather than leave that window open for every other script reading this cache.
if [[ "$(( MOVED + RELOCATED_SEARCH ))" -gt 0 ]]; then
info "Refreshing shared tracked-data cache..."
fresh_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series" "Sonarr")
[[ -n "$fresh_series" ]] && arr_cache_write "sonarr" "$fresh_series"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY MOVE SUMMARY ━━━━━"
echo "$ICON_SUCCESS Moved (files relocated): $MOVED"
echo "$ICON_SUCCESS Relocated + search triggered: $RELOCATED_SEARCH"
echo "$ICON_ERROR Failed: $FAILED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
exit 0
+229 -211
View File
@@ -10,6 +10,29 @@
# untracked that is old enough to be past the import window. Triggers an Emby
# library clean after each deletion run so ghost entries disappear immediately.
#
# The tracked-count floor check (Safety Layer 6) is rescan-aware: if Sonarr's own
# RescanSeries/DownloadedEpisodesScan is active (independently of this script's own
# lighter ProcessMonitoredDownloads pre-flight — e.g. during a large missing-episode
# search campaign), a genuinely low mid-scan count gets waited out (calibrated to that
# command's historical duration via arr_get_rescan_duration(), up to 3 strikes) and
# re-fetched rather than triggering a false-alarm abort. Mirrors the same fix built for
# lidarr_cleanup.sh 2026-07-16 after a whole-library rescan there made trackFileCount
# read 22% of normal mid-scan.
#
# Cache-first, both layers (2026-07-17). The series list itself comes from the shared
# tracked-data cache via arr_get_tracked_data() — fresh (kept warm every 30min by
# arr_cache_prefill.sh), live fetch as fallback. The per-series episodefile walk below still
# always fetches live (that's the actual disk-truth this script's delete decisions depend
# on), but write-throughs its result to arr_item_cache_write() for any future script that
# needs Sonarr's per-episode data — no second consumer exists yet, unlike Lidarr's
# lidarr_missing_art.sh, but the data's there once one does. The filesystem is walked once
# per run, not twice — classification records which paths are eligible for deletion as it
# goes, and the delete pass (once the size-threshold check below passes) just acts on that
# list instead of re-walking and re-classifying the whole tree. That single walk also gets
# size+ctime straight from find -printf instead of a separate stat fork per file — find
# already has to stat() every entry to know it's -type f, so this is free by comparison.
# Measured ~130x faster per file (0.033ms vs 4.3ms).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
@@ -43,6 +66,9 @@
# Files under SONARR_ORPHAN_AGE are left alone regardless of tracked status.
# Sonarr's import pipeline writes files before registering them — acting
# immediately would delete files mid-import.
# Age is measured from ctime, not mtime — an import preserves the release's original
# mtime, so a file that landed today can read as years old and skip this gate. Depends
# on media_shares_permissions.sh touching only entries that are actually wrong.
#
# Emby Cleanup Is Part of the Job
# Deleting a file without telling Emby leaves ghost entries that show as
@@ -53,22 +79,30 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Six gates — ALL must pass before any file is touched:
# Seven gates — ALL must pass before any file is touched:
# 1. Container running and not starting/unhealthy
# 2. API reachable
# 3. API version matches SONARR_VERSION_MAJOR in master.conf
# 4. Series count > 0
# 5. Tracked file count > 0
# 6. Deletion size < SONARR_MAX_DELETE_GB — or --i-know-what-im-doing required
# 6. Tracked count >= SONARR_MIN_TRACKED_PCT % of last known count
# 7. Deletion size < SONARR_MAX_DELETE_GB — or --i-know-what-im-doing required
#
# acquire_lock "wait" — large scans take time, wait for previous run to finish
# jq + curl validation — exits if either tool missing
# DOCKER_TIMEOUT — container checks protected against daemon hangs
# ARR_DOCKER_TIMEOUT — container checks protected against daemon hangs (script-local, not common.sh's DOCKER_TIMEOUT)
# notify_emby_scan() — triggers Emby clean after deletion
# platform_require_cmd — notify script validated before use
# Silent by default — orphans/junk warn(), clean library logs silently
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# SONARR_TRACKED_COUNT_FILE — persistent baseline for the tracked % safety check (gate 6)
# Updated after each successful run. Protects against misconfigured root path
# returning an empty API response and deleting the entire library.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
@@ -82,6 +116,8 @@
#
# SONARR_ORPHAN_AGE — days before untracked file eligible for deletion
# SONARR_MAX_DELETE_GB — require --i-know-what-im-doing above this
# SONARR_MIN_TRACKED_PCT — abort if tracked count drops below this % of last run
# SONARR_TRACKED_COUNT_FILE — persistent baseline file path
# SONARR_EXTENSIONS — video file extensions for orphan classification
# SONARR_PROTECTED_PATTERNS — file patterns never deleted
# SONARR_VERSION_MAJOR — expected Sonarr major version for API safety check
@@ -97,7 +133,7 @@
# sonarr_cleanup.sh --log — verbose output
# sonarr_cleanup.sh --status — show config and exit
# sonarr_cleanup.sh --i-know-what-im-doing — bypass size threshold
# sonarr_cleanup.sh --i-know-what-im-doing --skip-strike-list — NUCLEAR MODE
# sonarr_cleanup.sh --i-know-what-im-doing --skip-age-check — NUCLEAR MODE
#
# NUCLEAR MODE: both flags bypass age check AND size threshold. User accepts full
# responsibility — the flag name is long and annoying by design.
@@ -109,37 +145,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Special flag pre-processing ───────────────────────────────────────────────────────────────
I_KNOW=false
SKIP_STRIKES=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--i-know-what-im-doing) I_KNOW=true ;;
--skip-strike-list) SKIP_STRIKES=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_destructive_flags "$@"
parse_args "${FILTERED_ARGS[@]}"
# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────
if [[ "$I_KNOW" == true ]] && [[ "$SKIP_STRIKES" == true ]] && [[ "$DRY_RUN" != true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ WARNING — NUCLEAR MODE ACTIVE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Flags: --i-know-what-im-doing --skip-strike-list"
echo " Strike system: BYPASSED — deletes on first pass"
echo " Size threshold: BYPASSED — no GB limit"
echo " Data recovery: NOT POSSIBLE after deletion"
echo ""
echo " Review --dry-run output before proceeding."
echo " You have 10 seconds to cancel (Ctrl+C)..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
sleep 10
echo " Proceeding..."
echo ""
fi
nuclear_mode_warning
# ==============================================================================================
# ━━━ Setup ━━━
@@ -174,15 +185,17 @@ fi
# detect_hosts() sets MY_ID and aliases SONARR_URL, SONARR_API_KEY, SONARR_TV_ROOT
detect_hosts
DOCKER_TIMEOUT=15
# Skip if Sonarr is not configured on this host
if [[ -z "${SONARR_URL:-}" ]] || [[ -z "${SONARR_API_KEY:-}" ]]; then
info "Sonarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
exit 0
fi
ARR_DOCKER_TIMEOUT=15
SONARR_CONTAINER="Sonarr"
# Build path map from MY_ID's Sonarr path map
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_SONARR_PATH_MAP"
eval "for key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$key\"]=\"\${${local_path_map_var}[\$key]}\"
done"
build_arr_path_map "SONARR"
require_var SONARR_URL
require_var SONARR_API_KEY
@@ -200,7 +213,7 @@ echo " $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
[[ "$SKIP_STRIKES" == true ]] && warn "OVERRIDE — --skip-strike-list active — age check bypassed"
[[ "$SKIP_AGE_CHECK" == true ]] && warn "OVERRIDE — --skip-age-check active — age check bypassed"
# ==============================================================================================
# ━━━ Status ━━━
@@ -213,12 +226,13 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo "$ICON_GEAR TV root: $SONARR_TV_ROOT"
echo "$ICON_TIME Orphan age: ${SONARR_ORPHAN_AGE} days"
echo "$ICON_GEAR Max delete: ${SONARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)"
echo "$ICON_GEAR Min tracked %: ${SONARR_MIN_TRACKED_PCT}%"
echo "$ICON_GEAR Sonarr ver: v${SONARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Extensions: ${SONARR_EXTENSIONS[*]}"
echo "$ICON_GEAR Protected patterns: ${SONARR_PROTECTED_PATTERNS[*]}"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "$ICON_GEAR I know: $I_KNOW"
echo "$ICON_GEAR Skip strikes: $SKIP_STRIKES"
echo "$ICON_GEAR Skip age check: $SKIP_AGE_CHECK"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
@@ -229,90 +243,13 @@ fi
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
CONTAINER_RUNNING=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Running}}' "$SONARR_CONTAINER" 2>/dev/null)
if [[ "$CONTAINER_RUNNING" != "true" ]]; then
error "$SONARR_CONTAINER is not running — aborting"
notify "Sonarr cleanup aborted on $(hostname) — container not running" \
"Sonarr Cleanup" "warning"
exit 1
fi
CONTAINER_HEALTH=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Health.Status}}' "$SONARR_CONTAINER" 2>/dev/null)
case "$CONTAINER_HEALTH" in
healthy) info "$SONARR_CONTAINER is healthy" ;;
"") info "$SONARR_CONTAINER has no health check — proceeding" ;;
starting)
error "$SONARR_CONTAINER is still starting — aborting"
notify "Sonarr cleanup aborted on $(hostname) — container still starting" \
"Sonarr Cleanup" "warning"
exit 1 ;;
unhealthy)
error "$SONARR_CONTAINER is unhealthy — aborting"
notify "Sonarr cleanup aborted on $(hostname) — container unhealthy" \
"Sonarr Cleanup" "warning"
exit 1 ;;
*) warn "$SONARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution" ;;
esac
info "Safety layer 1 passed — container healthy"
check_container_health "$SONARR_CONTAINER" "$ARR_DOCKER_TIMEOUT" "Sonarr Cleanup"
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
sonarr_api() {
local endpoint="$1"
local response http_code body
response=$(curl -sf \
--max-time 30 \
-H "X-Api-Key: $SONARR_API_KEY" \
-w "\n%{http_code}" \
"${SONARR_URL}/api/v3/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "Sonarr API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
is_video_file() {
local ext="${1##*.}"
ext="${ext,,}"
for valid_ext in "${SONARR_EXTENSIONS[@]}"; do
[[ "$ext" == "$valid_ext" ]] && return 0
done
return 1
}
is_protected_file() {
local filename
filename=$(basename "$1")
for pattern in "${SONARR_PROTECTED_PATTERNS[@]}"; do
# shellcheck disable=SC2254
case "$filename" in
$pattern) return 0 ;;
esac
done
return 1
}
format_bytes() {
local bytes=$1
if (( bytes > 1073741824 )); then
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
elif (( bytes > 1048576 )); then
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
else
echo "${bytes}B"
fi
}
# check_container_health(), arr_api(), has_extension(), matches_pattern_list(), format_bytes() — common.sh
# ==============================================================================================
# ━━━ Pre-flight: Sonarr Import Scan ━━━
@@ -322,7 +259,7 @@ echo "━━━ $ICON_SYNC Pre-flight: Sonarr Import Scan ━━━"
# Fetch root folders from Sonarr API and translate container paths to host paths
mapfile -t SCAN_ROOTS < <(
sonarr_api "rootfolder" | \
arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "rootfolder" "Sonarr" | \
jq -r '.[].path' 2>/dev/null | \
while IFS= read -r cp; do translate_path "$cp"; done
)
@@ -339,36 +276,7 @@ info "Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}"
info "Triggering ProcessMonitoredDownloads pre-flight"
SCAN_PAYLOAD='{"name": "ProcessMonitoredDownloads"}'
SCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $SONARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$SCAN_PAYLOAD" \
"${SONARR_URL}/api/v3/command" 2>/dev/null)
SCAN_CMD_ID=$(echo "$SCAN_RESPONSE" | jq -r '.id // empty' 2>/dev/null)
if [[ -z "$SCAN_CMD_ID" ]]; then
warn "Could not trigger import scan — proceeding without pre-flight"
else
info "Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion..."
POLL_TIMEOUT=${SONARR_IMPORT_SCAN_TIMEOUT:-600}
POLLED=0
while [[ "$POLLED" -lt "$POLL_TIMEOUT" ]]; do
SCAN_STATUS=$(curl -sf --max-time 10 \
-H "X-Api-Key: $SONARR_API_KEY" \
"${SONARR_URL}/api/v3/command/${SCAN_CMD_ID}" 2>/dev/null | \
jq -r '.status // empty' 2>/dev/null)
case "$SCAN_STATUS" in
completed) info "Import scan complete ✅"; break ;;
failed) warn "Import scan reported failed — proceeding anyway"; break ;;
esac
sleep 10
(( POLLED += 10 ))
[[ $(( POLLED % 60 )) -eq 0 ]] && log " Still scanning... (${POLLED}s elapsed)"
done
[[ "$POLLED" -ge "$POLL_TIMEOUT" ]] && \
warn "Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway"
fi
trigger_and_await_command "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SCAN_PAYLOAD" "${SONARR_IMPORT_SCAN_TIMEOUT:-600}" "sonarr"
# ==============================================================================================
# ━━━ Fetch Sonarr Tracked Files ━━━
@@ -387,8 +295,12 @@ check_arr_version "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SONARR_VERSION_MAJOR" "
info "Querying Sonarr API..."
# Fetch all series
SERIES_RESPONSE=$(sonarr_api "series") || {
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (now kept
# current every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to
# a live fetch when it's stale, and waits out an active rescan before either. Only the series
# list itself is cached — the per-series episodeFile data below is never cached and always
# live, since that's the actual disk-truth this script's cleanup decisions depend on.
SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || {
error "Failed to fetch series from Sonarr"
notify "Sonarr cleanup failed on $(hostname) — could not fetch series" \
"Sonarr Cleanup" "warning"
@@ -396,7 +308,7 @@ SERIES_RESPONSE=$(sonarr_api "series") || {
}
SERIES_IDS=$(echo "$SERIES_RESPONSE" | jq -r '.[].id' 2>/dev/null)
SERIES_COUNT=$(echo "$SERIES_IDS" | grep -c "." 2>/dev/null || echo 0)
SERIES_COUNT=$(echo "$SERIES_IDS" | grep -c "." 2>/dev/null || true)
# Safety Layer 4 — series count > 0
if [[ "$SERIES_COUNT" -eq 0 ]]; then
@@ -411,31 +323,52 @@ info "Found $SERIES_COUNT series — fetching episode files..."
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
> "$TRACKED_FILE"
SERIES_INDEX=0
while IFS= read -r series_id; do
# Fetches every series' episode-file paths fresh into TRACKED_FILE/TRACKED_MAP/TRACKED_COUNT.
# Pulled into a function so the rescan-aware retry below can re-fetch after waiting without
# duplicating this whole loop inline.
#
# Write-through — also caches the raw per-episode data via arr_item_cache_write() (2026-07-17)
# for any future script that needs Sonarr's per-episode file data — none exist yet (unlike
# Lidarr, where lidarr_missing_art.sh already reads this), but the walk is happening regardless
# for our own cleanup decisions, so the cache write is free by comparison. Future consumers:
# call arr_get_cached_items("sonarr") first, fall back to your own live walk on a miss.
_fetch_tracked_files() {
> "$TRACKED_FILE"
local _series_index=0
local all_episodes_tmp
all_episodes_tmp=$(mktemp)
while IFS= read -r series_id; do
[[ -z "$series_id" ]] && continue
(( SERIES_INDEX++ ))
[[ $(( SERIES_INDEX % 50 )) -eq 0 ]] && \
log "Fetching files: $SERIES_INDEX/$SERIES_COUNT series..."
SERIES_FILES=$(sonarr_api "episodefile?seriesId=${series_id}" 2>/dev/null)
(( _series_index++ ))
[[ $(( _series_index % 50 )) -eq 0 ]] && \
log "Fetching files: $_series_index/$SERIES_COUNT series..."
SERIES_FILES=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "episodefile?seriesId=${series_id}" "Sonarr" 2>/dev/null)
if [[ -n "$SERIES_FILES" ]]; then
echo "$SERIES_FILES" >> "$all_episodes_tmp"
while IFS= read -r api_path; do
[[ -z "$api_path" ]] && continue
translate_path "$api_path" >> "$TRACKED_FILE"
done < <(echo "$SERIES_FILES" | jq -r '.[].path' 2>/dev/null)
fi
done <<< "$SERIES_IDS"
done <<< "$SERIES_IDS"
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
arr_item_cache_write "sonarr" "$(jq -s 'add // []' "$all_episodes_tmp" 2>/dev/null)"
rm -f "$all_episodes_tmp"
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
declare -A TRACKED_MAP
while IFS= read -r _tracked_path; do
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
unset TRACKED_MAP
declare -gA TRACKED_MAP
while IFS= read -r _tracked_path; do
[[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1
done < "$TRACKED_FILE"
unset _tracked_path
done < "$TRACKED_FILE"
unset _tracked_path
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
}
_fetch_tracked_files
info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths"
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
# Safety Layer 5 — tracked count > 0
if [[ "$TRACKED_COUNT" -eq 0 ]]; then
@@ -447,6 +380,41 @@ fi
info "$SERIES_COUNT series | $TRACKED_COUNT tracked episode files"
# Safety Layer 6 — percentage drop vs last known count, with rescan-aware retry.
# ProcessMonitoredDownloads (this script's own pre-flight) is a different, lighter operation
# than a full library rescan — but Sonarr's own RescanSeries/DownloadedEpisodesScan can be
# triggered independently (e.g. during a large missing-episode search campaign) and would
# cause the exact same mid-scan count dip confirmed on Lidarr 2026-07-16. Wait it out
# (calibrated to that command's own historical duration) before treating a drop as genuine.
_last_known=$(cat "$SONARR_TRACKED_COUNT_FILE" 2>/dev/null || echo 0)
if [[ "$_last_known" -gt 0 ]]; then
_strike=1
while [[ "$_strike" -le 3 ]]; do
_pct=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $_last_known) * 100}")
[[ "$_pct" -ge "${SONARR_MIN_TRACKED_PCT:-50}" ]] && break
_active_cmd=$(arr_active_rescan_command "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3")
[[ -z "$_active_cmd" ]] && break # low count, nothing rescanning — genuine, don't retry
_wait=$(( $(arr_get_rescan_duration "sonarr" "$_active_cmd" 300) / 2 ))
[[ "$_wait" -lt 30 ]] && _wait=30
warn "Tracked count ${_pct}% of last run, but $_active_cmd active — waiting ${_wait}s (strike ${_strike}/3)"
sleep "$_wait"
_fetch_tracked_files
(( _strike++ ))
done
if [[ "$_strike" -gt 3 ]]; then
_active_cmd=$(arr_active_rescan_command "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3")
if [[ -n "$_active_cmd" ]]; then
warn "Sonarr still busy ($_active_cmd) after 3 strikes — deferring to next scheduled run"
exit 0
fi
fi
fi
check_tracked_count_floor "$TRACKED_COUNT" "$SONARR_TRACKED_COUNT_FILE" "$SONARR_MIN_TRACKED_PCT" "Sonarr Cleanup"
# ==============================================================================================
# ━━━ Scan TV Root ━━━
# ==============================================================================================
@@ -464,29 +432,64 @@ JUNK_BYTES=0
AGE_SECONDS=$(( SONARR_ORPHAN_AGE * 86400 ))
NOW=$(date +%s)
MAX_DELETE_BYTES=$(awk "BEGIN {printf \"%d\", $SONARR_MAX_DELETE_GB * 1073741824}")
while IFS= read -r filepath; do
# Files classified ORPHAN/JUNK below get their path recorded here, so the deletion pass can
# just delete them directly instead of re-walking and re-classifying every SCAN_ROOTS entry a
# second time (2026-07-17) — the size-threshold check below needs to know the total before
# deleting anything, not before knowing what to delete.
TO_DELETE_FILE="$TMP_DIR/to_delete_paths.txt"
> "$TO_DELETE_FILE"
# ── Orphan strikes ────────────────────────────────────────────────────────────────────────────
# Same contract as radarr_cleanup.sh: a file must classify for deletion on
# SONARR_ORPHAN_STRIKE_LIMIT consecutive runs before it is removed. Covers the partial
# classification failure that is too small to trip the tracked-count floor above. The file is
# rebuilt from each run rather than edited, which is what prunes it.
SONARR_ORPHAN_STRIKE_LIMIT="${SONARR_ORPHAN_STRIKE_LIMIT:-2}"
STRIKES_FILE="${SONARR_ORPHAN_STRIKES_FILE:-$DB_DIR/sonarr_orphan_strikes.tsv}"
mkdir -p "$(dirname "$STRIKES_FILE")" 2>/dev/null || true
touch "$STRIKES_FILE" 2>/dev/null || true
STRIKES_NEW="$TMP_DIR/strikes_new.tsv"
> "$STRIKES_NEW"
HELD_COUNT=0
HELD_BYTES=0
orphan_strike_ok() {
local path="$1" prev strikes
prev=$(wd_state_get "$path" "$STRIKES_FILE"); prev="${prev//[^0-9]/}"
strikes=$(( ${prev:-0} + 1 ))
printf '%s:%s\n' "$path" "$strikes" >> "$STRIKES_NEW"
(( strikes >= SONARR_ORPHAN_STRIKE_LIMIT )) && return 0
warn " strike $strikes/$SONARR_ORPHAN_STRIKE_LIMIT — not removing yet: $path"
return 1
}
while read -r FILE_SIZE FILE_CTIME filepath; do
[[ -z "$filepath" ]] && continue
FILE_CTIME="${FILE_CTIME%%.*}"
if [[ -n "${TRACKED_MAP[$filepath]:-}" ]]; then
log "TRACKED: $filepath"
continue
fi
if is_protected_file "$filepath"; then
if matches_pattern_list "$filepath" "${SONARR_PROTECTED_PATTERNS[@]}"; then
log "$ICON_PROTECTED PROTECTED: $filepath"
(( PROTECTED_COUNT++ ))
continue
fi
FILE_SIZE=$(stat -c%s "$filepath" 2>/dev/null || echo 0)
if has_extension "$filepath" "${SONARR_EXTENSIONS[@]}"; then
# ctime, not mtime — an import preserves the release's original mtime, so a file
# Sonarr moved in today can read as years old and skip this gate entirely.
# Measured 2026-07-27: 400 of 400 files imported that week had mtimes over 7
# days, one of them 9613 days. ctime is stamped when the file lands on this
# filesystem and cannot be carried in from an archive. This only holds because
# media_shares_permissions.sh applies owner/mode conditionally — a blanket
# chown/chmod restamps every inode nightly and would peg every file at age 0.
FILE_AGE=$(( NOW - FILE_CTIME ))
if is_video_file "$filepath"; then
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_STRIKES" != true ]]; then
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_AGE_CHECK" != true ]]; then
log "RECENT (skipping): $filepath"
(( RECENT_COUNT++ ))
continue
@@ -495,62 +498,71 @@ while IFS= read -r filepath; do
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
if ! orphan_strike_ok "$filepath"; then (( HELD_COUNT++ )); HELD_BYTES=$(( HELD_BYTES + FILE_SIZE )); continue; fi
printf '%s\t%s\t%s\n' "$FILE_SIZE" "$FILE_CTIME" "$filepath" >> "$TO_DELETE_FILE"
fi
# -printf gets size + mtime directly from find's own stat() during the walk, instead of a
# separate stat fork per file (2026-07-17) — measured ~130x faster per file (0.033ms vs
# 4.3ms), since find already has to stat() every entry anyway to know it's -type f.
done < <(
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && find "$host_path" -type f 2>/dev/null
[[ -d "$host_path" ]] && find "$host_path" -type f -printf '%s %C@ %p\n' 2>/dev/null
done | sort -u
)
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
# Eligible, not classified: a file still serving its strikes is an orphan but is not queued this
# run, so it must not appear in the denominator the budget reports against.
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES - HELD_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT - HELD_COUNT ))
# Rebuilt, never edited. Skipped on a dry run: a preview that advanced real counters would make
# the next real run delete a run early.
if [[ "$DRY_RUN" == false ]]; then
mv "$STRIKES_NEW" "$STRIKES_FILE" 2>/dev/null || warn "Could not update $STRIKES_FILE"
fi
# ==============================================================================================
# ━━━ Safety Layer 6 — Deletion Size Threshold ━━━
# ━━━ Safety Layer 7 — Deletion Size Threshold ━━━
# ==============================================================================================
if [[ "$TOTAL_DELETE_BYTES" -gt "$MAX_DELETE_BYTES" ]]; then
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $TOTAL_DELETE_BYTES / 1073741824}")
if [[ "$I_KNOW" != true ]]; then
echo ""
error "Deletion would exceed ${SONARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted"
error "Review ORPHAN lines above carefully before proceeding"
error "Rerun with: --i-know-what-im-doing"
error "To also bypass age check: add --skip-strike-list"
notify "Sonarr cleanup halted on $(hostname)${TOTAL_HUMAN} requires --i-know-what-im-doing" \
# A per-run budget, not a veto — see apply_delete_budget() in common.sh. The ceiling still caps
# any single run; it just no longer deadlocks on a backlog larger than itself.
BUDGET_FILE="$TMP_DIR/to_delete_budgeted.txt"
if [[ "$I_KNOW" == true ]]; then
warn "OVERRIDE — --i-know-what-im-doing active, per-run budget not applied"
cut -d"$(printf '\t')" -f3- "$TO_DELETE_FILE" > "$BUDGET_FILE"
_BUDGET_KEPT_COUNT=$TOTAL_REMOVED; _BUDGET_KEPT_BYTES=$TOTAL_DELETE_BYTES
_BUDGET_DEFERRED_COUNT=0; _BUDGET_DEFERRED_BYTES=0; _BUDGET_STUCK=""
else
apply_delete_budget "$TO_DELETE_FILE" "$BUDGET_FILE" "$SONARR_MAX_DELETE_GB"
if [[ -n "$_BUDGET_STUCK" ]]; then
error "Single file exceeds the ${SONARR_MAX_DELETE_GB}GB budget on its own — nothing removed this run"
error " $_BUDGET_STUCK"
error "Raise SONARR_MAX_DELETE_GB or clear this one with --i-know-what-im-doing"
notify "Sonarr cleanup stalled on $(hostname) — one file exceeds the ${SONARR_MAX_DELETE_GB}GB budget" \
"Sonarr Cleanup" "warning"
exit 1
else
warn "OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing"
elif [[ "$_BUDGET_DEFERRED_COUNT" -gt 0 ]]; then
warn "Budget ${SONARR_MAX_DELETE_GB}GB — removing $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED ($(format_bytes "$_BUDGET_KEPT_BYTES")), deferring $_BUDGET_DEFERRED_COUNT ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) to the next run"
notify "Sonarr cleanup removed $(format_bytes "$_BUDGET_KEPT_BYTES") of $(format_bytes "$TOTAL_DELETE_BYTES") on $(hostname)$_BUDGET_DEFERRED_COUNT file(s) deferred" \
"Sonarr Cleanup" "normal"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
# Reuses TO_DELETE_FILE from the classification pass above instead of re-walking and
# re-classifying every SCAN_ROOTS entry again.
if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
[[ -n "${TRACKED_MAP[$filepath]:-}" ]] && continue
is_protected_file "$filepath" && continue
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if is_video_file "$filepath"; then
[[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && \
[[ "$SKIP_STRIKES" != true ]] && continue
fi
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < <(
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && find "$host_path" -type f 2>/dev/null
done | sort -u
)
done < "$BUDGET_FILE"
info "Cleaning up empty folders..."
for host_path in "${SCAN_ROOTS[@]}"; do
@@ -576,6 +588,10 @@ echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles,
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${SONARR_ORPHAN_AGE} days)"
[[ "${HELD_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Held (strikes): $HELD_COUNT files ($(format_bytes "$HELD_BYTES")) — under ${SONARR_ORPHAN_STRIKE_LIMIT} consecutive runs"
[[ "${_BUDGET_DEFERRED_COUNT:-0}" -gt 0 ]] && \
echo "$ICON_SKIP Deferred: $_BUDGET_DEFERRED_COUNT files ($(format_bytes "$_BUDGET_DEFERRED_BYTES")) — over the ${SONARR_MAX_DELETE_GB}GB run budget"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
@@ -584,8 +600,10 @@ if [[ "$DRY_RUN" == true ]]; then
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
notify "Sonarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
# What was actually removed, not what was classified. With strikes and a budget in force those
# differ, and reporting the classification as the outcome is the oldest bug shape here.
warn "$ICON_DONE Removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED eligible files ($(format_bytes "$_BUDGET_KEPT_BYTES"))"
notify "Sonarr cleanup on $(hostname) — removed $_BUDGET_KEPT_COUNT of $TOTAL_REMOVED eligible files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
"Sonarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
@@ -9,6 +9,10 @@
# status="deleted" — they generate health errors and can never be monitored
# or downloaded.
#
# Cache-first series list (2026-07-17) — comes from the shared tracked-data cache via
# arr_get_tracked_data(), fresh (kept warm every 30min by arr_cache_prefill.sh), live fetch
# as fallback.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
@@ -152,8 +156,10 @@ if ! curl -sf --connect-timeout 5 --max-time 10 \
exit 1
fi
SERIES=$(curl -sf --connect-timeout 5 --max-time 30 \
"$SONARR_URL/api/v3/series?apikey=$SONARR_API_KEY" 2>/dev/null)
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (kept current
# every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to a live
# fetch when it's stale, and waits out an active rescan before either.
SERIES=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3")
if [[ -z "$SERIES" || "$SERIES" == "null" ]]; then
error "Sonarr series API returned empty"
@@ -27,8 +27,9 @@
# ==============================================================================================
#
# 1. Check WEBHOOK_PORT — exit cleanly if 0 (listener disabled)
# 2. Check WEBHOOK_SECRET — generate and persist one if empty
# 3. exec node webhook_listener.js — replaces this process; PID stays the same
# 2. acquire_lock "continuous" — exit cleanly if a healthy instance is already running
# 3. Check WEBHOOK_SECRET — generate and persist one if empty
# 4. exec node webhook_listener.js — replaces this process; PID stays the same
#
# exec is intentional: array_started.sh tracks the PID of this script to check
# whether the listener is running. exec preserves that PID across the hand-off
@@ -53,15 +54,51 @@
# PID of this script to check liveness — exec ensures that PID continues to
# refer to the running node process after the hand-off.
#
# Continuous-Mode Lock, Not Just PID Tracking
# array_started.sh only checks whether ITS launch attempt is still alive after
# 1s — it has no idea a previous instance might already be listening (e.g. an
# array stop/start that didn't kill the old node process). Without its own
# guard, a relaunch would exec straight into node, hit EADDRINUSE on the port,
# exit 1 almost immediately, and array_started.sh would log a false failure
# for a listener that was actually still healthy. acquire_lock "continuous"
# detects the live instance and exits 0 instead.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# WEBHOOK_PORT=0 gate — exits cleanly before any setup if the listener is disabled
# Secret auto-generate — WEBHOOK_SECRET generated via openssl rand if empty;
# persisted to master.conf immediately so restarts reuse it
# Shared secret gate — webhook URL must include ?key=<WEBHOOK_SECRET>;
# requests without a valid key are rejected by the Node.js server
# Root Enforcement
# Writes /var/log/varaverk and persists the generated secret into master.conf.
#
# WEBHOOK_PORT=0 Gate
# Exits cleanly before any setup if the listener is disabled.
#
# node Presence Check
# node is exec'd at the end of this script. Checking up front fails with a clear
# reason at array start rather than an exec error buried in the log after setup.
#
# openssl Presence Check
# Checked before attempting to generate a secret, on the path that needs it.
#
# Lock Acquisition
# acquire_lock "continuous" exits 0 cleanly if a healthy instance is already
# listening, instead of relaunching into an EADDRINUSE port conflict. An array
# stop/start without a full reboot leaves the old node process alive, and without
# this array_started.sh would log a false failure.
#
# Secret Auto-Generation
# WEBHOOK_SECRET generated via openssl rand if empty, and persisted to master.conf
# so restarts reuse it.
#
# Secret Persistence Verification
# The write-back is confirmed by re-reading master.conf. If it did not land, the
# secret would exist only in this process and be regenerated on the next start,
# silently invalidating the key already registered in the arrs — so this aborts
# loudly rather than starting with a secret that will not survive a restart.
#
# Shared Secret Gate
# The webhook URL must include ?key=<WEBHOOK_SECRET>; requests without a valid key
# are rejected by the Node.js server.
#
# ==============================================================================================
# CONFIGURATION
@@ -92,22 +129,60 @@ ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ECOSYSTEM_ROOT/load_config.sh"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
[[ "${WEBHOOK_PORT:-0}" -eq 0 ]] && {
echo "[webhook] WEBHOOK_PORT=0 — listener disabled"
exit 0
}
# node is exec'd at the end of this script — checking here fails with a clear reason at
# array start instead of an exec error buried in the log after all the setup has run.
if ! command -v node >/dev/null 2>&1; then
error "node not found — required to run webhook_listener.js"
notify "Webhook listener failed to start on $(hostname) — node not installed" \
"Webhook Listener" "warning"
exit 1
fi
# Skip gracefully if a healthy instance is already listening — otherwise a
# restart that doesn't kill the old node process (array stop/start without a
# full reboot) hits EADDRINUSE and array_started.sh logs a false failure.
acquire_lock "continuous"
# ── Auto-generate secret if not yet set ─────────────────────────────────────
if [[ -z "${WEBHOOK_SECRET:-}" ]]; then
if ! command -v openssl >/dev/null 2>&1; then
error "openssl not found — cannot generate WEBHOOK_SECRET"
notify "Webhook listener failed to start on $(hostname) — openssl not installed" \
"Webhook Listener" "warning"
exit 1
fi
GENERATED=$(openssl rand -hex 32)
MASTER_CONF="$ECOSYSTEM_ROOT/Configurations/master.conf"
sed -i "s/WEBHOOK_SECRET=\"\"/WEBHOOK_SECRET=\"$GENERATED\"/" "$MASTER_CONF"
WEBHOOK_SECRET="$GENERATED"
# If the sed didn't match, the secret only exists in this process. The listener would
# come up, then regenerate a different secret on the next start — silently invalidating
# the key already registered in the arrs. Fail loudly instead.
if ! grep -q "WEBHOOK_SECRET=\"$GENERATED\"" "$MASTER_CONF" 2>/dev/null; then
error "Generated WEBHOOK_SECRET but could not persist it to $MASTER_CONF"
error "Set WEBHOOK_SECRET manually — a non-persisted secret changes on every restart"
notify "Webhook secret not persisted on $(hostname) — set WEBHOOK_SECRET manually" \
"Webhook Listener" "warning"
exit 1
fi
echo "[webhook] Generated WEBHOOK_SECRET — run Tools/webhook_setup.sh to register in arrs"
fi
mkdir -p /var/log/varaverk
exec node "$ECOSYSTEM_ROOT/Media/webhook_listener.js" \
exec node "$ECOSYSTEM_ROOT/Arrs_Stack/webhook_listener.js" \
"$WEBHOOK_PORT" "$WEBHOOK_SECRET" "$ECOSYSTEM_ROOT" \
>> /var/log/varaverk/upgrade_webhook.log 2>&1
@@ -15,6 +15,30 @@
# begin searching — a search it will never win because we already have it.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Invoked per import by webhook_listener.js with <arr_type> <item_path>:
#
# 1. Validate
# → arr_type must be sonarr|radarr|lidarr; item_path must exist and be a safe
# absolute path
#
# 2. Resolve arr specifics
# → API port, API version and rescan command for that arr type
#
# 3. Discover remote nodes
# → discover_remote_nodes(); no remotes configured means exit cleanly
#
# 4. Per remote node, independently:
# a. Resolve its Tailscale IP — unresolvable skips that node
# b. rsync the single item to the same absolute path (--no-delete)
# c. Skip the rescan if rsync failed — never scan a partial file
# d. Trigger the arr's refresh command, cache-first API key with SSH fallback
#
# One failing node is counted and skipped; the rest still receive the upgrade.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -38,13 +62,47 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Arg validation — exits with usage message if arr_type or item_path missing
# Path existence — exits if item_path is not a directory on disk
# Tailscale resolution — skips a node if its Tailscale IP cannot be resolved
# rsync exit check — rescan is only triggered if rsync succeeded; a failed
# transfer does not cause the remote arr to scan a partial file
# SSH fallback — if no cached API key, falls back to SSH to read config.xml
# on the remote rather than failing the rescan step
# Root Enforcement
# rsync runs over SSH as root and writes to root@remote at the same absolute path.
#
# Argument Validation
# Exits with a usage message if arr_type or item_path is missing, and rejects an
# arr_type outside sonarr|radarr|lidarr rather than defaulting to one.
#
# Path Existence
# Exits if item_path is not a directory on disk.
#
# Item Path Depth Guard
# item_path must be an absolute path at least three levels deep. It arrives from the
# arr's webhook payload and is rsynced to the same path on the partner, so a truncated
# or malformed value would push a system directory — or the filesystem root — onto the
# remote. The existence check alone does not catch this, because / is a directory.
#
# No Lock — Deliberate
# This is an event handler invoked per import by webhook_listener.js. Concurrent
# upgrades are normal and expected. A default lock would silently drop overlapping
# events, and a waiting lock would queue them behind a slow transfer, so neither is
# used: each invocation rsyncs a different item path and they do not contend.
#
# Tailscale Resolution
# Skips a node if its Tailscale IP cannot be resolved, rather than attempting the
# transfer against an unresolved or stale address.
#
# rsync Exit Check
# The rescan is only triggered if rsync succeeded. A failed transfer never causes the
# remote arr to scan a partial file into its library.
#
# No Delete on Push
# rsync runs with --no-delete. This pushes one upgraded item; it is not a mirror, and
# must never remove content on the partner that this run does not know about.
#
# SSH Fallback
# If no cached API key is available, falls back to SSH to read config.xml on the
# remote rather than failing the rescan step.
#
# Per-Node Isolation
# One unreachable or failing node is counted and skipped; the remaining nodes still
# receive the upgrade.
#
# ==============================================================================================
# CONFIGURATION
@@ -90,6 +148,21 @@ ITEM_PATH="${2:-}"
[[ -d "$ITEM_PATH" ]] || { echo "Path not found: $ITEM_PATH" >&2; exit 1; }
if [[ "$EUID" -ne 0 ]]; then
echo "Must be run as root" >&2
exit 1
fi
# ITEM_PATH is rsynced to root@remote at the same absolute path. It arrives from the arr's
# webhook payload, so a malformed or truncated value would push a system directory — or the
# filesystem root — onto the partner. -d alone does not catch that: / is a directory.
_depth="${ITEM_PATH//[^\/]/}"
if [[ "$ITEM_PATH" != /* || "${#_depth}" -lt 3 ]]; then
echo "Refusing unsafe item path: '$ITEM_PATH' — expected an absolute path at least 3 levels deep" >&2
exit 1
fi
unset _depth
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
detect_hosts
@@ -103,13 +176,7 @@ case "$ARR_TYPE" in
esac
# ── Remote node list ──────────────────────────────────────────────────────────────────────────
declare -a REMOTE_NODES=()
for _hv in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
[[ "$_hv" == "$MY_ID" ]] && continue
[[ -z "${!_hv:-}" ]] && continue
REMOTE_NODES+=("$_hv")
done
unset _hv
discover_remote_nodes
if [[ "${#REMOTE_NODES[@]}" -eq 0 ]]; then
echo "No remote nodes configured — nothing to push"
@@ -123,11 +190,14 @@ echo "[$(date '+%H:%M:%S')] Upgrade push: ${ARR_TYPE} — ${ITEM_NAME}"
echo " Path: $ITEM_PATH"
echo " Targets: ${REMOTE_NODES[*]}"
NODE_FAIL=0
# ── Push and rescan each remote ───────────────────────────────────────────────────────────────
for node_id in "${REMOTE_NODES[@]}"; do
node_name="${!node_id}"
node_ip=$(resolve_tailscale_ip "$node_name") || {
echo " [${node_name}] Cannot resolve Tailscale IP — skipping"
(( NODE_FAIL++ ))
continue
}
@@ -142,6 +212,7 @@ for node_id in "${REMOTE_NODES[@]}"; do
if [[ "$rsync_exit" -ne 0 ]]; then
echo " [${node_name}] rsync failed (exit ${rsync_exit}) — skipping rescan"
(( NODE_FAIL++ ))
continue
fi
echo " [${node_name}] rsync done (${transferred:-0} bytes)"
@@ -177,7 +248,11 @@ REMOTE
echo " [${node_name}] ${RESCAN_CMD} triggered ✅"
else
echo " [${node_name}] ${RESCAN_CMD} failed (HTTP ${http_code:-timeout})"
(( NODE_FAIL++ ))
fi
done
echo "[$(date '+%H:%M:%S')] Done — ${ITEM_NAME}"
[[ "$NODE_FAIL" -gt 0 ]] && exit 1
exit 0
@@ -20,7 +20,7 @@ if (!port || !secret || !scriptsDir) {
}
const LOG_FILE = '/var/log/varaverk/upgrade_webhook.log';
const HANDLER = path.join(scriptsDir, 'Media', 'upgrade_webhook_handler.sh');
const HANDLER = path.join(scriptsDir, 'Arrs_Stack', 'upgrade_webhook_handler.sh');
function log(msg) {
const ts = new Date().toTimeString().slice(0, 8);
-162
View File
@@ -1,162 +0,0 @@
# Varaverk — Claude Code Context
## Working Rules (read first)
- **Dev workspace** — `/mnt/cloud-storage/Important Shit/Git/Development/Varaverk/`. All editing happens here.
- **Prod** — `/boot/config/plugins/varaverk/`. Never edited directly. Only updated via `git pull` (daily orchestrator or the pull button in the UI).
- **No Co-Authored-By** in commit messages unless explicitly asked.
- **No comments** unless the WHY is genuinely non-obvious.
### Current workflow: dev/prod split
Edit in dev, push to Gitea, pull prod when ready (daily orchestrator or manual UI trigger).
Push is the only bridge — no deploy hooks, no rsync-on-save, no direct path references between dev and prod.
`plugin_setup.sh` stays pointing at the prod path. Dev never touches `/boot/` directly.
### Hard limits — do not cross these
- **Never create or modify `.claude/settings.json`** in this repo. No workspace hooks, ever. The stale hook that existed here previously fired `Deployment/deploy.sh` (now deleted) on every file edit and caused unintended deploys. If you think a hook would help, ask first.
- **Never change `HOST1_STORAGE_MODE_INTERNAL`** in `host1.conf`. Claude data belongs in `/mnt/user/appdata/claude-code/` — not inside this repo.
- **Never move files between `Configurations/` and `Deployment/`** without explicit instruction. `Configurations/` = live runtime confs (gitignored). `Deployment/` = templates and setup tooling (tracked).
---
## Project: What Varaverk Is
Self-healing, self-maintaining, mutually-redundant two-server Unraid home media ecosystem.
One codebase runs on both servers. No primary/standby — both run independently and cover each other.
**HOST1 — unRAID-Gmer4Lfe** (`gmer4lfe@gmail.com`)
- Hardware: Threadripper 1950X, 128 GB RAM, ZFS cache pools
- Domain: Gmer4Lfe.com
- Runs: full arr stack (Sonarr/Radarr/Lidarr), auth stack (source of truth), Emby primary
**HOST2 — unRAID-Jayred365**
- Hardware: Intel i5 10th gen, 64 GB RAM
- Domain: Gmer4Lfe.us
- Status: being rebuilt — most host2.conf sections scaffolded, not yet fully online
Networking between hosts: Tailscale mesh. No hardcoded IPs — hostnames resolve via Tailscale.
---
## Configuration System (three-file model)
Every script sources all three at startup:
```
master.conf ← shared: thresholds, toggles, profiles, orchestrator job lists
host1.conf ← HOST1 credentials, shares, container names, keys
host2.conf ← HOST2 credentials, shares, container names, keys
```
Sparse checkout (git) means each server only pulls its own `host*.conf`.
HOST1 never sees HOST2 credentials and vice versa.
**Rule:** thresholds/toggles → `master.conf`; credentials/paths/container names → `host*.conf`.
`detect_hosts()` in `common.sh` matches `$(hostname)` against `HOST1`/`HOST2` in `master.conf`
and sets `MY_ID` / `REMOTE_ID` for the rest of the script.
---
## Platform Adapter Layer
`Plugin/unraid/adapter.sh` isolates all OS-specific calls.
Scripts never branch on OS directly — always call adapter functions.
This is intentional architecture — don't bypass it.
---
## Key Paths
| Path | Purpose |
|------|---------|
| `master.conf` | Shared config — all thresholds, toggles, profiles |
| `host1.conf` / `host2.conf` | Per-host credentials, shares, container lists |
| `common.sh` | Shared functions — `detect_hosts()`, `log()`, `notify()`, etc. |
| `load_config.sh` | Sources all three conf files + common.sh |
| `State_Files/` | Runtime state (watchdogs, fallback, transcode) — survives reboots |
| `data/` | Historical logs and stats |
| `Plugin/unraid/` | Unraid WebGUI plugin (PHP pages, API endpoints, adapter) |
| `Orchestrators/` | Top-level schedulers (array_started, daily, weekly, watchdog) |
| `Watchdogs/` | docker_watchdog, system_watchdog, resource_watchdog, stability |
| `Fallback/` | Mutual container failover logic |
| `Rsync/` | rsync.sh + profile system |
| `Media/` | Arr cleanup, discovery, permissions, play state sync |
| `Tools/` | Manual one-off tools including `claude_startup.sh` |
---
## Orchestrator Schedule
| When | What |
|------|------|
| Array start | `Orchestrators/array_started.sh` → runs `ARRAY_START_SCRIPTS` |
| Every minute | `watchdog_orchestrator.sh` → resource → docker → system → stability watchdogs |
| Every 30 min | `critical_sync_maintenance.sh` → downloaders_reset, play_state_sync, critical rsync |
| Every 4 hours | `intermediate_sync_maintenance.sh` → arr_sync, arrs_failed_stalled_recovery |
| Daily 1am | `daily_sync_maintenance.sh` → git pull, permissions, cleaners, arr cleanup, docker updates |
| Sunday 2:30am | `weekly_sync_maintenance.sh` → full Emby + Critical-Data sync, weekly restarts |
| Sunday 3am+ | `monthly_maintenance.sh` (self-gated on 30-day uptime) → ZFS scrub, SMART tests |
| Sunday 7am | `sunday_morning_coffee_report.sh` → ZFS, SMART, certs, backup verify, bandwidth, Emby report |
---
## Rsync Toggle State (current)
```bash
RSYNC_ENABLED=true
CRITICAL_RSYNC_ENABLED=true
INTERMEDIATE_RSYNC_ENABLED=true
DAILY_RSYNC_ENABLED=true
WEEKLY_RSYNC_ENABLED=true
FALLBACK_RSYNC_ENABLED=true
```
---
## Fallback System
`fallback.sh` runs continuously from array start.
States: `NORMAL | FALLBACK | NO_INTERNET | DARK`
DDNS rules are absolute:
- Internet loss → stop own DDNS immediately
- Failover → start remote's DDNS as Tier 1 first
- Handback → stop remote DDNS → rsync → start containers → start local DDNS last
Tier delays before activating higher tiers are in `host*.conf` (`HOST1_TIER*_DELAY`, `HOST2_TIER*_DELAY`).
---
## Port Notes
- **NPM admin API (`HOST1_NPM_URL`)** — port **7818**. Port 81 is the partnership WebUI port (`HOST1_PARTNERSHIP_AUTH_WEBUIS`), not the API. Easy to confuse.
- **HOST1_NETWORK_WATCHDOG_NPM_URL** — external HTTPS domain, completely separate from the admin API.
## Known Gaps / Active Work
- HOST2 NPM/lldap credentials (`HOST2_NPM_USER`, `HOST2_NPM_PASS`, `HOST2_LLDAP_PASS`) are empty in `host2.conf` — fill in when HOST2 is back online.
- `PARTNERSHIP_ENABLED=false` — not yet active.
- `FALLBACK_ENABLED=true` — fallback is running.
---
## Claude Code Persistence on Unraid
`/root` is a RAM filesystem — wiped on every reboot.
`Tools/claude_startup.sh` runs at array start (via `ARRAY_START_SCRIPTS`) and:
- Symlinks `/root/.claude``/mnt/user/appdata/claude-code/.claude`
- Symlinks `/root/.local/share/claude``/mnt/user/appdata/claude-code/local/share/claude`
- Symlinks `/root/CLAUDE.md``/boot/config/plugins/varaverk/CLAUDE.md` (this file)
This file lives on `/boot` (USB flash) and is always available regardless of array state.
---
## Commit Style
Plain, concise messages. No Co-Authored-By trailers. No bullet-point summaries in the body.
One sentence on the why, not the what.
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
# ━━━━━ DEPLOYMENT — Manual ━━━━━
Procedures and flag reference for the schema layer.
For overview see README-Deployment.md. For per-script detail see the script headers.
---
## ━━━ PROCEDURES ━━━
### Adding a New Configuration Variable
The single most common task here, and the one with the quietest failure mode if done wrong.
```bash
# 1. Add it to the template — this is the versioned schema
# Shared threshold/toggle → Deployment/master.conf.template
# Per-host credential/path → Deployment/host.conf.template (use the HOSTN_ prefix)
# 2. Add it to your own live conf so you can test immediately
# Configurations/master.conf or Configurations/host1.conf
# 3. Use it in the script, with a safe default
# [[ "${NEW_THRESHOLD:-50}" -gt ... ]]
# 4. Commit the script change and the template change TOGETHER
git add Deployment/master.conf.template Watchdogs/System/storage_watchdog.sh
git commit -m "..."
```
**Step 1 is the one that gets skipped.** A script merged without its template entry works on
the machine it was written on and nowhere else — the variable is simply empty on every other
node, and the script takes whatever branch an empty value produces. Nothing errors.
Placement rule, same as everywhere in the ecosystem:
| Kind | Goes in |
|------|---------|
| Threshold, toggle, profile, job list | `master.conf.template` |
| Credential, path, container name, per-host identity | `host.conf.template` |
---
### Previewing a Schema Change Before It Lands
```bash
bash Deployment/conf_upgrade.sh \
--template Deployment/master.conf.template \
--target Configurations/master.conf \
--dry-run
```
Prints the full ADDED / REMOVED / KEPT report and writes nothing. No root required — safe to
run as any user, on any target, at any time.
Read the **REMOVED** list carefully. A key showing as REMOVED means it is in your conf but no
longer in the template — either genuinely deprecated, or someone forgot step 1 above and the
next pull will drop a setting you still rely on.
---
### Applying a Schema Change by Hand
Normally automatic via `git_pull_execute.sh`. To run it manually:
```bash
bash Deployment/conf_upgrade.sh \
--template Deployment/master.conf.template \
--target Configurations/master.conf \
--backup
```
`--backup` writes `master.conf.bak` first. Use it — see The Recovery Gap in the README for
why the backup matters more here than it looks.
For a host conf, the template must have its prefix resolved first, exactly as
`git_pull_execute.sh` does it:
```bash
TMPL=$(mktemp)
sed "s/HOSTN_/HOST1_/g; s/REMOTE_ID/HOST2/g" Deployment/host.conf.template > "$TMPL"
bash Deployment/conf_upgrade.sh --template "$TMPL" --target Configurations/host1.conf --backup
rm -f "$TMPL"
```
Merging the raw template without substituting `HOSTN_` would add 140 new `HOSTN_*` keys
alongside your real `HOST1_*` ones, and mark every real key as REMOVED.
---
### Populating Credentials on a New Host
```bash
bash Deployment/conf_populate.sh --dry-run # always first
bash Deployment/conf_populate.sh
```
Reads from the services actually running on this host and fills **empty** fields only.
Existing values are never touched without `--overwrite`.
What it detects: arr API keys and ports from each `config.xml`, root folders from the arr
rootFolder API, path maps from docker volume mounts, SABnzbd/slskd/qBittorrent credentials
from their own config files, Emby/Jellyfin containers and ports, the boot device transport,
and the default-gateway NIC.
Then pushes the updated conf to partners via `conf_sync.sh` so they hold the fresh keys
immediately. `--no-push` skips that.
**If a field stays empty after a run,** check the output for an ambiguity warning:
```
WARN: Container prefix 'authelia' is ambiguous — matches: Authelia-Secondary Authelia
WARN: Refusing to guess. Set the container name manually in host*.conf.
```
That is working as intended. Set it by hand and re-run.
---
### After Rotating an API Key
```bash
bash Deployment/conf_populate.sh --overwrite --dry-run
bash Deployment/conf_populate.sh --overwrite
```
`--overwrite` replaces detected fields even when already set. This is the intended use for it
— rotating an arr key, rebuilding a container, or repointing at a moved service.
Note it overwrites **every** detected field, not just the rotated one. Run the dry-run first
and read the list.
---
### Rebuilding a Wiped Node
The templates are what make this possible without copying another machine's credentials.
```bash
# 1. Clone the repo — templates come with it, confs do not (gitignored)
# 2. Seed the confs from the templates
cp Deployment/master.conf.template Configurations/master.conf
sed "s/HOSTN_/HOST2_/g; s/REMOTE_ID/HOST1/g" Deployment/host.conf.template > Configurations/host2.conf
# 3. Fill in what can be detected automatically
bash Deployment/conf_populate.sh
# 4. Fill in the rest by hand — anything conf_populate cannot see:
# hostnames, DDNS containers, fallback tier lists, sync share lists,
# partner credentials, Discord webhook
```
From then on, `git_pull_execute.sh` keeps the conf in step with the template automatically.
---
## ━━━ FLAG REFERENCE ━━━
### `conf_upgrade.sh`
| Flag | Required | What it does |
|------|----------|-------------|
| `--template <file>` | yes | Source of structure and new keys |
| `--target <file>` | yes | Existing conf — source of real values, always preserved |
| `--dry-run` | | Print the change report, write nothing. No root needed. |
| `--backup` | | Write `<target>.bak` before installing |
Takes no other flags. It does not source `load_config.sh`, so `--log` and `--status` do not
exist here — see the script header for why that is deliberate.
### `conf_populate.sh`
| Flag | What it does |
|------|-------------|
| `--dry-run` | Show what would be written, truncated. Changes nothing. |
| `--overwrite` | Replace detected fields even when already set |
| `--no-push` | Skip pushing the updated conf to partners |
| `--log` | Verbose per-field output |
---
## ━━━ TROUBLESHOOTING ━━━
### A variable is empty on one node but set on another
The template entry is missing. Confirm:
```bash
grep -n "MY_VARIABLE" Deployment/master.conf.template Deployment/host.conf.template
```
No hit means the variable was added to a conf directly and never to the template, so it has
never reached any other node. Add it to the template; the next pull propagates it.
### `conf_upgrade` reports a key as REMOVED that is still in use
Same cause, opposite direction — the key is in your conf and in a script, but not in the
template. Add it to the template before the next pull drops it.
### The conf came back with different permissions
It should not — the target's mode and owner are copied onto the staged file before the
rename. If they did change, check that the target existed before the run: `chmod --reference`
silently no-ops against a missing file.
### A conf edit disappeared after a pull
Expected if the key is not in the template. `conf_upgrade` keeps values for keys that exist in
both; a key present only in your conf is classified REMOVED and dropped. Add it to the
template.
### `conf_populate` skipped a field
Either the service is not running, its config file was unreadable, or the container name
prefix was ambiguous. The last case prints an explicit warning with the full match list —
set that value by hand.
---
## ━━━ WHAT THIS FOLDER DOES NOT DO ━━━
- **It does not create `Configurations/`.** `conf_upgrade.sh` aborts if the target does not
exist rather than creating a partial conf. Seeding a new node is the manual step above.
- **It does not sync confs between hosts.** That is `System_Essentials/conf_sync.sh`.
- **It does not validate values.** It reconciles *structure*. A threshold set to nonsense
merges through untouched — the consuming script owns validation.
+211
View File
@@ -0,0 +1,211 @@
# ━━━━━ DEPLOYMENT ━━━━━
The schema layer. `Configurations/*.conf` holds every value the ecosystem runs on — and is
gitignored, because it holds credentials. This folder holds the **templates** those confs are
built from, and the two scripts that keep the confs in step with them.
Two files that are versioned, and two scripts that reconcile the unversioned confs against
them:
```
Deployment/master.conf.template 349 vars ← the versioned schema
Deployment/host.conf.template 140 vars ← per-host schema, HOSTN_-prefixed
Deployment/conf_upgrade.sh ← template → conf, values preserved
Deployment/conf_populate.sh ← running services → conf, empty fields only
```
> **The templates are the only versioned record of what configuration exists.** Nothing else
> in git knows that a variable is supposed to be there.
---
## ━━━ THE PROBLEM THAT BUILT THIS ━━━
**Config Holds Secrets, So Config Cannot Be Committed**
`master.conf` and `host*.conf` contain API keys, passwords, SSH key paths and personal
hostnames. They are gitignored, along with their `.bak` files:
```
.gitignore:4 Configurations/host*.conf
.gitignore:5 Configurations/master.conf
.gitignore:6 Configurations/*.bak
```
That is correct and non-negotiable. But it creates a problem: if the confs are not in git,
then **git has no idea a new setting was ever added.** A script that starts reading
`NEW_THRESHOLD` works on the machine where it was developed and silently fails everywhere
else, because no other node's conf has that key.
**A New Node Would Start With Nothing**
Without a versioned schema there is no way to stand up a second server, or rebuild a wiped
one, except by hand-copying a conf from a machine that already works — which means copying
its credentials too.
**Hand-Editing Confs Across Nodes Does Not Scale**
Two servers, ~490 variables between them. Adding a setting by hand means editing it on every
node, in the right section, with the right default, without disturbing the values already
there. Miss one and the failure surfaces days later as a script behaving differently on one
host.
---
## ━━━ WHAT THIS FOLDER DOES ━━━
### 🔀 Schema Merge — `conf_upgrade.sh`
Merges a template into an existing conf while preserving **every value the user has already
set**. Runs automatically from `git_pull_execute.sh` after every single pull.
```
Key in template only → ADDED placeholder/default, filled in once
Key in conf only → REMOVED deprecated in this version
Key in both → KEPT the conf's value always wins
Comments, blank lines → from the template — structure follows the new version
```
That last rule is what makes it safe to run unattended forever: the template supplies
*structure and new keys*, never settings. Your values cannot be overwritten by a pull.
The live confs currently match their templates exactly — 349 and 140 variables — which is
what a working merge looks like.
### 🔎 Credential Discovery — `conf_populate.sh`
Reads settings out of the services actually running on this host and writes them into the
host conf: arr API keys from each `config.xml`, ports from real docker port bindings, paths
from real volume mounts, SABnzbd/slskd/qBittorrent credentials from their own config files.
Only fills **empty** fields unless `--overwrite`. Manual — it is not scheduled anywhere.
---
## ━━━ HOW A CHANGE REACHES EVERY NODE ━━━
```
You add a variable
└── edit Deployment/master.conf.template ← the versioned schema
git push
└── every node: git_pull_execute.sh
└── conf_upgrade.sh --template ... --target ... --backup
ADDED → new key appears with the template default
KEPT → every existing value untouched
REMOVED → deprecated keys dropped
```
**This is the rule that follows from it, and it is not optional:**
> Any conf variable change — add, remove, or rename — must update
> `Deployment/master.conf.template` and `Deployment/host.conf.template` **in the same pass**
> as the script change that uses it.
A script merged without its template entry works only on the machine it was written on.
Nothing errors; the variable is simply empty everywhere else, and the script takes whatever
branch an empty value leads to.
---
## ━━━ THE `HOSTN_` PLACEHOLDER ━━━
`host.conf.template` is written with a generic prefix — 149 occurrences of `HOSTN_`:
```bash
HOSTN_SONARR_URL=""
HOSTN_SONARR_API_KEY=""
```
`git_pull_execute.sh` substitutes the real identity before merging, so keys match the target:
```bash
sed "s/HOSTN_/${MY_ID}_/g; s/REMOTE_ID/${REMOTE_ID}/g" host.conf.template > "$TMPL_RESOLVED"
```
One template therefore serves every host. HOST1 merges it as `HOST1_*`, HOST2 as `HOST2_*`,
and a third node would work with no template change at all.
---
## ━━━ SCRIPTS IN THIS FOLDER ━━━
| Script | Role | When It Runs |
|--------|------|-------------|
| `conf_upgrade.sh` | Merge template into conf — structure forward, values preserved | Automatically, after every `git pull` |
| `conf_populate.sh` | Detect settings from running services into the host conf | Manually — onboarding, or after a key rotation |
| `migrate_data_layout.sh` | Move everything persisted into the rooted `data/` tree | Once per host, manually. Idempotent. |
### 📦 Data layout migration — `migrate_data_layout.sh`
`conf_upgrade.sh` adds keys the template has and the installation does not; it never rewrites a
value you already have. That is exactly what you want from it, and exactly why it cannot perform
a layout migration — the paths being moved are *existing* keys, so their values would keep
pointing at the old layout forever while the new directory variables sat beside them unused.
So this rewrites those values and moves the files to match. Both halves or neither.
```bash
Deployment/migrate_data_layout.sh --dry-run # always first
Deployment/migrate_data_layout.sh
```
It refuses to run while a job from *this* installation is active — scoped to the installation's
own path, because `pgrep` is system-wide and a box running both a production checkout and a
development clone will otherwise always look busy. `--force` overrides.
Each host runs it itself: `data/` is gitignored, so a restructure travels as code and conf while
the files stay where they are. See `data/README.md` for the resulting layout.
| Template | Role |
|----------|------|
| `master.conf.template` | Shared schema — thresholds, toggles, profiles, orchestrator job lists |
| `host.conf.template` | Per-host schema — credentials, paths, container names. `HOSTN_`-prefixed |
---
## ━━━ SAFEGUARDS WORTH KNOWING ━━━
**The install is atomic.** `conf_upgrade.sh` stages the merged conf beside the target and
installs it with a rename, never a copy. A `cp` truncates the live conf and writes into it —
and every watchdog sources `load_config.sh` on every run, so anything reading during that
window would get a partial conf with empty path variables. The temp file is staged in the
target's own directory deliberately: `/tmp` is rootfs while the confs are on flash, and a
cross-device `mv` degrades to copy-then-unlink, which is the exact torn write being avoided.
**Dry run needs no privilege, writing does.** `--dry-run` prints the full ADDED / REMOVED /
KEPT report and is useful to anyone. Installing over a conf under `/boot` requires root.
**`conf_upgrade.sh` sources nothing — deliberately.** No `load_config.sh`, no `common.sh`.
It is the tool that repairs the conf `load_config.sh` depends on, so it has to work when that
conf is broken, partial, or missing keys. That is also why it uses plain `echo` rather than
`log()`, and why it has no `acquire_lock` — concurrency is handled by the atomic rename
instead, and since the merge is idempotent, last-writer-wins is identical to running once.
**`conf_populate.sh` refuses to guess a container.** An ambiguous name prefix skips the field
rather than picking the first match. Writing the wrong container name is worse than writing
nothing: an empty field is visibly incomplete and gets fixed, a wrong one silently points the
whole stack at the wrong instance. This host has a live example — `authelia` prefix-matches
both `Authelia` (9091) and `Authelia-Secondary` (9092).
---
## ━━━ THE RECOVERY GAP ━━━
Confs are gitignored, **and so are their `.bak` files**. There is no versioned history to
revert to, and the single `.bak` slot is overwritten by whoever writes next:
| file | modified | its `.bak` |
|---|---|---|
| `master.conf` | Jul 28 18:52 | Jul 28 18:52 |
| `host1.conf` | Aug 1 21:00 | **Jul 3 17:46** |
A bad write to `master.conf` currently falls back to a file that may predate weeks of edits.
Worth knowing before hand-editing a conf, and the reason `--backup` exists on `conf_upgrade.sh`
at all.
Moving `Configurations/` into a private repo would make `git diff` and `git revert` the
recovery mechanism and give the history for free. That overlaps the existing GitHub-mirror
TODO, which is blocked on the same question — see `Notes_AI-Design.md`, where it also blocks
AI-assisted conf writes.
+130 -5
View File
@@ -10,7 +10,7 @@
# EMPTY fields, never overwrites existing values unless --overwrite is passed.
#
# After populating, pushes the updated conf to all partners via conf_sync.sh
# so they have the fresh keys in their /tmp/.cache/vv/d/ cache immediately.
# so they have the fresh keys in their /tmp/varaverk/conf/ cache immediately.
#
# ==============================================================================================
# AUTO-DETECTED FIELDS
@@ -55,6 +55,97 @@
# GITEA_CONTAINER fuzzy match from docker ps
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Resolve this host's conf from MY_ID
# 2. Per service, locate its container and read its own config file:
# arrs → config.xml via the container's /config volume mount
# SABnzbd → sabnzbd.ini
# slskd → config.yml
# qBit → qBittorrent.conf (plaintext WebUI credentials only)
# Emby/JF → docker port bindings and /transcode mount
# 3. Write each value only if the conf field is EMPTY, unless --overwrite
# 4. Push the updated conf to partners via conf_sync.sh, unless --no-push
#
# Container names are resolved by _resolve_container(): an exact name match wins, otherwise
# a prefix match must be unambiguous or the field is skipped.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Never Overwrite a Human's Value
# Only empty fields are populated. A value already in the conf was either set deliberately
# or populated from a service that has since changed — either way, the file wins over
# detection. --overwrite exists for deliberate re-sync after a key rotation.
#
# Read From the Service, Not From Assumption
# Every value comes out of the service's own config file or docker metadata — ports from
# actual port bindings, paths from actual volume mounts. Nothing is derived from naming
# convention where the real value is readable.
#
# Refuse to Guess a Container
# An ambiguous prefix skips the field rather than picking one. Writing the wrong container
# name is worse than writing nothing: an empty field is visibly incomplete and gets fixed,
# while a wrong one silently points the whole stack at the wrong instance. This host has a
# live example — "authelia" prefix-matches both Authelia and Authelia-Secondary.
#
# Push Immediately After Populating
# Fresh credentials go to partners right away rather than waiting for the next scheduled
# conf sync, so a partner is never authenticating with a key this host has already rotated.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Reads service config files owned by container users and writes the host conf.
#
# Host Detection
# detect_hosts() resolves MY_ID, which selects which host conf is written. Populating the
# wrong host's conf would write this machine's credentials into a partner's file.
#
# Conf Existence Guard
# Aborts if the resolved host conf does not exist, rather than creating a partial one.
#
# Empty-Field-Only Writes
# Existing values are preserved unless --overwrite is passed explicitly.
#
# Container Ambiguity Guard
# _resolve_container() refuses a prefix matching more than one container, warning with the
# full match list. Exact name matches short-circuit and are never treated as ambiguous.
#
# Missing Service Tolerance
# A service that is not installed on this host is skipped with a log line. Absence is a
# valid configuration, not a failure.
#
# Map Block Validation
# Associative-array entries are only inserted when the target map actually exists in the
# conf; a missing map warns and skips instead of appending an orphaned entry.
#
# Dry Run Support
# --dry-run reports every value it would write, truncated, and writes nothing.
#
# Credential Truncation in Output
# Detected secrets are printed truncated (first 8 chars) so a populate run can be pasted
# into a log or issue without leaking full API keys.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# Reads and writes: Configurations/<hostid>.conf and Configurations/master.conf
#
# master.conf
#
# DOCKER_APPDATA_BASE
# Fallback appdata root when a container exposes no /config mount to read from.
#
# Everything else this script touches is a field it populates rather than one it consumes —
# see AUTO-DETECTED FIELDS above for the full list.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
@@ -67,7 +158,11 @@
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
# Deployment/ sits one level under the repo root, not three. The old ../../../ resolved to
# /boot/config on a flash install and /mnt/user on an appdata one — outside the repo either
# way, so this sourced nothing and every helper below was "command not found". Onboard Step 11
# has been failing on that since it was written.
source "$SCRIPT_DIR/../load_config.sh"
SCRIPTS_ROOT="$SCRIPTS_DIR"
OVERWRITE=false
@@ -174,11 +269,41 @@ _set_conf_map_entry() {
(( UPDATED++ ))
}
# ── Helper: resolve a container name from a prefix, refusing ambiguity ────────
# grep -m1 silently returns whichever name docker happens to list first. On this very host
# "^authelia" matches both Authelia (9091, primary) and Authelia-Secondary (9092), and -m1
# picks the secondary — writing the wrong container into the conf that every other script
# then trusts. An exact match wins outright; otherwise a prefix match must be unambiguous.
# Same rule detect_hosts() applies to host identity: exactly one candidate, or none.
_resolve_container() {
local pattern="$1"
local all exact matches count
all=$(docker ps -a --format '{{.Names}}' 2>/dev/null)
[[ -z "$all" ]] && return 1
# Exact name match short-circuits — "Authelia" is not ambiguous with "Authelia-Secondary"
exact=$(printf '%s\n' "$all" | grep -ixm1 -- "$pattern")
[[ -n "$exact" ]] && { echo "$exact"; return 0; }
matches=$(printf '%s\n' "$all" | grep -i -- "^${pattern}")
count=$(printf '%s\n' "$matches" | grep -c .)
if [[ "$count" -gt 1 ]]; then
warn "Container prefix '${pattern}' is ambiguous — matches: $(printf '%s' "$matches" | tr '\n' ' ')"
warn " Refusing to guess. Set the container name manually in host*.conf."
return 1
fi
[[ "$count" -eq 1 ]] && { printf '%s\n' "$matches"; return 0; }
return 1
}
# ── Helper: find arr config dir via docker volume mount ───────────────────────
_arr_config_dir() {
local pattern="$1"
local container_name
container_name=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
container_name=$(_resolve_container "$pattern") || return 1
[[ -z "$container_name" ]] && return 1
local config_path
@@ -239,7 +364,7 @@ _set_conf_var "${MY_ID}_SSH_KEY" "/root/.ssh/${owner}_rsync_automation" "
for arr in radarr sonarr lidarr; do
arr_upper="${arr^^}"
arr_container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${arr}")
arr_container=$(_resolve_container "$arr")
config_dir=$(_arr_config_dir "$arr") || {
log "${arr_upper}: no running container found — skipping"
continue
@@ -341,7 +466,7 @@ qbit_dir=$(_arr_config_dir "qbittorrent") && {
transcode_dir=""
for pattern in "emby" "jellyfin"; do
container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
container=$(_resolve_container "$pattern") || continue
[[ -z "$container" ]] && continue
case "$pattern" in
+258 -28
View File
@@ -19,9 +19,104 @@
# associative arrays (declare -A).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Validate both --template and --target exist
# 2. Parse each into keys, handling scalars, indexed arrays and declare -A
# 3. Classify every key: ADDED (template only) / REMOVED (target only) / KEPT (both)
# 4. Emit the merged result — template structure, target values — to a temp file
# staged in the target's own directory
# 5. --dry-run stops here and prints the report
# 6. --backup copies the current target to .bak
# 7. Install by atomic rename over the target
#
# Called automatically by git_pull_execute.sh after every pull, for master.conf and this
# host's own host*.conf. The host template is HOSTN_-prefixed and the caller substitutes
# the real MY_ID before merging.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# The User's Value Always Wins
# For any key present in both files, the target's value is kept and the template's is
# discarded. The template supplies structure and new keys, never settings. This is what
# makes the upgrade safe to run unattended after every single pull.
#
# Structure Follows the Template
# Comments, ordering and blank lines come from the template, so an upgraded conf reads
# like the current version rather than accumulating layers of old formatting.
#
# Standalone by Design — Do Not Add load_config.sh
# This script sources nothing. It is the tool that repairs the conf that load_config.sh
# depends on, so it has to work when that conf is broken, partial, or missing keys.
# Sourcing load_config.sh here would make the repair tool fail in exactly the situation
# it exists for. That is also why it uses plain echo instead of log()/error(), and why
# there is no acquire_lock — common.sh is not available to it.
#
# Atomic Install, Never In-Place
# The merged conf is renamed over the target, not copied into it. Every watchdog sources
# load_config.sh on every run; a cp would truncate master.conf and write into it, and
# anything reading during that window gets a partial conf with empty path variables.
#
# Concurrency Handled by Atomicity, Not a Lock
# Two concurrent runs against the same target cannot corrupt it — each stages its own
# temp file and the rename is atomic, so the last writer simply wins. Since the merge is
# idempotent, that outcome is identical to running once.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# The Host Slot Must Be Declared, Never Inferred
# host.conf.template ships HOSTN_ placeholders; a live host conf uses HOST1_ / HOST2_. Key
# matching is literal, so merging the raw template against a real host conf classifies every
# existing key as deprecated — KEPT 0 — and the install drops every credential in the file.
# Verified on HOST1 2026-08-02: it would have removed HOST1_RADARR_API_KEY,
# HOST1_EMBY_API_KEY, HOST1_NPM_PASS and 137 others.
#
# --host-slot HOST<n> is how a caller says which slot the template is being resolved for, and
# it works for any slot — HOST1, HOST2, and whatever a third server would be. The slot is
# still never inferred from the target and applied silently: the caller declares it, and the
# target is read only to contradict a wrong answer. A declared slot that disagrees with the
# target's own keys aborts, because substituting for the wrong slot destroys the file just as
# thoroughly as not substituting at all. Without the flag, a template containing HOSTN is
# refused exactly as before.
#
# Both cases are substituted. HOSTN_ covers the key prefixes, bare HOSTN appears in section
# comments, and lowercase hostn is a real value — the hostn-appdata rsync profile keys. A
# substitution handling only HOSTN_ leaves a conf carrying a profile named hostn-appdata that
# nothing references.
#
# A Target Owning Two Slots Is Refused
# A host conf describes exactly one server. Finding both HOST1_ and HOST2_ key definitions in
# one target means it is not the file it claims to be, so the slot cross-check has nothing
# trustworthy to compare against and the run aborts rather than picking one.
#
# Total Mismatch Is Refused
# Keeping nothing from a populated conf is never a real upgrade; it means the two files do
# not describe the same thing. KEPT 0 with a non-empty REMOVED list aborts. This is the
# general net behind the HOSTN check — the failure mode is silent and total, so it fires in
# --dry-run as well, putting the warning in the report itself.
#
# Root Required to Write
# Installing over the target requires root. --dry-run deliberately does not, so the
# change report can be previewed by anyone.
#
# Atomic Rename
# The temp file is created in the target's own directory so the rename stays within one
# filesystem. On Unraid /tmp is rootfs while the confs live on flash, and a cross-device
# mv degrades to copy-then-unlink — precisely the torn write this avoids.
#
# Permissions Preserved
# mktemp creates 0600; the target's existing mode and owner are copied onto the temp file
# before it is installed, so a conf does not come back with different permissions.
#
# Temp File Cleanup
# An EXIT trap removes the staged file on any early exit, and is cleared once the rename
# has succeeded so the trap cannot delete the installed conf.
#
# Dry-run Mode
# --dry-run prints the full change report (ADDED / REMOVED / KEPT) then exits
# without writing anything. Always preview before applying to production confs.
@@ -34,10 +129,6 @@
# Both --template and --target are validated before any parsing begins.
# Missing files abort immediately with a clear error.
#
# Atomic Write
# Merged output is written to a tempfile first, then copied to the target.
# A partial write cannot corrupt the original.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
@@ -46,6 +137,9 @@
#
# --template <file> New version conf file (source of structure and defaults)
# --target <file> Existing user conf (source of real values — always preserved)
# --host-slot <HOSTn> Resolve HOSTN/hostn placeholders to this slot before merging.
# Required for host.conf.template; meaningless for master.conf.template,
# which has no placeholders. Must match the target's own slot.
# --dry-run Show what would change without writing
# --backup Write a .bak copy of target before modifying
#
@@ -62,6 +156,11 @@
# conf_upgrade.sh --template Deployment/master.conf.template --target Configurations/master.conf
# Apply the upgrade in-place with no backup.
#
# conf_upgrade.sh --template Deployment/host.conf.template --target Configurations/host1.conf \
# --host-slot HOST1 --dry-run
# Preview a host conf upgrade. HOSTN/hostn are resolved to HOST1/host1 first. Swap in HOST2
# and host2.conf for the other server — the template is the same file for every slot.
#
# ==============================================================================================
set -uo pipefail
@@ -72,11 +171,14 @@ TEMPLATE=""
TARGET=""
DRY_RUN=false
BACKUP=false
HOST_SLOT=""
_RESOLVED_TMPL="" # set only when --host-slot triggers a substitution; cleaned on exit
while [[ $# -gt 0 ]]; do
case "$1" in
--template) TEMPLATE="$2"; shift 2 ;;
--target) TARGET="$2"; shift 2 ;;
--host-slot) HOST_SLOT="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
--backup) BACKUP=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
@@ -88,6 +190,80 @@ done
[[ -f "$TEMPLATE" ]] || { echo "Error: template not found: $TEMPLATE" >&2; exit 1; }
[[ -f "$TARGET" ]] || { echo "Error: target not found: $TARGET" >&2; exit 1; }
# ── Host slot resolution ─────────────────────────────────────────────────────────────────────
#
# host.conf.template ships HOSTN_ placeholders; a live host conf uses HOST1_ / HOST2_. Key
# matching below is literal, so merging the raw template against a real host conf classifies
# EVERY existing key as deprecated and every template key as new — KEPT 0, and the install
# would drop every credential in the file.
#
# --host-slot is how a caller declares which slot the template is for. The slot is never
# inferred from the target and silently applied: the caller states it, and the target is used
# only to contradict a wrong answer. Substituting for the wrong slot is the same catastrophe as
# not substituting at all, so a declared slot that disagrees with the target is refused.
#
# Both cases matter. HOSTN_ covers the 159 key prefixes; bare HOSTN appears in section comments,
# and lowercase hostn is a real value — the hostn-appdata rsync profile keys. A substitution
# that only handles HOSTN_ leaves a live conf with a profile named hostn-appdata that nothing
# references, which is what the pull script did before this flag existed.
_target_slots=$(grep -oE '^[[:space:]]*HOST[0-9]+_' "$TARGET" 2>/dev/null \
| grep -oE 'HOST[0-9]+' | sort -u)
_target_slot=$(echo "$_target_slots" | head -1)
if [[ $(echo "$_target_slots" | grep -c .) -gt 1 ]]; then
echo "Error: '$TARGET' defines keys for more than one host slot:" >&2
echo " $(echo "$_target_slots" | tr '\n' ' ')" >&2
echo " A host conf owns exactly one slot. Refusing rather than picking one." >&2
exit 1
fi
if [[ -n "$HOST_SLOT" ]]; then
if ! [[ "$HOST_SLOT" =~ ^HOST[0-9]+$ ]]; then
echo "Error: --host-slot must be HOST<n> (e.g. HOST1, HOST2) — got '$HOST_SLOT'" >&2
exit 1
fi
if [[ -n "$_target_slot" && "$_target_slot" != "$HOST_SLOT" ]]; then
echo "Error: --host-slot says $HOST_SLOT but '$TARGET' defines ${_target_slot}_ keys." >&2
echo " Substituting for the wrong slot removes every key the target actually has," >&2
echo " credentials included. Refusing." >&2
exit 1
fi
if grep -q -e 'HOSTN' -e 'hostn' "$TEMPLATE" 2>/dev/null; then
_lower=$(echo "$HOST_SLOT" | tr '[:upper:]' '[:lower:]')
_RESOLVED_TMPL="$(mktemp)"
trap '[[ -n "${_RESOLVED_TMPL:-}" ]] && rm -f "$_RESOLVED_TMPL"' EXIT
sed -e "s/HOSTN/${HOST_SLOT}/g" -e "s/hostn/${_lower}/g" "$TEMPLATE" > "$_RESOLVED_TMPL"
TEMPLATE="$_RESOLVED_TMPL"
echo " Resolved HOSTN → ${HOST_SLOT} for this host"
fi
elif grep -q 'HOSTN' "$TEMPLATE" 2>/dev/null; then
# No slot declared and the template is still generic — the original refusal, unchanged.
if [[ -n "$_target_slot" ]]; then
_lower=$(echo "$_target_slot" | tr '[:upper:]' '[:lower:]')
echo "Error: template still contains HOSTN placeholders, but the target uses ${_target_slot}_." >&2
echo " Merging as-is would classify all ${_target_slot}_ keys as deprecated and remove" >&2
echo " them — including every credential. Declare the slot:" >&2
echo "" >&2
echo " $0 --template $TEMPLATE --target $TARGET --host-slot ${_target_slot} --dry-run" >&2
echo "" >&2
exit 1
fi
fi
# ── Block-parsing patterns ───────────────────────────────────────────────────────────────────
# All three walkers below share these. A single-line array — KEY=(a b c) — both opens and
# closes on one line. Matching only the opener leaves the walker inside a block it never
# leaves, so every key until the next standalone ")" becomes invisible: skipped by
# _collect_stats, dropped from HOST_MAP by _parse_target, and emitted from the template
# instead of the user's conf by _write_merged. _ARR_ONELINE_RE is what stops that.
_ARR_DECL_RE='^[[:space:]]*declare[[:space:]]+-[a-zA-Z]+[[:space:]]+([A-Z0-9_]+)[[:space:]]*=\('
_ARR_OPEN_RE='^[[:space:]]*([A-Z0-9_]+)[[:space:]]*=\('
_ARR_CLOSE_RE='^[[:space:]]*\)[[:space:]]*(#.*)?$'
# The [^#]* is deliberate: it forces the closing ")" to appear before any comment, so an
# opener like FOO=( # see note (here) is not mistaken for a complete single-line array.
_ARR_ONELINE_RE='=\([^#]*\)[[:space:]]*(#.*)?$'
_SCALAR_RE='^[[:space:]]*([A-Z0-9_]+)[[:space:]]*='
# ── Parse target → KEY → full definition block ───────────────────────────────────────────────
declare -A HOST_MAP # KEY → complete definition line(s) from user's conf
@@ -99,19 +275,23 @@ _parse_target() {
if [[ "$in_block" == true ]]; then
cur_block+="$line"$'\n'
# Closing ) — optional trailing whitespace and comment
if [[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]]; then
if [[ "$line" =~ $_ARR_CLOSE_RE ]]; then
HOST_MAP["$cur_key"]="$cur_block"
in_block=false; cur_key=""; cur_block=""
fi
else
# declare -A KEY=(
if [[ "$line" =~ ^[[:space:]]*declare[[:space:]]+-[a-zA-Z]+[[:space:]]+([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true; cur_block="$line"$'\n'
# KEY=(
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true; cur_block="$line"$'\n'
# declare -A KEY=( / KEY=(
if [[ "$line" =~ $_ARR_DECL_RE ]] || [[ "$line" =~ $_ARR_OPEN_RE ]]; then
cur_key="${BASH_REMATCH[1]}"
# KEY=(a b c) — opens and closes on one line, never enter block mode
if [[ "$line" =~ $_ARR_ONELINE_RE ]]; then
HOST_MAP["$cur_key"]="$line"$'\n'
cur_key=""
else
in_block=true; cur_block="$line"$'\n'
fi
# KEY=value (simple scalar)
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*= ]]; then
elif [[ "$line" =~ $_SCALAR_RE ]]; then
HOST_MAP["${BASH_REMATCH[1]}"]="$line"$'\n'
fi
fi
@@ -128,7 +308,7 @@ _collect_stats() {
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$in_block" == true ]]; then
if [[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]]; then
if [[ "$line" =~ $_ARR_CLOSE_RE ]]; then
in_block=false
TMPL_SEEN["$cur_key"]=1
if [[ -n "${HOST_MAP[$cur_key]+_}" ]]; then KEPT+=("$cur_key")
@@ -136,11 +316,17 @@ _collect_stats() {
cur_key=""
fi
else
if [[ "$line" =~ ^[[:space:]]*declare[[:space:]]+-[a-zA-Z]+[[:space:]]+([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*= ]]; then
if [[ "$line" =~ $_ARR_DECL_RE ]] || [[ "$line" =~ $_ARR_OPEN_RE ]]; then
cur_key="${BASH_REMATCH[1]}"
if [[ "$line" =~ $_ARR_ONELINE_RE ]]; then
TMPL_SEEN["$cur_key"]=1
if [[ -n "${HOST_MAP[$cur_key]+_}" ]]; then KEPT+=("$cur_key")
else ADDED+=("$cur_key"); fi
cur_key=""
else
in_block=true
fi
elif [[ "$line" =~ $_SCALAR_RE ]]; then
local k="${BASH_REMATCH[1]}"
TMPL_SEEN["$k"]=1
if [[ -n "${HOST_MAP[$k]+_}" ]]; then KEPT+=("$k")
@@ -163,18 +349,23 @@ _write_merged() {
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$in_block" == true ]]; then
cur_block+="$line"$'\n'
if [[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]]; then
if [[ "$line" =~ $_ARR_CLOSE_RE ]]; then
in_block=false
if [[ -n "${HOST_MAP[$cur_key]+_}" ]]; then printf '%s' "${HOST_MAP[$cur_key]}"
else printf '%s' "$cur_block"; fi
cur_key=""; cur_block=""
fi
else
if [[ "$line" =~ ^[[:space:]]*declare[[:space:]]+-[a-zA-Z]+[[:space:]]+([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true; cur_block="$line"$'\n'
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true; cur_block="$line"$'\n'
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*= ]]; then
if [[ "$line" =~ $_ARR_DECL_RE ]] || [[ "$line" =~ $_ARR_OPEN_RE ]]; then
cur_key="${BASH_REMATCH[1]}"
if [[ "$line" =~ $_ARR_ONELINE_RE ]]; then
if [[ -n "${HOST_MAP[$cur_key]+_}" ]]; then printf '%s' "${HOST_MAP[$cur_key]}"
else printf '%s\n' "$line"; fi
cur_key=""
else
in_block=true; cur_block="$line"$'\n'
fi
elif [[ "$line" =~ $_SCALAR_RE ]]; then
local k="${BASH_REMATCH[1]}"
if [[ -n "${HOST_MAP[$k]+_}" ]]; then printf '%s' "${HOST_MAP[$k]}"
else printf '%s\n' "$line"; fi
@@ -208,6 +399,24 @@ fi
echo " KEPT ${#KEPT[@]} existing vars — your values preserved"
# ── Guard: total mismatch ────────────────────────────────────────────────────────────────────
#
# Keeping nothing from a populated conf is never a real upgrade — it is the signature of the
# two files not describing the same thing (wrong template, wrong target, unsubstituted
# placeholders). The HOSTN check above catches the known cause; this catches the rest, because
# the failure mode is silent and total: every value in the file is replaced by a default.
# Deliberately fires in --dry-run too, so the report itself carries the warning.
if [[ ${#KEPT[@]} -eq 0 && ${#REMOVED[@]} -gt 0 ]]; then
echo "────────────────────────────────────────────────────────────────────────"
echo ""
echo "Error: refusing — this would keep NOTHING and remove all ${#REMOVED[@]} existing keys." >&2
echo " A genuine upgrade preserves values; keeping zero means the template and the" >&2
echo " target do not describe the same conf. Check that --template matches --target" >&2
echo " and that any HOSTN placeholders were substituted for this host's slot." >&2
echo "" >&2
exit 1
fi
if [[ ${#ADDED[@]} -eq 0 && ${#REMOVED[@]} -eq 0 ]]; then
echo " Already up to date — no changes needed."
echo "────────────────────────────────────────────────────────────────────────"
@@ -225,15 +434,36 @@ if [[ "$DRY_RUN" == true ]]; then
exit 0
fi
TMPOUT="$(mktemp)"
trap 'rm -f "$TMPOUT"' EXIT
# Checked here rather than at the top: --dry-run is a read-only report and is useful to
# anyone, but installing over a conf under /boot needs root. Plain echo because this script
# deliberately does not source common.sh — see the header.
if [[ "$EUID" -ne 0 ]]; then
echo "ERROR: writing '$TARGET' requires root (use --dry-run to preview as any user)" >&2
exit 1
fi
# Staged beside the target, not in /tmp. mv is only atomic within one filesystem, and on
# Unraid /tmp is rootfs while the confs live on flash — a cross-device mv silently degrades
# to copy-then-unlink, which is exactly the torn write this is meant to prevent.
TMPOUT="$(mktemp "${TARGET}.XXXXXX")"
trap 'rm -f "$TMPOUT"; [[ -n "${_RESOLVED_TMPL:-}" ]] && rm -f "$_RESOLVED_TMPL"' EXIT
# mktemp creates 0600; carry the target's existing mode/owner across so the installed conf
# does not come back with different permissions than it went in with.
chmod --reference="$TARGET" "$TMPOUT" 2>/dev/null || true
chown --reference="$TARGET" "$TMPOUT" 2>/dev/null || true
_write_merged > "$TMPOUT"
if [[ "$BACKUP" == true ]]; then
cp "$TARGET" "${TARGET}.bak"
cp -a "$TARGET" "${TARGET}.bak"
echo "Backup: ${TARGET}.bak"
fi
cp "$TMPOUT" "$TARGET"
# Atomic install. cp would truncate the live conf and write into it, leaving a window where
# anything sourcing load_config.sh reads a half-written master.conf — every watchdog does
# that constantly. A rename swaps the inode: readers get the old file or the new one.
mv -f "$TMPOUT" "$TARGET"
trap - EXIT
[[ -n "$_RESOLVED_TMPL" ]] && rm -f "$_RESOLVED_TMPL"
echo "Updated: $TARGET"
+114 -7
View File
@@ -67,6 +67,7 @@
# CERTIFICATE MONITOR domains checked for SSL expiry
# SMART HEALTH drives to skip in SMART monitoring
# ZFS REPORT pools to exclude from ZFS health report
# PCIe AER QUIET dead PCIe devices removed at array start to stop AER log spam
#
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
# RESOURCE MANAGER containers paused/stopped under memory pressure
@@ -94,7 +95,7 @@
# ━━━ Identity ━━━
# HOSTN hostname lives in master.conf (not a credential — safe for all servers).
# SSH key used for all server-to-server operations — rsync, failover, conf sync.
# SSH key used for all server-to-server operations — rsync, fallback, conf sync.
# Convention: /root/.ssh/<hostname-lowercase-no-unraid-prefix>_rsync_automation
# Must be in /root/.ssh/ and authorised in the partner's /root/.ssh/authorized_keys.
# Run Partnership/ssh_setup.sh to generate the key and copy it to the partner.
@@ -112,6 +113,11 @@
HOSTN_EMBY_CONTAINER="Emby"
HOSTN_EMBY_URL="http://localhost:8096"
HOSTN_EMBY_API_KEY="" # Emby Dashboard → API Keys → + New Key
HOSTN_EMBY_PUBLIC_URL="" # e.g. https://media.example.com/emby — browser-reachable base, used
# to build image URLs that render in the WebGUI. Deliberately separate
# from HOSTN_EMBY_URL: that one is for server-side API calls and is
# usually localhost, which resolves to the wrong machine in a browser.
# Empty = features that need an image quietly go without one.
# ━━━ Jellyfin ━━━
HOSTN_JELLYFIN_CONTAINER="Jellyfin"
@@ -123,6 +129,17 @@
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user
HOSTN_GITEA_API_TOKEN=""
# ━━━ Bug Reports ━━━
# Only used when BUG_REPORT_LOCAL_ENABLED=true in master.conf. Reports then go to this Gitea
# instead of GitHub — and stay there, so they do not reach the Varaverk maintainer.
#
# Reached locally or over Tailscale, so the token never crosses the public proxy and no Authelia
# bypass is needed. It is a credential and lives here rather than master.conf for that reason;
# it is never shipped, and the settings UI masks it.
HOSTN_BUG_REPORT_URL="" # e.g. http://gitea:3000 or the tailnet name
HOSTN_BUG_REPORT_REPO="" # owner/repo
HOSTN_BUG_REPORT_TOKEN="" # Gitea API token with issue-write on that repo
# ━━━ Notifications ━━━
# Discord webhook — leave blank to disable.
HOSTN_DISCORD_WEBHOOK=""
@@ -138,6 +155,23 @@
# "Authelia|9091"
)
# Shares rsynced to the mirror during onboard Step 1e, BEFORE the auth containers are created.
# This is the only rsync an onboard performs — media is never seeded here.
# Profile is inferred from the directory basename, so Critical-Data resolves to critical-data:
# a clean copy with the auth containers stopped on both sides. Do not point this at a share
# whose profile keeps databases running; a dirty copy of MariaDB or Redis is worse than none,
# because the container starts, reads Up, and restarts a dead database behind it.
HOSTN_PARTNERSHIP_PROVISION_SHARES=(
"/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile — the auth stack
)
# Containers that belong in "<PartnerShort>-Fallback" on the mirror rather than in a mirrored
# copy of this host's folder layout — the ones that exist there only to cover this host going
# dark. Everything else the onboard deploys is filed onto the same shelf it occupies here
# (Arrs Stack, Networking, Databases…), because it runs on the mirror continuously.
# Empty is the normal state: leave it empty unless a container is genuinely failover-only.
HOSTN_PARTNERSHIP_FALLBACK_ONLY=()
# XML templates pushed to mirror during onboard — auth stack.
# Dependencies (databases) must come before apps that depend on them.
HOSTN_PARTNERSHIP_AUTH_STACK=(
@@ -151,6 +185,11 @@
# "my-Radarr.xml"
)
# XML templates pushed to mirror during onboard — other services (not auth, not arr).
HOSTN_PARTNERSHIP_SERVICES_STACK=(
# "my-Lidarr.xml"
)
# Paths the partner should collect during the grace window after offboard.
HOSTN_PARTNERSHIP_MIRROR_BACKUPS=(
# "/mnt/user/appdata-Fallback/Partner-Emby"
@@ -219,7 +258,6 @@
# Format: "/path/to/share" or "/path/to/share|profile-name"
HOSTN_CRITICAL_SYNC_SHARES=(
# "/mnt/user/appdata-Fallback/Critical-Data|critical-fallback"
# "/mnt/user/Media_Server/Emby|emby-fallback"
)
# ━━━ Backup Verify ━━━
@@ -230,13 +268,11 @@
# ━━━ HOSTN Rsync Profile — hostn-appdata ━━━
# Host-specific appdata sync profile.
# Retry/sleep/container-delay omitted — this profile uses the master.conf defaults for all three.
PROFILE_RSYNC_OPTS[hostn-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[hostn-appdata]:-8000}"
PROFILE_BW_LIMIT[hostn-appdata]=8000
PROFILE_RETRY_COUNT[hostn-appdata]=3
PROFILE_SLEEP[hostn-appdata]=300
PROFILE_CRITICAL_CONTAINER_NAMES[hostn-appdata]=""
PROFILE_DELAYED_CONTAINERS[hostn-appdata]=""
PROFILE_CONTAINER_DELAY[hostn-appdata]=5
PROFILE_EXCLUDE_DIRS[hostn-appdata]="logs *.tmp"
# ==============================================================================================
@@ -326,14 +362,20 @@
# HTTP health check URLs — checked every cycle.
declare -A HOSTN_WATCHDOG_CONTAINER_URLS=(
# ["Emby"]="http://localhost:8096"
# ["Jellyfin"]="http://localhost:8095"
# ["NginxProxyManager"]="http://localhost:7818"
# ["Authelia"]="http://localhost:9091/api/health"
# ["Authelia-Secondary"]="http://localhost:9092/api/health"
# ["Lldap"]="http://localhost:17170"
)
# API-level health checks. Format: ["ContainerName"]="url|expected_json_key|expected_value"
# API-level health checks — catches HTTP-200-but-internally-frozen containers (DB lock,
# deadlocked thread, etc.) that a basic HTTP check above would miss. Pick an endpoint that
# forces a real DB round-trip — a lightweight status endpoint may stay 200 even while the
# rest of the app is locked up. Format: ["ContainerName"]="url|APIKey"
declare -A HOSTN_WATCHDOG_CONTAINER_API_CHECKS=(
# ["Emby"]="${HOSTN_EMBY_URL}/Users|${HOSTN_EMBY_API_KEY}"
# ["Jellyfin"]="${HOSTN_JELLYFIN_URL}/Users|${HOSTN_JELLYFIN_API_KEY}"
)
# Required containers — must always be running.
@@ -425,6 +467,11 @@
HOSTN_SONARR_URL="http://localhost:8989"
HOSTN_SONARR_API_KEY=""
HOSTN_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
HOSTN_SONARR_GENERAL_ROOT="" # rootFolderPath literal for the general root (e.g. "/tv") — target for reverse-kids-leak moves; leave blank to disable
HOSTN_SONARR_KIDS_ROOT="" # rootFolderPath literal, as reported by Sonarr API — leave blank if no dedicated kids root
HOSTN_SONARR_ANIME_ROOT="" # rootFolderPath literal, as reported by Sonarr API — leave blank if no dedicated anime root
HOSTN_SONARR_DOWNLOAD_DIR="" # host path of the completed-downloads folder Sonarr imports from (e.g. "/mnt/cache/Temp_Storage/SABnzbd/Completed/Tv Shows") — blank disables the download orphan cleaner for Sonarr
HOSTN_SONARR_DOWNLOAD_CONTAINER_DIR="" # same folder as Sonarr's container sees it (e.g. "/downloads/Completed/Tv Shows") — needed to trigger import scans on held folders
declare -A HOSTN_SONARR_PATH_MAP=(
# ["/tv"]="/mnt/user/Tv_Shows"
@@ -435,11 +482,37 @@
HOSTN_RADARR_API_KEY=""
HOSTN_TMDB_API_KEY=""
HOSTN_RADARR_MOVIES_ROOT="/mnt/user/Movies"
HOSTN_RADARR_GENERAL_ROOT="" # rootFolderPath literal for the general root (e.g. "/movies") — target for reverse-kids-leak moves; leave blank to disable
HOSTN_RADARR_KIDS_ROOT="" # rootFolderPath literal, as reported by Radarr API — leave blank if no dedicated kids root
HOSTN_RADARR_ANIME_ROOT="" # rootFolderPath literal, as reported by Radarr API — leave blank if no dedicated anime root
HOSTN_RADARR_DOWNLOAD_DIR="" # host path of the completed-downloads folder Radarr imports from (e.g. "/mnt/cache/Temp_Storage/SABnzbd/Completed/Movies") — blank disables the download orphan cleaner for Radarr
HOSTN_RADARR_DOWNLOAD_CONTAINER_DIR="" # same folder as Radarr's container sees it (e.g. "/downloads/Completed/Movies") — needed to trigger import scans on held folders
HOSTN_LIDARR_DOWNLOAD_DIR="" # host path of the completed-downloads folder Lidarr imports from (e.g. "/mnt/cache/Temp_Storage/SABnzbd/Completed/Music") — blank disables the download orphan cleaner for Lidarr
HOSTN_LIDARR_DOWNLOAD_CONTAINER_DIR="" # same folder as Lidarr's container sees it (e.g. "/downloads/Completed/Music") — needed to trigger import scans on held folders
declare -A HOSTN_RADARR_PATH_MAP=(
# ["/movies"]="/mnt/user/Movies"
)
# ━━━ Corruption Scan ━━━
# Container that has a working ffprobe binary AND mounts the same shares as the arrs'
# roots — check `docker inspect <container>` for its mounts before filling this in.
HOSTN_FFPROBE_CONTAINER=""
HOSTN_FFPROBE_BIN=""
declare -A HOSTN_FFPROBE_PATH_MAP=(
# ["/mnt/user/Tv_Shows"]="/ext-tv-shows"
# ["/mnt/user/Movies"]="/ext-movies"
# Prefixes should match what Sonarr/Radarr actually track (HOSTN_SONARR_PATH_MAP /
# HOSTN_RADARR_PATH_MAP) — extend coverage here as more shares get mounted into the
# ffprobe container, e.g.:
# ["/mnt/user/Kids_Tv_Shows"]="/ext-kids-tv"
# ["/mnt/user/Kids_Movies"]="/ext-kids-movies"
# ["/mnt/user/Anime_Shows"]="/ext-anime-shows"
# ["/mnt/user/Anime_Movies"]="/ext-anime-movies"
# ["/mnt/user/stand-up_comedy"]="/ext-standup"
)
# ━━━ Arr Recovery Toggles ━━━
HOSTN_LIDARR_RECOVERY=false
HOSTN_SONARR_RECOVERY=true
@@ -473,6 +546,19 @@
"sda" # boot USB — SMART not meaningful on flash drives
)
# ━━━ PCIe AER Quiet ━━━
# PCI addresses removed from the bus at array start so dead hardware stops spamming
# correctable AER errors into syslog. Full DDDD:BB:DD.F form — find them with:
# grep -o "from [0-9a-f:.]*" /var/log/syslog | sort | uniq -c | sort -rn
#
# Only devices bound to vfio-pci or to no driver at all are eligible. Anything with a
# live driver is refused, so a mistyped address cannot pull an HBA or NIC out from
# under a running system. Devices claimed by a running VM are refused too.
# Gated by PCIE_QUIET_ENABLED in master.conf. Empty list = no-op.
HOSTN_PCIE_QUIET_DEVICES=(
# "0000:03:00.0"
)
# ━━━ ZFS Report ━━━
HOSTN_ZFS_REPORT_IGNORE_POOLS=(
# "disk5"
@@ -532,8 +618,29 @@
HOSTN_LLDAP_USER="admin" # lldap admin username
HOSTN_LLDAP_PASS="" # lldap admin password
# ==============================================================================================
# ── Ollama / AI ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Per-host because only some nodes actually have a GPU. A node with an empty OLLAMA_URL is not
# an error — it falls through to the resolver and uses another node's Ollama over Tailscale.
# ━━━ Ollama ━━━
HOSTN_OLLAMA_URL="" # e.g. http://localhost:11434 — empty if no local Ollama
HOSTN_OLLAMA_GPU_UUID="" # pins Ollama to one card on multi-GPU hosts
HOSTN_OLLAMA_MODEL="hf.co/unsloth/Qwen3-14B-GGUF:IQ4_XS" # generation — must fully offload; see README-AI.md
# ━━━ Web search ━━━
# Per-host because one is an address on this network and the other is a credential. Only the
# General Chat profile can use these — it is the profile that cannot change anything, which is
# why it is the one allowed to look outside. Off until AI_WEB_SEARCH_ENABLED says otherwise.
HOSTN_DEGOOG_URL="" # e.g. http://localhost:4444 — self-hosted, no key, /api/search
HOSTN_SEARXNG_URL="" # e.g. http://localhost:8888 — needs format: [json] in its settings.yml
HOSTN_WEB_SEARCH_API_KEY="" # brave or tavily; unused when the provider is searxng
HOSTN_OLLAMA_EMBED_MODEL="nomic-embed-text" # embeddings — the generation model cannot embed
# ━━━ Authelia ━━━
HOSTN_AUTHELIA_CONFIG="/mnt/user/appdata/Authelia/configuration.yml"
HOSTN_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
HOSTN_AUTHELIA_CONTAINER="Authelia"
# ==============================================================================================
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
#!/bin/bash
# ==============================================================================================
# ============================== DATA LAYOUT MIGRATION =========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# One-time move of everything Varaverk persists into a single rooted tree under DATA_DIR.
#
# State_Files/ → data/state/
# data/*.db|.count|.tsv|.list → data/db/
# data/ai_* → data/ai/
# data/*_tracked_cache.json → data/cache/arr/
# data/*.log → data/logs/
# SCRIPTS_DIR/.cache/vv/d/ → data/cache/conf/
#
# ==============================================================================================
# WHY THIS EXISTS SEPARATELY FROM conf_upgrade
# ==============================================================================================
#
# conf_upgrade adds keys the template has and the installation does not; it never rewrites a
# value the operator already has, which is exactly the behaviour you want from it and exactly
# why it cannot perform this migration. The paths being moved are existing keys — STATE_DIR,
# BANDWIDTH_LOG, AI_INDEX_DB and two dozen more — so their values would keep pointing at the old
# layout forever while the new directory variables sat beside them unused.
#
# So this rewrites those values, then moves the files to match. Both halves, or neither: a conf
# pointing at a directory the data is not in is worse than not having started.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Two halves, in order: move the files, then rewrite the conf keys that point at them. Doing it
# the other way round would leave every path variable naming a location nothing had reached yet,
# and any script that ran in between would create the old layout again underneath the new one.
#
# Idempotent. A path already under DATA_DIR is left alone, so a re-run after a partial migration
# finishes the job rather than moving things twice or failing on what is already done.
#
# One-time by intent, not by a marker file. There is no "already migrated" flag — the check is
# whether each individual path is already where it belongs, which is also what makes an
# interrupted run safe to repeat.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Existing keys are rewritten, which is why conf_upgrade cannot do this.
# conf_upgrade adds keys the template has and the installation does not, and never rewrites a
# value the operator already holds — correct for it, and exactly why it is the wrong tool here.
# STATE_DIR, BANDWIDTH_LOG, AI_INDEX_DB and two dozen more are existing keys whose values must
# change, or they would go on naming the old layout forever while the new directory variables
# sat beside them unused.
#
# Move, never copy-and-hope.
# The data being relocated is the only copy — statistics, histories, the AI index, arr caches.
# Everything is moved and the source is gone afterwards, so there is no second location that
# might still be written to by something that missed the change.
#
# The conf rewrite is the last thing, and the riskiest thing.
# Until it happens the installation still works from the old layout. That ordering means an
# abort partway through leaves a system that runs, rather than one whose paths point at
# nothing.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Idempotent
# Every step tests before acting. A second run reports "already migrated" and changes nothing,
# which matters because the natural instinct after a partial failure is to run it again.
#
# Moves, never copies-and-deletes
# mv within one filesystem is atomic per file, so a reader either sees the file at the old
# path or the new one — never a half-written copy at both. Nothing is deleted; if a file
# cannot be moved it is reported and left exactly where it is.
#
# Conf is backed up before it is rewritten
# master.conf.bak-<stamp>, next to the original, same convention conf_upgrade uses.
#
# Refuses to run while the orchestrators might be writing
# A watchdog that sourced conf before the rewrite and writes state after the move would put a
# file back at the old path. The window is seconds and the damage is one stale file, but the
# check costs nothing and the failure is silent otherwise.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# This script reads conf to find the old locations and rewrites conf to record the new ones. It
# is the one script here whose purpose is to change these values rather than obey them.
#
# Read to locate what moves
# STATE_DIR, BANDWIDTH_LOG, AI_INDEX_DB, AI_MEMORY_FILE, AI_TOKEN_DB, ARR_CLEANUP_STATS,
# ARR_SYNC_BLOCKLIST, CORRUPTION_SCAN_STATE_FILE, LIDARR_CACHE_FILE, ZFS_REPORT_LOG and the
# rest of the per-script path keys — roughly two dozen in total.
#
# Written as the new roots
# DATA_DIR and the directories beneath it: DB_DIR, STATE_DIR, AI_DATA_DIR,
# CACHE_BACKUP_DIR, ARR_CACHE_BACKUP_DIR, CONF_CACHE_BACKUP_DIR, LOG_ARCHIVE_DIR.
#
# Every rewritten value is expressed as ${DB_DIR}/… rather than an absolute path, so a later
# storage-mode migration moves them again by changing one variable.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# migrate_data_layout.sh --dry-run Show what would move. Changes nothing. Do this first.
# migrate_data_layout.sh Perform the migration.
# migrate_data_layout.sh --force Skip the running-orchestrator check.
#
# ==============================================================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONF="$ROOT/Configurations/master.conf"
DRY_RUN=false
FORCE=false
for a in "$@"; do
case "$a" in
--dry-run) DRY_RUN=true ;;
--force) FORCE=true ;;
*) echo "Unknown argument: $a" >&2; exit 2 ;;
esac
done
[[ -f "$CONF" ]] || { echo "[FATAL] master.conf not found at $CONF" >&2; exit 1; }
# Resolve the roots the same way load_config.sh will after this runs.
SCRIPTS_DIR="$ROOT"
DATA_DIR="$(grep -m1 -E '^\s*DATA_DIR=' "$CONF" | cut -d'"' -f2)"
DATA_DIR="${DATA_DIR//\$\{SCRIPTS_DIR\}/$SCRIPTS_DIR}"
DATA_DIR="${DATA_DIR:-$ROOT/data}"
OLD_STATE="$ROOT/State_Files"
OLD_CONFCACHE="$ROOT/.cache/vv/d"
DB_DIR="$DATA_DIR/db"
STATE_DIR="$DATA_DIR/state"
AI_DATA_DIR="$DATA_DIR/ai"
CACHE_BACKUP_DIR="$DATA_DIR/cache"
ARR_CACHE_BACKUP_DIR="$CACHE_BACKUP_DIR/arr"
CONF_CACHE_BACKUP_DIR="$CACHE_BACKUP_DIR/conf"
LOG_ARCHIVE_DIR="$DATA_DIR/logs"
moved=0; skipped=0; failed=0
say() { printf ' %s\n' "$*"; }
step() { printf '\n━━━ %s ━━━\n' "$*"; }
# ── Guard: orchestrators mid-run ──────────────────────────────────────────────
# Scoped to THIS installation's path, not to the script names. pgrep is system-wide, and a box
# running both a production checkout and a development clone will always have one of them busy —
# matching on "Orchestrators/" alone made a dev migration abort because prod was mid-cycle, which
# is a process that cannot touch this tree's data at all. The full path is what distinguishes
# them, so that is what is matched.
if [[ "$FORCE" == false && "$DRY_RUN" == false ]]; then
running=$(pgrep -fa "$ROOT/(Orchestrators|Watchdogs|Media|Rsync)/" 2>/dev/null \
| grep -v "$$" | grep -v migrate_data_layout || true)
if [[ -n "$running" ]]; then
echo "[ABORT] A job from this installation is running — it may rewrite state mid-move:" >&2
echo "$running" >&2
echo "Wait for it to finish, or re-run with --force if you are sure." >&2
exit 1
fi
fi
# ── Move one path ─────────────────────────────────────────────────────────────
move() {
local src="$1" dstdir="$2" base
base="$(basename "$src")"
[[ -e "$src" ]] || return 0
if [[ -e "$dstdir/$base" ]]; then
say "skip $base — already at ${dstdir#$DATA_DIR/}/"
((skipped++)); return 0
fi
if [[ "$DRY_RUN" == true ]]; then
say "would $base${dstdir#$DATA_DIR/}/"
((moved++)); return 0
fi
mkdir -p "$dstdir" 2>/dev/null
if mv "$src" "$dstdir/$base" 2>/dev/null; then
say "moved $base${dstdir#$DATA_DIR/}/"
((moved++))
else
say "FAILED $base — left in place"
((failed++))
fi
}
# ── 1. Rewrite the conf values conf_upgrade cannot ────────────────────────────
step "Step 1: master.conf path values"
if grep -q 'STATE_DIR="\${DATA_DIR}/state"' "$CONF"; then
say "already migrated — no conf changes needed"
else
if [[ "$DRY_RUN" == false ]]; then
cp "$CONF" "${CONF}.bak-$(date +%Y%m%d%H%M%S)"
sed -i -E \
-e 's|^(\s*STATE_DIR=)".*"|\1"${DATA_DIR}/state"|' \
-e 's|^(\s*PERSISTENT_CONF_CACHE=)".*"|\1"${CACHE_BACKUP_DIR}/conf"|' \
"$CONF"
for v in ARR_SYNC_BLOCKLIST DOCKER_UPDATE_REBUILT_DAILY_FILE DOCKER_UPDATE_REBUILT_WEEKLY_FILE \
WATCHDOG_CONTAINER_RESTART_LOG TUNING_MONITOR_LOG LIDARR_TRACKED_COUNT_FILE \
LIDARR_RESCAN_DURATION_DB LIDARR_ART_MISS_CACHE LIDARR_DISCOVERY_HISTORY \
SONARR_DISCOVERY_HISTORY RADARR_DISCOVERY_HISTORY SONARR_TRACKED_COUNT_FILE \
CORRUPTION_SCAN_STATE_FILE CORRUPTION_SCAN_STRIKES_FILE RADARR_TRACKED_COUNT_FILE \
TRANSCODE_DAILY_LOG BANDWIDTH_LOG ARR_CLEANUP_STATS ARR_RECOVERY_STATS \
ARR_RECOVERY_FAILURE_COUNTS; do
sed -i -E "s|^(\s*${v}=\")\\\$\{?DATA_DIR\}?/|\1\${DB_DIR}/|" "$CONF"
done
for v in AI_INDEX_DB AI_MEMORY_FILE AI_TOKEN_DB; do
sed -i -E "s|^(\s*${v}=\")\\\$\{?DATA_DIR\}?/|\1\${AI_DATA_DIR}/|" "$CONF"
done
sed -i -E 's|^(\s*LIDARR_CACHE_FILE=")\$\{?DATA_DIR\}?/|\1${ARR_CACHE_BACKUP_DIR}/|' "$CONF"
sed -i -E 's|^(\s*ZFS_REPORT_LOG=")\$\{?DATA_DIR\}?/|\1${LOG_ARCHIVE_DIR}/|' "$CONF"
say "rewritten — backup kept beside it"
else
say "would rewrite STATE_DIR, PERSISTENT_CONF_CACHE and 25 file paths"
fi
fi
# ── 2. Build the tree ─────────────────────────────────────────────────────────
step "Step 2: directory tree"
for d in "$DB_DIR" "$STATE_DIR" "$AI_DATA_DIR" "$ARR_CACHE_BACKUP_DIR" "$LOG_ARCHIVE_DIR"; do
if [[ -d "$d" ]]; then say "exists ${d#$DATA_DIR/}"
elif [[ "$DRY_RUN" == true ]]; then say "would create ${d#$DATA_DIR/}"
else mkdir -p "$d" && say "created ${d#$DATA_DIR/}"
fi
done
# The conf cache carries partner credentials and keeps its restrictive mode.
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$CONF_CACHE_BACKUP_DIR" && chmod 700 "$CONF_CACHE_BACKUP_DIR"
say "created cache/conf (0700)"
fi
# ── 3. State files ────────────────────────────────────────────────────────────
step "Step 3: State_Files → data/state"
if [[ -d "$OLD_STATE" ]]; then
shopt -s nullglob dotglob
for f in "$OLD_STATE"/*; do move "$f" "$STATE_DIR"; done
shopt -u nullglob dotglob
if [[ "$DRY_RUN" == false && -d "$OLD_STATE" ]]; then
rmdir "$OLD_STATE" 2>/dev/null && say "removed empty State_Files/" \
|| say "State_Files/ not empty — left in place, inspect it"
fi
else
say "no State_Files/ — nothing to do"
fi
# ── 4. Sort the data root ─────────────────────────────────────────────────────
step "Step 4: sort data/ into subfolders"
classify() {
local f="$1" base; base="$(basename "$f")"
case "$base" in
ai_*) move "$f" "$AI_DATA_DIR" ;;
*_tracked_cache.json) move "$f" "$ARR_CACHE_BACKUP_DIR" ;;
*.log) move "$f" "$LOG_ARCHIVE_DIR" ;;
*.db|*.count|*.tsv|*.list|*.json) move "$f" "$DB_DIR" ;;
*) say "leave $base — unclassified, left in data/" ;;
esac
}
# Two passes, sidecars first. A SQLite database is three files, and the -wal holds committed
# transactions that have not been checkpointed into the .db yet. Move the .db first and any
# process that opens it during the gap sees a database with no write-ahead log, creates a fresh
# one at the old path, and everything still in the old -wal is lost when it is moved over the
# top. Sidecars ahead of their base closes that ordering: the worst case becomes a database
# opened without its log still sitting beside it, which SQLite handles.
shopt -s nullglob
for f in "$DATA_DIR"/*-wal "$DATA_DIR"/*-shm; do
[[ -d "$f" ]] && continue
classify "$f"
done
for f in "$DATA_DIR"/*; do
# Skip the destinations by name, not everything that happens to be a directory. ai_bugs/ and
# ai_chats/ are directories that belong under ai/, and an earlier version of this skipped
# every directory outright — which moved neither, silently, while the PHP layer had already
# been repointed at the new location. They were empty at the time; that was luck, not design.
case "$(basename "$f")" in
db|state|ai|cache|logs) continue ;;
*-wal|*-shm) continue ;;
esac
classify "$f"
done
shopt -u nullglob
# ── 5. Conf cache backup ──────────────────────────────────────────────────────
step "Step 5: conf cache backup"
if [[ -d "$OLD_CONFCACHE" ]]; then
shopt -s nullglob dotglob
for f in "$OLD_CONFCACHE"/*; do move "$f" "$CONF_CACHE_BACKUP_DIR"; done
shopt -u nullglob dotglob
[[ "$DRY_RUN" == false ]] && rmdir "$OLD_CONFCACHE" "$ROOT/.cache/vv" "$ROOT/.cache" 2>/dev/null
else
say "no $OLD_CONFCACHE — nothing to do"
fi
# ── Summary ───────────────────────────────────────────────────────────────────
printf '\n━━━━━ SUMMARY ━━━━━\n'
printf ' %-10s %s\n' "moved:" "$moved"
printf ' %-10s %s\n' "skipped:" "$skipped"
printf ' %-10s %s\n' "failed:" "$failed"
[[ "$DRY_RUN" == true ]] && printf '\n DRY RUN — nothing was changed.\n'
[[ "$failed" -gt 0 ]] && exit 1
exit 0
+75 -14
View File
@@ -94,20 +94,54 @@ HOST1_NETWORK_CONNECT_CONTAINERS=(
```bash
# master.conf
DAILY_CONTAINER_UPDATES=true # enable/disable daily image pull
DAILY_CONTAINER_UPDATES=true # enable/disable the daily image pull
# docker_daily_restart.sh still runs regardless
# update and restart are independent
WEEKLY_REMAINING_UPDATES=true # enable/disable weekly remainder pull + prune
# to disable: set false or remove from WEEKLY_MAINTENANCE_SCRIPTS
WEEKLY_CONTAINER_UPDATES=true # enable/disable the weekly image pull
# docker_weekly_restart.sh still runs regardless
MONTHLY_REMAINING_UPDATES=true # enable/disable the monthly remainder pull
# to disable: set false, or remove
# "docker_update.sh --remainder" from
# MONTHLY_MAINTENANCE_SCRIPTS
```
`docker_update.sh` in normal mode targets `DAILY_RESTART_CONTAINERS` — the same list
used by `docker_daily_restart.sh`. No second list to maintain.
There is one update script, `docker_update.sh`, with three modes. Each reuses the restart
list it pairs with, so there is no second list to maintain:
`docker_update_remaining.sh` derives its target list automatically:
all running containers minus `DAILY_RESTART_CONTAINERS` minus `WEEKLY_RESTART_CONTAINERS`.
Everything gets updated at least once per week with no explicit configuration.
| Mode | Targets | Runs |
|------|---------|------|
| *(default)* | `DAILY_RESTART_CONTAINERS` | Daily, before `docker_daily_restart.sh` |
| `--weekly` | `WEEKLY_RESTART_CONTAINERS` | Weekly, before `docker_weekly_restart.sh` |
| `--remainder` | derived, see below | Monthly, via `MONTHLY_MAINTENANCE_SCRIPTS` |
Remainder mode needs no configuration at all. It takes every **running** container and
subtracts:
```
DAILY_RESTART_CONTAINERS already updated daily
WEEKLY_RESTART_CONTAINERS already updated weekly
PROFILE_CRITICAL_CONTAINER_NAMES[emby] updated inline by the weekly sync window
PROFILE_CRITICAL_CONTAINER_NAMES[critical-data] updated inline by the weekly sync window
FALLBACK_<REMOTE>_TIER1..4 owned by the remote's update cycle
```
Stopped containers are never targeted in any mode — pulling for a stopped container adds
nothing, and it was most likely stopped deliberately.
The fallback exclusion is a correctness rule, not an optimisation. This server only runs
those containers during a fallback; the remote owns their version. If remainder updated them
independently and a handback then occurred, the remote's older image could meet data written
by the newer one.
**Ordering matters.** The update always runs *before* its matching restart so the restart
lands on the freshly pulled image. If a container's image actually changed, `docker_update.sh`
rebuilds it from its template (a plain `docker restart` reuses the image ID baked in at
creation time and would never pick up the new digest) and records it in
`DOCKER_UPDATE_REBUILT_*_FILE`. The restart script reads that file and skips those containers
rather than restarting them a second time — and discards the file as stale if it is older
than `DOCKER_UPDATE_REBUILT_STALE_HOURS`.
---
@@ -142,12 +176,22 @@ HOST1_NETWORK_CONNECT_CONTAINERS=()
# Shared — applies to both servers
# ── Container updates ──────────────────────────────────────────────────
DAILY_CONTAINER_UPDATES=true
WEEKLY_REMAINING_UPDATES=true
DAILY_CONTAINER_UPDATES=true # daily pull (DAILY_RESTART_CONTAINERS)
WEEKLY_CONTAINER_UPDATES=true # weekly pull (WEEKLY_RESTART_CONTAINERS)
MONTHLY_REMAINING_UPDATES=true # monthly pull (everything else)
# Handoff between update and restart — written by docker_update.sh,
# read by the matching restart script so it skips containers already
# rebuilt onto a new image this run.
DOCKER_UPDATE_REBUILT_DAILY_FILE
DOCKER_UPDATE_REBUILT_WEEKLY_FILE
DOCKER_UPDATE_REBUILT_STALE_HOURS=12 # older than this = discarded, restart all
# ── Retry behaviour (shared by restart scripts) ────────────────────────
RETRY_COUNT=3 # retry attempts before marking failed
SLEEP=5 # seconds between retry attempts
CONTAINER_DELAY # seconds between a dependency and its dependents
RESTART_VERIFY_WAIT=3 # settle time before verifying a restart stuck
# Watchdog thresholds (CPU, memory, HTTP, restart loop):
# → see Watchdogs/Manual-Watchdogs.md
@@ -211,7 +255,24 @@ All scripts support these standard flags:
summary block: identity, duration, counts, and a status line. Per-container detail only
appears with `--log`. Warnings and errors are always visible regardless of `--log`.
### `docker_update.sh --remainder`
Switches to remainder mode — updates all running containers not in the managed daily/weekly
lists. Called by `weekly_sync_maintenance.sh`. Can be run manually to sweep containers
that haven't been updated recently.
### Script-specific flags
| Script | Flag | What it does |
|--------|------|-------------|
| `docker_update.sh` | `--weekly` | Target `WEEKLY_RESTART_CONTAINERS`. Called by `weekly_sync_maintenance.sh` before the weekly restart. |
| `docker_update.sh` | `--remainder` | Target every running container not in the daily list, weekly list, emby/critical-data profiles, or fallback tiers. Called by `monthly_maintenance.sh`. Safe to run manually to sweep anything missed. |
| `media_cleaner.sh` *(Media/)* | `<anime\|media>` | Required positional profile — there is no default. |
`docker_update.sh` with no mode flag is normal mode: `DAILY_RESTART_CONTAINERS`, called by
`daily_sync_maintenance.sh` before the daily restart.
### Exit codes
| Code | Meaning |
|------|---------|
| `0` | Success, or nothing to do (empty list, disabled toggle, no running containers) |
| `1` | One or more containers failed — the summary names them |
`docker_update.sh` deliberately exits `0` even when pulls fail: a failed pull is not a reason
to abort the restart that follows, which simply proceeds on the existing image. The failure
is reported in the summary.
+64 -9
View File
@@ -178,12 +178,28 @@ windows so any downtime from restarts is absorbed by the window that's already h
---
### 🔄 Image Currency — `docker_update.sh` + `docker_update_remaining.sh`
### 🔄 Image Currency — `docker_update.sh`
Keeps all container images current without manual intervention. Daily updates for the
auth/proxy stack (the containers that restart daily anyway — no extra downtime). Weekly
remainder pass for everything else — derives the target list automatically from `docker ps`
minus what was already updated, so there is no second list to maintain.
One script, three modes — there is no separate remainder script.
| Mode | Targets | Runs |
|------|---------|------|
| *(default)* | `DAILY_RESTART_CONTAINERS` | Daily, **before** `docker_daily_restart.sh` |
| `--weekly` | `WEEKLY_RESTART_CONTAINERS` | Weekly, **before** `docker_weekly_restart.sh` |
| `--remainder` | Everything running that is in neither list | Monthly, via `monthly_maintenance.sh` |
The update always runs *before* its matching restart, so the restart lands on the freshly
pulled image. Reversing that order would restart onto the old image and leave the new one
sitting unused until the next window.
Each mode reuses the restart list it pairs with rather than maintaining its own — add a
container to `DAILY_RESTART_CONTAINERS` once and it gets both the restart and the image pull.
Remainder mode needs no list at all: it derives its targets from `docker ps` minus the daily
list, the weekly list, the emby/critical-data sync-window profiles, and the fallback tiers.
Fallback containers are deliberately excluded from remainder mode. This server only runs them
during a fallback; the remote owns their version. Updating them here would risk the remote's
older image meeting data written by a newer one after a handback.
---
@@ -229,7 +245,7 @@ full coordination model between all four watchdogs.
|--------|------|-------------|
| `docker_daily_restart.sh` | Nightly proactive restart of degradation-prone containers | 1am via `daily_sync_maintenance.sh` |
| `docker_weekly_restart.sh` | Weekly restart of less-critical services | 2:30am Sunday via `weekly_sync_maintenance.sh` |
| `docker_update.sh` | Container image updates — daily list + weekly remainder mode | Daily before restart; weekly remainder pass |
| `docker_update.sh` | Container image updates — three modes (default / `--weekly` / `--remainder`) | Daily and weekly before each restart; monthly for the remainder |
| `docker_network_connect.sh` | Network existence + container connection enforcement | Every array start |
| `docker_container_stop.sh` | Ordered container shutdown — graceful then forced | Called by `array_stopping.sh` |
| `downloaders_reset.sh` | Download client hygiene — slskd / SABnzbd / qBittorrent | Every 30min via `critical_sync_maintenance.sh` |
@@ -257,8 +273,13 @@ Daily maintenance window (1am):
Weekly maintenance window (2:30am Sunday):
weekly_sync_maintenance.sh
├── docker_weekly_restart.sh ──── restart less-critical services
└── docker_update.sh --remainder ─ update containers not in managed lists
├── docker_update.sh --weekly ─── pull latest (WEEKLY_RESTART_CONTAINERS)
└── docker_weekly_restart.sh ──── restart onto the fresh image
Monthly maintenance window:
monthly_maintenance.sh
├── docker_update.sh --remainder update everything not in the managed lists
└── Tools/docker_prune_images.sh --all
Critical maintenance window (every 30min):
critical_sync_maintenance.sh
@@ -266,5 +287,39 @@ Critical maintenance window (every 30min):
Array stopping:
array_stopping.sh
└── docker_container_stop.sh ──── ordered graceful shutdown
└── docker_container_stop.sh ──── ordered graceful shutdown, verified per container
```
---
## ━━━ SAFEGUARDS COMMON TO THIS FOLDER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Every script here talks to the Docker daemon, so they share the same protections. Each
script's own header documents its full set; these are the ones worth knowing folder-wide.
**Daemon health is checked, not assumed.** A hung daemon returns an empty container list,
which is indistinguishable from "no containers running". Without the check,
`docker_container_stop.sh` would report a clean shutdown that never happened, and
`docker_update.sh --remainder` would report "nothing to update" while doing nothing.
**Every docker call is timeout-wrapped.** A wedged daemon cannot stall a maintenance window
or hold a lock open. The one deliberate exception is `docker pull` — a large image
legitimately outlasts any sane timeout, and killing it mid-layer wastes the transfer.
**State is respected.** Running containers get restarted; stopped ones stay stopped. A
stopped container was almost certainly stopped on purpose, and none of these scripts has the
authority to overrule that.
**Restarts are verified, not assumed.** After each restart the container is re-checked once
it has had time to settle. A container that starts and immediately crashes is recorded as a
failure and notified — a restart that did not stick is never reported as success.
**Dependency ordering is shared with the watchdog.** Restarts follow
`HOST*_WATCHDOG_DEPENDENCIES`, with `CONTAINER_DELAY` between a dependency and its dependents,
so a dependent is never brought up while what it needs is still initialising.
**Locks prevent overlap.** Long windows can outlast their interval;
`downloaders_reset.sh` uses wait-mode because it runs every 30 minutes and the previous pass
may still be finishing.
---
+24 -7
View File
@@ -68,6 +68,15 @@
# Docker Presence Check
# Verifies docker binary exists before execution.
#
# Docker Daemon Check
# Verifies the daemon is responsive before enumerating containers. A hung
# daemon returns an empty container list, which would otherwise be read as
# "nothing to stop" and pass a shutdown that never happened.
#
# Host Detection
# detect_hosts() identifies which server is running the script and sets
# MY_ID / LOCAL_SERVER_NAME for logging and notifications.
#
# Lock Acquisition
# acquire_lock prevents overlapping runs (e.g. array_stopping firing twice).
#
@@ -140,10 +149,20 @@ fi
detect_hosts
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be stopped"
DOCKER_TIMEOUT=30
DOCKER_STOP_TIMEOUT=30 # grace period for SIGTERM before docker sends SIGKILL internally
# A hung daemon makes docker ps return nothing — indistinguishable from "no containers
# running", which would silently report a clean shutdown that never happened.
if ! timeout "$DOCKER_TIMEOUT" docker info >/dev/null 2>&1; then
error "Docker daemon not responding — cannot verify container shutdown"
notify "Container stop aborted on $(hostname) ($MY_ID) — Docker daemon not responding" \
"Docker Container Stop" "warning"
exit 1
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be stopped"
_RETRY_COUNT="${RETRY_COUNT:-3}"
log "$ICON_GEAR Config: retries=$_RETRY_COUNT sleep=${SLEEP:-5}s grace=${DOCKER_STOP_TIMEOUT}s cmd-timeout=${DOCKER_TIMEOUT}s"
@@ -152,7 +171,7 @@ log "$ICON_GEAR Config: retries=$_RETRY_COUNT sleep=${SLEEP:-5}s grace=${DOCKER_
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
mapfile -t RUNNING < <(docker ps --format '{{.Names}}' 2>/dev/null | sort)
mapfile -t RUNNING < <(timeout "$DOCKER_TIMEOUT" docker ps --format '{{.Names}}' 2>/dev/null | sort)
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
@@ -166,7 +185,7 @@ fi
# ==============================================================================================
# ━━━ Stop Containers ━━━
# ==============================================================================================
mapfile -t RUNNING < <(docker ps --format '{{.Names}}' 2>/dev/null | sort)
mapfile -t RUNNING < <(timeout "$DOCKER_TIMEOUT" docker ps --format '{{.Names}}' 2>/dev/null | sort)
echo ""
echo "━━━ $ICON_CONTAINERS Docker Container Stop — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
@@ -186,10 +205,8 @@ FAILED=()
for container in "${RUNNING[@]}"; do
[[ -z "$container" ]] && continue
local c_start
c_start=$(date +%s)
local c_image
c_image=$(docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null || echo "unknown")
c_image=$(timeout "$DOCKER_TIMEOUT" docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null || echo "unknown")
log "━━━ $ICON_CONTAINERS $container ($c_image) ━━━"
if [[ "$DRY_RUN" == true ]]; then
+104 -72
View File
@@ -14,6 +14,33 @@
# so there is no second list to maintain.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Containers are processed one at a time in dependency-safe order:
#
# 1. Build restart order
# → build_restart_order() sorts DAILY_RESTART_CONTAINERS by WATCHDOG_DEPENDENCIES
#
# 2. Skip anything docker_update.sh already rebuilt this run
# → a rebuild onto a new image already restarted it moments ago
#
# 3. Inspect container state
# missing → skip, not an error
# stopped → skip, stopped state is respected
# running → restart
#
# 4. Restart with retry
# → retry_docker wraps each attempt in a timeout, up to RETRY_COUNT
#
# 5. Verify it stayed running
# → verify_running() settles for RESTART_VERIFY_WAIT then checks State.Running
# → a container that crashes immediately is marked failed and notified
#
# 6. Prune dangling images
# → restarts swap onto new images, leaving the old ones dangling
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -37,6 +64,27 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Docker operations require root privileges.
#
# Docker Presence Check
# Verifies the docker binary exists before execution. Notifies on absence —
# a missing binary during the maintenance window is worth knowing about.
#
# Docker Daemon Check
# Verifies the daemon is responsive before any restart work. Every container
# would otherwise fail its inspect and be logged as an unknown-status failure,
# burying one daemon fault under a list of bogus per-container errors.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_DAILY_RESTART_CONTAINERS and HOST*_WATCHDOG_DEPENDENCIES to the
# correct host's values.
#
# Empty List Guard
# Exits cleanly with a pointer to the relevant conf key if
# DAILY_RESTART_CONTAINERS is unconfigured for this host.
#
# Dependency Ordering
# Containers restart in dependency-safe order using HOST*_WATCHDOG_DEPENDENCIES.
# CONTAINER_DELAY seconds between dependency restart and dependent restart gives
@@ -53,6 +101,11 @@
# cannot cause this script to hang indefinitely. Timed-out commands retry
# per RETRY_COUNT before marking as failed.
#
# Stale Rebuild-List Guard
# The rebuilt-container list written by docker_update.sh is discarded if older
# than DOCKER_UPDATE_REBUILT_STALE_HOURS. A stale file would otherwise suppress
# real restarts based on an update run that never happened today.
#
# Lock Acquisition
# acquire_lock() prevents concurrent execution if a previous run is still active.
#
@@ -87,6 +140,11 @@
# Gives the process time to initialise before verify_running samples the state.
# (default: 3)
#
# DOCKER_UPDATE_REBUILT_DAILY_FILE / DOCKER_UPDATE_REBUILT_STALE_HOURS
# List of containers docker_update.sh already rebuilt onto a new image this run —
# read here so they're not restarted a second time. Discarded as stale (and every
# container restarts normally) if older than DOCKER_UPDATE_REBUILT_STALE_HOURS.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
@@ -129,6 +187,14 @@ fi
# detect_hosts() sets MY_ID and aliases HOST*_DAILY_RESTART_CONTAINERS → DAILY_RESTART_CONTAINERS
detect_hosts
# Without this, a hung daemon fails every container's inspect individually and the summary
# reports a list of unknown-status failures instead of the one fault that caused them.
if ! timeout "$DOCKER_TIMEOUT" docker info >/dev/null 2>&1; then
error "Docker daemon not responding — skipping daily restart"
notify "Daily restart skipped on $(hostname) — Docker daemon not responding" "Docker Daily Restart" "warning"
exit 1
fi
if [[ ${#DAILY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "DAILY_RESTART_CONTAINERS is empty for $MY_ID — nothing to restart"
warn "Check HOST${MY_ID#HOST}_DAILY_RESTART_CONTAINERS in host*.conf"
@@ -159,71 +225,7 @@ fi
# docker_cmd, verify_running, retry_docker — defined in common.sh
# Builds a dependency-safe restart order from DAILY_RESTART_CONTAINERS.
# Containers that are dependencies of others restart first.
# Returns ordered list in ORDERED_RESTART array.
build_restart_order() {
ORDERED_RESTART=()
local remaining=("${DAILY_RESTART_CONTAINERS[@]}")
local placed=()
# First pass — add dependency containers that appear in our list
for container in "${remaining[@]}"; do
[[ -z "$container" ]] && continue
local is_dependency=false
# Check if this container is a dependency of any other in our list
for dep_string in "${WATCHDOG_DEPENDENCIES[@]}"; do
if [[ "$dep_string" == *"$container"* ]]; then
is_dependency=true
break
fi
done
# Also check associative array format
for dependent in "${!WATCHDOG_DEPENDENCIES[@]}"; do
if [[ "${WATCHDOG_DEPENDENCIES[$dependent]}" == *"$container"* ]]; then
is_dependency=true
break
fi
done
if [[ "$is_dependency" == true ]]; then
# Check not already placed
local already=false
for p in "${placed[@]}"; do [[ "$p" == "$container" ]] && already=true && break; done
if [[ "$already" == false ]]; then
ORDERED_RESTART+=("$container")
placed+=("$container")
fi
fi
done
# Second pass — add remaining containers (dependents and independents)
for container in "${remaining[@]}"; do
[[ -z "$container" ]] && continue
local already=false
for p in "${placed[@]}"; do [[ "$p" == "$container" ]] && already=true && break; done
if [[ "$already" == false ]]; then
ORDERED_RESTART+=("$container")
placed+=("$container")
fi
done
}
# Checks if a container is a dependent of the previously restarted container.
# If so, waits CONTAINER_DELAY before restarting to allow dependency to settle.
# Usage: check_dependency_delay "$container" "$last_restarted"
check_dependency_delay() {
local container="$1"
local last="$2"
[[ -z "$last" ]] && return
local deps="${WATCHDOG_DEPENDENCIES[$container]:-}"
if [[ -n "$deps" ]] && [[ "$deps" == *"$last"* ]]; then
echo " Waiting ${CONTAINER_DELAY}s — $container depends on $last..."
sleep "$CONTAINER_DELAY"
fi
}
# build_restart_order() / check_dependency_delay() — provided by common.sh
# ==============================================================================================
# ━━━ Daily Restart ━━━
@@ -239,17 +241,40 @@ START=$(date +%s)
FAILED=()
RESTARTED=()
SKIPPED=()
ALREADY_UPDATED=()
# Build dependency-safe restart order
build_restart_order
log "$ICON_GEAR Restart order: ${ORDERED_RESTART[*]}"
build_restart_order DAILY_RESTART_CONTAINERS
# ── Load containers docker_update.sh already rebuilt this run ───────────────────────────────────
# docker_update.sh's rebuild (stop+recreate onto a new image) already restarted anything whose
# image changed today — doing a plain restart on it again here is redundant. A file older than
# DOCKER_UPDATE_REBUILT_STALE_HOURS means docker_update.sh either didn't run today or this is way
# out of sync with it, so it's discarded rather than trusted, and every container restarts as
# normal — same as if the file had never existed.
declare -A ALREADY_REBUILT_MAP
if [[ -n "${DOCKER_UPDATE_REBUILT_DAILY_FILE:-}" && -f "$DOCKER_UPDATE_REBUILT_DAILY_FILE" ]]; then
_rebuilt_age=$(( $(date +%s) - $(stat -c %Y "$DOCKER_UPDATE_REBUILT_DAILY_FILE" 2>/dev/null || echo 0) ))
_rebuilt_stale_seconds=$(( ${DOCKER_UPDATE_REBUILT_STALE_HOURS:-12} * 3600 ))
if [[ "$_rebuilt_age" -gt "$_rebuilt_stale_seconds" ]]; then
warn "Rebuilt-container list is stale ($(( _rebuilt_age / 3600 ))h old) — discarding, restarting all"
rm -f "$DOCKER_UPDATE_REBUILT_DAILY_FILE"
else
while IFS= read -r _c; do
[[ -n "$_c" ]] && ALREADY_REBUILT_MAP["$_c"]=1
done < "$DOCKER_UPDATE_REBUILT_DAILY_FILE"
[[ "${#ALREADY_REBUILT_MAP[@]}" -gt 0 ]] && \
log "Already rebuilt today by docker_update.sh, skipping restart: ${!ALREADY_REBUILT_MAP[*]}"
fi
unset _rebuilt_age _rebuilt_stale_seconds
fi
LAST_RESTARTED=""
for container in "${ORDERED_RESTART[@]}"; do
[[ -z "$container" ]] && continue
c_start=$(date +%s)
c_image=$(docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null || echo "unknown")
c_image=$(timeout "$DOCKER_TIMEOUT" docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null || echo "unknown")
log "━━━ $ICON_CONTAINERS $container ($c_image) ━━━"
if ! timeout "$DOCKER_TIMEOUT" docker inspect "$container" &>/dev/null; then
@@ -261,6 +286,13 @@ for container in "${ORDERED_RESTART[@]}"; do
case "$STATUS" in
true)
if [[ -n "${ALREADY_REBUILT_MAP[$container]:-}" ]]; then
log "$ICON_RUNNING $container already rebuilt onto new image by docker_update.sh — skipping redundant restart"
ALREADY_UPDATED+=("$container")
LAST_RESTARTED="$container" # it did restart, just moments ago via the rebuild
continue
fi
log "$ICON_RUNNING $container is running — restarting..."
# Wait if this container depends on the last one restarted
@@ -271,7 +303,6 @@ for container in "${ORDERED_RESTART[@]}"; do
RESTARTED+=("$container")
else
if retry_docker docker restart "$container"; then
[[ "${RESTART_VERIFY_WAIT:-3}" -gt 0 ]] && sleep "${RESTART_VERIFY_WAIT:-3}"
if verify_running "$container"; then
echo "$ICON_STARTED $container restarted and running in $(format_duration $(( $(date +%s) - c_start )))"
RESTARTED+=("$container")
@@ -312,7 +343,7 @@ if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would prune dangling images"
PRUNED_SUMMARY="(dry run)"
else
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
PRUNED_OUTPUT=$(timeout "$DOCKER_TIMEOUT" docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
@@ -323,8 +354,9 @@ fi
echo "━━━━━ $ICON_SUMMARY DAILY RESTART SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $((END - START)))"
echo "$ICON_CONTAINERS Scope: ${#RESTARTED[@]} restarted, ${#SKIPPED[@]} skipped, ${#FAILED[@]} failed"
echo "$ICON_CONTAINERS Scope: ${#RESTARTED[@]} restarted, ${#ALREADY_UPDATED[@]} already updated, ${#SKIPPED[@]} skipped, ${#FAILED[@]} failed"
[[ ${#RESTARTED[@]} -gt 0 ]] && log "$ICON_STARTED Restarted: ${RESTARTED[*]}"
[[ ${#ALREADY_UPDATED[@]} -gt 0 ]] && log "$ICON_DONE Already updated (skipped): ${ALREADY_UPDATED[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_NOT_RUNNING Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
@@ -333,7 +365,7 @@ if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: ALL DONE ✅"
notify "Daily restart complete — ${#RESTARTED[@]} restarted, ${#SKIPPED[@]} skipped on $(hostname)" "Docker Daily Restart" "normal"
notify "Daily restart complete — ${#RESTARTED[@]} restarted, ${#ALREADY_UPDATED[@]} already updated, ${#SKIPPED[@]} skipped on $(hostname)" "Docker Daily Restart" "normal"
else
echo "$ICON_ERROR Status: ${#FAILED[@]} container(s) failed"
notify "Daily restart completed with errors on $(hostname) — failed: ${FAILED[*]}" "Docker Daily Restart" "warning"
+14 -6
View File
@@ -51,6 +51,12 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Docker network operations require root privileges.
#
# Docker Presence Check
# Verifies the docker binary exists before any network operations.
#
# Lock Acquisition
# Prevents concurrent execution via acquire_lock(). Safe to call from
# array start hooks or manually without risk of overlap.
@@ -71,8 +77,10 @@
# Warns and exits cleanly if NETWORK_CONNECT_NETWORKS or
# NETWORK_CONNECT_CONTAINERS are unconfigured.
#
# Command Validation
# Validates unRAID notify script before use.
# Missing Container Tolerance
# A configured container that does not exist yet warns and is skipped rather
# than failing the run. This script executes early at array start, before
# every container has necessarily been created.
#
# ==============================================================================================
# CONFIGURATION
@@ -130,13 +138,12 @@ fi
# detect_hosts() sets MY_ID and aliases HOST*_NETWORK_CONNECT_* arrays
detect_hosts
# Validate unRAID notify script — used for network creation alerts
# Docker daemon check — network operations are useless if daemon is hung
DOCKER_TIMEOUT=15
if ! timeout "$DOCKER_TIMEOUT" docker info >/dev/null 2>&1; then
error "Docker daemon not responding — cannot manage networks"
notify "docker_network_connect failed on $(hostname) — Docker daemon not responding" "Network Connect" "warning"
notify "docker_network_connect failed on $(hostname) — Docker daemon not responding" \
"Network Connect" "warning"
exit 1
fi
@@ -291,7 +298,8 @@ if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -gt 0 ]]; then
echo "$ICON_ERROR Status: SOME OPERATIONS FAILED"
notify "Docker network connect failed on $(hostname)${FAILED[*]}" "Network Connect" "warning"
notify "Docker network connect failed on $(hostname)${FAILED[*]}" \
"Network Connect" "warning"
elif [[ ${#NETWORKS_CREATED[@]} -gt 0 ]]; then
warn "Networks recreated — ${NETWORKS_CREATED[*]} — unRAID update likely wiped them"
else
+59 -8
View File
@@ -74,6 +74,28 @@
# Root Enforcement
# Docker operations require root privileges.
#
# Docker Presence Check
# Verifies the docker binary exists before execution.
#
# Docker Daemon Check
# Verifies the daemon is responsive before container discovery. Remainder mode
# derives its entire target list from docker ps — against a hung daemon that
# returns empty and the run silently reports "no containers to update".
#
# Timeout Protection
# Inspect, discovery and image-query commands are wrapped in a timeout so a
# hung daemon cannot stall the maintenance window. docker pull is deliberately
# NOT wrapped — a large image legitimately takes longer than any sane timeout,
# and killing it mid-layer wastes the transfer.
#
# Empty List Guards
# Each mode exits cleanly with a pointer to the relevant conf key when its
# container list is unconfigured for this host.
#
# Rebuild Failure Fallback
# A container that fails to rebuild is excluded from the rebuilt-list handoff
# file, so the follow-up restart script still gives it a normal restart pass.
#
# DAILY_CONTAINER_UPDATES / WEEKLY_CONTAINER_UPDATES Toggles
# Each mode exits cleanly when disabled. Restart scripts run regardless —
# update and restart are independent operations.
@@ -106,6 +128,13 @@
# MONTHLY_REMAINING_UPDATES
# Enable or disable remainder mode. (default: true)
#
# DOCKER_UPDATE_REBUILT_DAILY_FILE / DOCKER_UPDATE_REBUILT_WEEKLY_FILE
# Written after each normal/weekly run with the containers actually rebuilt
# this pass — read by docker_daily_restart.sh / docker_weekly_restart.sh so
# they skip restarting a container a second time right after this script
# already rebuilt it onto the new image. Not written in remainder mode
# (no follow-up restart script exists for it).
#
# PROFILE_CRITICAL_CONTAINER_NAMES[emby|critical-data]
# Container names for emby and critical-data profiles — excluded from
# remainder mode (already updated by the weekly sync window)
@@ -185,6 +214,14 @@ fi
detect_hosts
# Remainder mode builds its whole target list from docker ps — a hung daemon returns
# empty and the run would report "no containers to update" instead of failing.
if ! timeout "$DOCKER_TIMEOUT" docker info >/dev/null 2>&1; then
error "Docker daemon not responding — skipping image updates"
notify "Docker update skipped on $(hostname) — Docker daemon not responding" "Docker Update" "warning"
exit 1
fi
# ==============================================================================================
# ━━━ Container Discovery ━━━
# ==============================================================================================
@@ -227,7 +264,7 @@ if [[ "$REMAINDER_MODE" == true ]]; then
done
unset _tier _tier_var _tier_arr _c
mapfile -t _all_running < <(docker ps --format '{{.Names}}' | sort)
mapfile -t _all_running < <(timeout "$DOCKER_TIMEOUT" docker ps --format '{{.Names}}' | sort)
TARGET_CONTAINERS=()
for _c in "${_all_running[@]}"; do
[[ -z "${_exclude[$_c]+x}" ]] && TARGET_CONTAINERS+=("$_c")
@@ -321,13 +358,13 @@ for container in "${TARGET_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
log "━━━ $ICON_CONTAINERS $container ━━━"
if ! docker inspect "$container" &>/dev/null; then
if ! timeout "$DOCKER_TIMEOUT" docker inspect "$container" &>/dev/null; then
warn "$container — not found, skipping"
SKIPPED+=("$container")
continue
fi
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
IMAGE=$(timeout "$DOCKER_TIMEOUT" docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
warn "$container — could not determine image, skipping"
SKIPPED+=("$container")
@@ -345,8 +382,8 @@ for container in "${TARGET_CONTAINERS[@]}"; do
# Capture the image ID the container is currently running on, and the
# image ID :latest points to before the pull. After pulling, we rebuild if
# either a new digest landed OR the container is behind what :latest is now.
CONTAINER_IMAGE_ID=$(docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
OLD_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
CONTAINER_IMAGE_ID=$(timeout "$DOCKER_TIMEOUT" docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
OLD_ID=$(timeout "$DOCKER_TIMEOUT" docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "$ICON_SYNC Pulling $IMAGE..."
if [[ "$ENABLE_LOGGING" == "true" ]]; then
@@ -356,7 +393,7 @@ for container in "${TARGET_CONTAINERS[@]}"; do
docker pull "$IMAGE" >/dev/null 2>&1
_pull_rc=$?
fi
NEW_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
NEW_ID=$(timeout "$DOCKER_TIMEOUT" docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ $_pull_rc -eq 0 ]]; then
_pull_new=$( [[ -n "$OLD_ID" && "$OLD_ID" != "$NEW_ID" ]] && echo true || echo false)
@@ -406,6 +443,20 @@ if [[ ${#UPDATED[@]} -gt 0 ]]; then
done
fi
# Record successfully-rebuilt containers so the follow-up restart script (daily/weekly only —
# remainder has no follow-up) can skip them instead of restarting an already-fresh container a
# second time. Deliberately excludes REBUILD_FAILED — those still need the restart script's
# normal pass as a fallback, exactly as the error message above promises. Written even when
# REBUILT is empty, so a stale file from a previous run doesn't linger and get misread later.
if [[ "$DRY_RUN" == false && "$REMAINDER_MODE" != true ]]; then
_rebuilt_file="$DOCKER_UPDATE_REBUILT_DAILY_FILE"
[[ "$WEEKLY_MODE" == true ]] && _rebuilt_file="$DOCKER_UPDATE_REBUILT_WEEKLY_FILE"
if [[ -n "$_rebuilt_file" ]]; then
printf '%s\n' "${REBUILT[@]}" > "$_rebuilt_file" 2>/dev/null
fi
unset _rebuilt_file
fi
# ── Remove old images ────────────────────────────────────────────────────────
# Explicitly rmi by the IDs captured before each pull. Tagged images are never
# caught by dangling-only prune, so this is the only reliable cleanup path.
@@ -415,9 +466,9 @@ if [[ "$DRY_RUN" == true ]]; then
PRUNED_SUMMARY="(dry run)"
else
for _old_id in "${OLD_IMAGE_IDS[@]}"; do
docker rmi "$_old_id" >/dev/null 2>&1 || true
timeout "$DOCKER_TIMEOUT" docker rmi "$_old_id" >/dev/null 2>&1 || true
done
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
PRUNED_OUTPUT=$(timeout "$DOCKER_TIMEOUT" docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
+106 -62
View File
@@ -16,6 +16,33 @@
# stopped → leave, missing → skip. Container state is always respected.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Identical to docker_daily_restart.sh, against WEEKLY_RESTART_CONTAINERS:
#
# 1. Build restart order
# → build_restart_order() sorts WEEKLY_RESTART_CONTAINERS by WATCHDOG_DEPENDENCIES
#
# 2. Skip anything docker_update.sh --weekly already rebuilt this run
# → a rebuild onto a new image already restarted it moments ago
#
# 3. Inspect container state
# missing → skip, not an error
# stopped → skip, stopped state is respected
# running → restart
#
# 4. Restart with retry
# → retry_docker wraps each attempt in a timeout, up to RETRY_COUNT
#
# 5. Verify it stayed running
# → verify_running() settles for RESTART_VERIFY_WAIT then checks State.Running
# → a container that crashes immediately is marked failed and notified
#
# 6. Prune dangling images
# → restarts swap onto new images, leaving the old ones dangling
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -39,6 +66,26 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Docker operations require root privileges.
#
# Docker Presence Check
# Verifies the docker binary exists before execution. Notifies on absence.
#
# Docker Daemon Check
# Verifies the daemon is responsive before any restart work. Every container
# would otherwise fail its inspect and be logged as an unknown-status failure,
# burying one daemon fault under a list of bogus per-container errors.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_WEEKLY_RESTART_CONTAINERS and HOST*_WATCHDOG_DEPENDENCIES to the
# correct host's values.
#
# Empty List Guard
# Exits cleanly with a pointer to the relevant conf key if
# WEEKLY_RESTART_CONTAINERS is unconfigured for this host.
#
# Dependency Ordering
# Containers restart in dependency-safe order using HOST*_WATCHDOG_DEPENDENCIES.
# CONTAINER_DELAY seconds between dependency restart and dependent restart.
@@ -51,10 +98,10 @@
# All docker commands wrapped in a 30 second timeout. A hung Docker daemon
# cannot cause this script to hang indefinitely.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_WEEKLY_RESTART_CONTAINERS and HOST*_WATCHDOG_DEPENDENCIES to the
# correct host's values.
# Stale Rebuild-List Guard
# The rebuilt-container list written by docker_update.sh --weekly is discarded
# if older than DOCKER_UPDATE_REBUILT_STALE_HOURS. A stale file would otherwise
# suppress real restarts based on an update run that never happened this week.
#
# Lock Acquisition
# acquire_lock() prevents concurrent execution.
@@ -84,6 +131,17 @@
# CONTAINER_DELAY
# Seconds to wait after restarting a dependency before starting its dependents
#
# RESTART_VERIFY_WAIT
# Seconds verify_running() waits after docker restart before checking the
# container is running. Gives the process time to initialise before the
# state is sampled. (default: 3)
#
# DOCKER_UPDATE_REBUILT_WEEKLY_FILE / DOCKER_UPDATE_REBUILT_STALE_HOURS
# List of containers docker_update.sh --weekly already rebuilt onto a new image
# this run — read here so they're not restarted a second time. Discarded as
# stale (and every container restarts normally) if older than
# DOCKER_UPDATE_REBUILT_STALE_HOURS.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
@@ -126,6 +184,14 @@ fi
# detect_hosts() sets MY_ID and aliases HOST*_WEEKLY_RESTART_CONTAINERS → WEEKLY_RESTART_CONTAINERS
detect_hosts
# Without this, a hung daemon fails every container's inspect individually and the summary
# reports a list of unknown-status failures instead of the one fault that caused them.
if ! timeout "$DOCKER_TIMEOUT" docker info >/dev/null 2>&1; then
error "Docker daemon not responding — skipping weekly restart"
notify "Weekly restart skipped on $(hostname) — Docker daemon not responding" "Docker Weekly Restart" "warning"
exit 1
fi
if [[ ${#WEEKLY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "WEEKLY_RESTART_CONTAINERS is empty for $MY_ID — nothing to restart"
warn "Check HOST*_WEEKLY_RESTART_CONTAINERS in host*.conf"
@@ -158,58 +224,7 @@ fi
# docker_cmd, retry_docker, verify_running — defined in common.sh
# Builds a dependency-safe restart order from WEEKLY_RESTART_CONTAINERS.
# Containers that are dependencies of others restart first.
build_restart_order() {
ORDERED_RESTART=()
local remaining=("${WEEKLY_RESTART_CONTAINERS[@]}")
local placed=()
# First pass — add dependency containers that appear in our list
for container in "${remaining[@]}"; do
[[ -z "$container" ]] && continue
local is_dependency=false
for dependent in "${!WATCHDOG_DEPENDENCIES[@]}"; do
if [[ "${WATCHDOG_DEPENDENCIES[$dependent]}" == *"$container"* ]]; then
is_dependency=true
break
fi
done
if [[ "$is_dependency" == true ]]; then
local already=false
for p in "${placed[@]}"; do [[ "$p" == "$container" ]] && already=true && break; done
if [[ "$already" == false ]]; then
ORDERED_RESTART+=("$container")
placed+=("$container")
fi
fi
done
# Second pass — add remaining containers (dependents and independents)
for container in "${remaining[@]}"; do
[[ -z "$container" ]] && continue
local already=false
for p in "${placed[@]}"; do [[ "$p" == "$container" ]] && already=true && break; done
if [[ "$already" == false ]]; then
ORDERED_RESTART+=("$container")
placed+=("$container")
fi
done
log "Restart order: ${ORDERED_RESTART[*]}"
}
# Waits CONTAINER_DELAY if this container depends on the last restarted one.
check_dependency_delay() {
local container="$1"
local last="$2"
[[ -z "$last" ]] && return
local deps="${WATCHDOG_DEPENDENCIES[$container]:-}"
if [[ -n "$deps" ]] && [[ "$deps" == *"$last"* ]]; then
log "Waiting ${CONTAINER_DELAY}s — $container depends on $last..."
sleep "$CONTAINER_DELAY"
fi
}
# build_restart_order() / check_dependency_delay() — provided by common.sh
# ==============================================================================================
# ━━━ Weekly Restart ━━━
@@ -224,18 +239,39 @@ START=$(date +%s)
FAILED=()
RESTARTED=()
SKIPPED=()
ALREADY_UPDATED=()
# Build dependency-safe restart order
build_restart_order
build_restart_order WEEKLY_RESTART_CONTAINERS
# ── Load containers docker_update.sh --weekly already rebuilt this run ──────────────────────────
# Same reasoning as docker_daily_restart.sh: a container docker_update.sh already rebuilt onto a
# new image doesn't need a plain restart right after. A file older than
# DOCKER_UPDATE_REBUILT_STALE_HOURS is discarded as untrustworthy rather than trusted, and every
# container restarts as normal.
declare -A ALREADY_REBUILT_MAP
if [[ -n "${DOCKER_UPDATE_REBUILT_WEEKLY_FILE:-}" && -f "$DOCKER_UPDATE_REBUILT_WEEKLY_FILE" ]]; then
_rebuilt_age=$(( $(date +%s) - $(stat -c %Y "$DOCKER_UPDATE_REBUILT_WEEKLY_FILE" 2>/dev/null || echo 0) ))
_rebuilt_stale_seconds=$(( ${DOCKER_UPDATE_REBUILT_STALE_HOURS:-12} * 3600 ))
if [[ "$_rebuilt_age" -gt "$_rebuilt_stale_seconds" ]]; then
warn "Rebuilt-container list is stale ($(( _rebuilt_age / 3600 ))h old) — discarding, restarting all"
rm -f "$DOCKER_UPDATE_REBUILT_WEEKLY_FILE"
else
while IFS= read -r _c; do
[[ -n "$_c" ]] && ALREADY_REBUILT_MAP["$_c"]=1
done < "$DOCKER_UPDATE_REBUILT_WEEKLY_FILE"
[[ "${#ALREADY_REBUILT_MAP[@]}" -gt 0 ]] && \
log "Already rebuilt today by docker_update.sh, skipping restart: ${!ALREADY_REBUILT_MAP[*]}"
fi
unset _rebuilt_age _rebuilt_stale_seconds
fi
LAST_RESTARTED=""
for container in "${ORDERED_RESTART[@]}"; do
[[ -z "$container" ]] && continue
local c_start
c_start=$(date +%s)
local c_image
c_image=$(docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null || echo "unknown")
c_image=$(timeout "$DOCKER_TIMEOUT" docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null || echo "unknown")
log "━━━ $ICON_CONTAINERS $container ($c_image) ━━━"
if ! timeout "$DOCKER_TIMEOUT" docker inspect "$container" &>/dev/null; then
@@ -247,6 +283,13 @@ for container in "${ORDERED_RESTART[@]}"; do
case "$STATUS" in
true)
if [[ -n "${ALREADY_REBUILT_MAP[$container]:-}" ]]; then
log "$ICON_RUNNING $container already rebuilt onto new image by docker_update.sh — skipping redundant restart"
ALREADY_UPDATED+=("$container")
LAST_RESTARTED="$container" # it did restart, just moments ago via the rebuild
continue
fi
log "$ICON_RUNNING $container is running — restarting..."
# Wait if this container depends on the last one restarted
@@ -298,7 +341,7 @@ if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would prune dangling images"
PRUNED_SUMMARY="(dry run)"
else
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
PRUNED_OUTPUT=$(timeout "$DOCKER_TIMEOUT" docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
@@ -313,6 +356,7 @@ if [[ ${#RESTARTED[@]} -gt 0 ]]; then
echo "$ICON_STARTED Restarted: ${#RESTARTED[@]}"
log " Names: ${RESTARTED[*]}"
fi
[[ ${#ALREADY_UPDATED[@]} -gt 0 ]] && log "$ICON_DONE Already updated (skipped): ${ALREADY_UPDATED[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_NOT_RUNNING Skipped: ${SKIPPED[*]} (were stopped)"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
@@ -321,7 +365,7 @@ if [[ "$DRY_RUN" == true ]]; then
echo "$ICON_WARN Status: DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: $ICON_SUCCESS ALL DONE"
notify "Weekly restart complete — ${#RESTARTED[@]} restarted, ${#SKIPPED[@]} skipped (stopped) on $(hostname)" "Docker Weekly Restart" "normal"
notify "Weekly restart complete — ${#RESTARTED[@]} restarted, ${#ALREADY_UPDATED[@]} already updated, ${#SKIPPED[@]} skipped (stopped) on $(hostname)" "Docker Weekly Restart" "normal"
else
echo "$ICON_ERROR Status: $ICON_ERROR ${#FAILED[@]} container(s) failed"
notify "Weekly restart completed with errors on $(hostname) — failed: ${FAILED[*]}" "Docker Weekly Restart" "warning"
+48 -11
View File
@@ -61,6 +61,15 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Failed-import purging deletes directories owned by container users.
#
# Dependency Check
# Verifies curl and jq exist before any API work. jq backs the slskd connection
# probe — without it the probe returns false forever, the script burns its full
# 60 second reconnect wait, then skips every slskd section as "disconnected".
# A missing dependency is reported as itself rather than as a phantom outage.
#
# Active Transfer Protection
# slskd: skips users with InProgress or Queued transfers before any removal.
# SABnzbd: age threshold enforced before deletion.
@@ -70,6 +79,19 @@
# Each section validates its downloader URL before API calls. Missing or
# unreachable downloaders skip without affecting other sections.
#
# No Downloaders Guard
# Exits cleanly when none of SLSKD_URL, SABNZBD_URL or QBIT_URL are set for
# this host — nothing configured is not an error.
#
# Timeout Protection
# Every curl carries --max-time. An unresponsive downloader cannot stall the
# 30 minute maintenance cycle or overlap the next run.
#
# Deletion Scope Limit
# qBittorrent removals pass deleteFiles=false — the torrent record is dropped
# but files on disk are left for the arrs to manage. This script never deletes
# media.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# all HOST*_SLSKD_*, HOST*_SABNZBD_*, and HOST*_QBIT_* vars to the correct
@@ -140,6 +162,17 @@ fi
# Lock first — wait mode since this runs every 30min and previous may still be finishing
acquire_lock "wait"
# jq backs the slskd connection probe. Missing, the probe never returns true and slskd
# looks permanently disconnected — a 60s wait followed by silently skipped sections.
for _dep in curl jq; do
if ! command -v "$_dep" &>/dev/null; then
error "$_dep not found — required for downloader API calls"
notify "Downloaders reset failed on $(hostname)$_dep not installed" "Downloaders Reset" "warning"
exit 1
fi
done
unset _dep
# detect_hosts() sets MY_ID and aliases all HOST*_SLSKD_*, HOST*_SABNZBD_*, HOST*_QBIT_* vars
detect_hosts
@@ -250,7 +283,7 @@ if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]] && [[ "$SLSKD_CONNECTED" =
IDS=$(echo "$SEARCHES" | tr '{' '\n' | \
grep '"isComplete":true' | grep '"searchText":' | \
grep -o '"id":"[^"]*"' | sed 's/"id":"//;s/"//')
COUNT=$(echo "$IDS" | grep -c . 2>/dev/null || echo 0)
COUNT=$(echo "$IDS" | grep -c . 2>/dev/null || true)
COUNT="${COUNT//[^0-9]/}"; COUNT="${COUNT:-0}"
if [[ "$COUNT" -eq 0 ]]; then
@@ -306,7 +339,7 @@ if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]] && [[ "$SLSKD_CONNECTED" =
if [[ -z "$USERNAMES" ]]; then
success "No transfer records found ✅"
else
USER_COUNT=$(echo "$USERNAMES" | grep -c . 2>/dev/null || echo 0)
USER_COUNT=$(echo "$USERNAMES" | grep -c . 2>/dev/null || true)
log "Found $USER_COUNT user(s) with transfer records"
SUCCESS=0; SKIPPED=0; FAIL=0
while IFS= read -r USER; do
@@ -383,7 +416,7 @@ if [[ -n "$SLSKD_FAILED_IMPORTS_DIR" ]]; then
else
OLD_IMPORTS=$(find "$SLSKD_FAILED_IMPORTS_DIR" \
-mindepth 1 -maxdepth 1 -mtime +"${DOWNLOADER_RETENTION_DAYS}")
IMPORT_COUNT=$(echo "$OLD_IMPORTS" | grep -c . 2>/dev/null || echo 0)
IMPORT_COUNT=$(echo "$OLD_IMPORTS" | grep -c . 2>/dev/null || true)
IMPORT_COUNT="${IMPORT_COUNT//[^0-9]/}"; IMPORT_COUNT="${IMPORT_COUNT:-0}"
if [[ "$IMPORT_COUNT" -eq 0 ]]; then
@@ -426,7 +459,7 @@ if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then
if [[ -z "$COMPLETED_IDS" ]]; then
success "No completed history found ✅"
else
HIST_TOTAL=$(echo "$COMPLETED_IDS" | grep -c . 2>/dev/null || echo 0)
HIST_TOTAL=$(echo "$COMPLETED_IDS" | grep -c . 2>/dev/null || true)
log "Found $HIST_TOTAL completed history entries"
DELETED=0; SKIPPED=0
while IFS= read -r NZO_ID; do
@@ -474,7 +507,7 @@ if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then
if [[ -z "$FAILED_IDS" ]]; then
success "No failed history found ✅"
else
FAILED_TOTAL=$(echo "$FAILED_IDS" | grep -c . 2>/dev/null || echo 0)
FAILED_TOTAL=$(echo "$FAILED_IDS" | grep -c . 2>/dev/null || true)
log "Found $FAILED_TOTAL failed history entries"
DELETED=0; SKIPPED=0
while IFS= read -r NZO_ID; do
@@ -524,7 +557,7 @@ if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then
if [[ -z "$STALLED_IDS" ]]; then
success "No stalled queue items found ✅"
else
QUEUE_TOTAL=$(echo "$STALLED_IDS" | grep -c . 2>/dev/null || echo 0)
QUEUE_TOTAL=$(echo "$STALLED_IDS" | grep -c . 2>/dev/null || true)
log "Found $QUEUE_TOTAL queue item(s) — checking status"
DELETED=0; SKIPPED=0
while IFS= read -r NZO_ID; do
@@ -585,7 +618,7 @@ if [[ -n "$QBIT_URL" ]] && [[ -n "$QBIT_USERNAME" ]]; then
-H "Cookie: $QBIT_COOKIE" 2>/dev/null)
NOW=$(date +%s)
TORRENT_TOTAL=$(echo "$TORRENTS" | tr '}' '\n' | grep -c '"hash"' 2>/dev/null || echo 0)
TORRENT_TOTAL=$(echo "$TORRENTS" | tr '}' '\n' | grep -c '"hash"' 2>/dev/null || true)
log "Found $TORRENT_TOTAL torrent(s) — applying age/ratio filter"
DELETED=0; SKIPPED=0
@@ -602,11 +635,13 @@ if [[ -n "$QBIT_URL" ]] && [[ -n "$QBIT_USERNAME" ]]; then
# Age check — must be old enough
[[ "$AGE_DAYS" -lt "$QBIT_FAILSAFE_MIN_DAYS" ]] && ((SKIPPED++)) && continue
# Ratio check — if configured
# Ratio check — if configured. awk handles the fractional comparison;
# bash's [[ -lt ]] only does integers and would treat e.g. 1.4 and 1.5
# as equal once truncated. Missing/empty ratio defaults to 0 (protected).
if [[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]]; then
RATIO_INT="${RATIO%.*}"
MIN_RATIO_INT="${QBIT_FAILSAFE_MIN_RATIO%.*}"
[[ "$RATIO_INT" -lt "$MIN_RATIO_INT" ]] && ((SKIPPED++)) && continue
if (( $(awk "BEGIN {print (${RATIO:-0} < $QBIT_FAILSAFE_MIN_RATIO) ? 1 : 0}") )); then
((SKIPPED++)) && continue
fi
fi
if [[ "$DRY_RUN" == true ]]; then
@@ -643,8 +678,10 @@ if [[ "$DRY_RUN" == true ]]; then
elif [[ "$TOTAL_FAIL" -gt 0 ]]; then
echo "$ICON_ERROR Status: $TOTAL_FAIL failure(s) — check logs"
notify "Downloaders reset completed with failures on $(hostname)" "Downloaders Reset" "warning"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 1
else
echo "$ICON_DONE Status: $ICON_SUCCESS DONE"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
+43 -25
View File
@@ -105,18 +105,36 @@ internet connectivity. Most hosts leave this empty.
---
```
FALLBACK_HOST*_COVERS_HOST*_TIER1=(...)
FALLBACK_HOST*_COVERS_HOST*_TIER2=(...)
FALLBACK_HOST*_COVERS_HOST*_TIER3=(...)
FALLBACK_HOST*_COVERS_HOST*_TIER4=(...)
FALLBACK_HOST*_TIER1=(...)
FALLBACK_HOST*_TIER2=(...)
FALLBACK_HOST*_TIER3=(...)
FALLBACK_HOST*_TIER4=(...)
```
Containers this host starts for the remote host when the remote is down. TIER1 starts
immediately. TIER24 activate after the corresponding delay thresholds.
Variable pattern: `FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER${N}`
**Each host declares its OWN services, in its OWN conf.** `FALLBACK_HOST2_TIER1` lives in
`host2.conf` and lists HOST2's vital containers — it is not a list HOST1 maintains.
The DDNS container for the remote's domain must be the first entry in TIER1 — DNS
coverage before anything else.
At runtime `fallback.sh` reads its **partner's** list:
```bash
get_tier_containers() {
local var_name="FALLBACK_${REMOTE_ID}_TIER${tier}" # note: REMOTE_ID, not MY_ID
```
So HOST1, covering HOST2, reads `FALLBACK_HOST2_TIER1` — a variable defined in `host2.conf`
and delivered to HOST1 through the partner conf cache (`conf_sync.sh`), because sparse
checkout means HOST1 never pulls `host2.conf` from git.
This is why the naming is what it is. The alternative — each host keeping a copy of its
partner's container list — would need editing on both machines every time either one changed
a service, and the two copies would silently diverge. Declaring once, on the host that owns
the services, means a host is always the authority on what covering it requires.
TIER1 starts immediately. TIER24 activate after their delay thresholds
(`HOST*_TIER2_DELAY` and friends, also in that host's own conf).
The DDNS container for that host's domain must be the **first entry in TIER1** — DNS coverage
before anything else.
---
@@ -181,15 +199,15 @@ FALLBACK_TEST_HANDBACK_WAIT=300
---
### host2.conf — HOST2 covering HOST1
### host1.conf — HOST1's own services (started by HOST2 when HOST1 is down)
```bash
HOST2_DDNS_CONTAINERS=("Gmer4Lfe.us-DDNS")
HOST1_DDNS_CONTAINERS=("Gmer4Lfe.com-DDNS")
FALLBACK_HOST2_STOP_ON_NO_NET=()
FALLBACK_HOST1_STOP_ON_NO_NET=()
# Tier 1 — immediate (vital services + Live TV)
FALLBACK_HOST2_COVERS_HOST1_TIER1=(
FALLBACK_HOST1_TIER1=(
"Gmer4Lfe.com-DDNS" # ALWAYS FIRST — DNS coverage before anything else
"Emby" # media server — people are watching
"NginxProxyManager" # reverse proxy — all external access routes through this
@@ -205,7 +223,7 @@ FALLBACK_HOST2_COVERS_HOST1_TIER1=(
)
# Tier 2 — after 4 hours (shared productivity services)
FALLBACK_HOST2_COVERS_HOST1_TIER2=(
FALLBACK_HOST1_TIER2=(
"Postgres-NextCloud" # must start before NextCloud
"NextCloud"
"PostgreSQL-Immich" # must start before Immich
@@ -214,7 +232,7 @@ FALLBACK_HOST2_COVERS_HOST1_TIER2=(
)
# Tier 3 — after 12 hours (secondary services)
FALLBACK_HOST2_COVERS_HOST1_TIER3=(
FALLBACK_HOST1_TIER3=(
"Organizrv2-Gmer4Lfe"
"AdGuard-Home"
"UptimeKuma"
@@ -223,7 +241,7 @@ FALLBACK_HOST2_COVERS_HOST1_TIER3=(
)
# Tier 4 — after 24 hours (arrs + downloaders)
FALLBACK_HOST2_COVERS_HOST1_TIER4=(
FALLBACK_HOST1_TIER4=(
"Sonarr-Gmer4Lfe"
"Radarr-Gmer4Lfe"
"Lidarr-Gmer4Lfe"
@@ -259,22 +277,22 @@ FALLBACK_HOST1_WRITEBACK_TIER3=(
---
### host1.conf — HOST1 covering HOST2
### host2.conf — HOST2's own services (started by HOST1 when HOST2 is down)
```bash
HOST1_DDNS_CONTAINERS=("Gmer4Lfe.com-DDNS")
HOST2_DDNS_CONTAINERS=("Gmer4Lfe.us-DDNS")
FALLBACK_HOST1_STOP_ON_NO_NET=()
FALLBACK_HOST2_STOP_ON_NO_NET=()
# Tier 1 — immediate (HOST2's vital services)
FALLBACK_HOST1_COVERS_HOST2_TIER1=(
FALLBACK_HOST2_TIER1=(
"Gmer4Lfe.us-DDNS" # ALWAYS FIRST
# HOST2's Tier 1 services — fill per HOST2's stack
)
FALLBACK_HOST1_COVERS_HOST2_TIER2=(...)
FALLBACK_HOST1_COVERS_HOST2_TIER3=(...)
FALLBACK_HOST1_COVERS_HOST2_TIER4=(...)
FALLBACK_HOST2_TIER2=(...)
FALLBACK_HOST2_TIER3=(...)
FALLBACK_HOST2_TIER4=(...)
# Tier delays for HOST2 outage
HOST2_TIER2_DELAY=240
@@ -296,7 +314,7 @@ FALLBACK_HOST2_WRITEBACK_TIER1=(
Location: `$STATE_DIR/fallback_state.db` (survives reboots — boot device or appdata)
> In a shell where load_config.sh is not sourced, use the full path:
> `/boot/config/plugins/varaverk/State_Files/fallback_state.db` (internal storage mode)
> `/boot/config/plugins/varaverk/data/state/fallback_state.db` (internal storage mode)
```
state=NORMAL # NORMAL | FALLBACK | NO_INTERNET | DARK
@@ -492,7 +510,7 @@ Restart fallback.sh via User Scripts plugin. It will resume from NORMAL on its n
1. Create the container on the covering server (stopped), with volume mounts pointing at
the mirrored share path (e.g. `/mnt/user/Movies` must exist on the covering server)
2. Add the container name to `FALLBACK_HOST*_COVERS_HOST*_TIER*` in host*.conf
2. Add the container name to `FALLBACK_<THAT-HOST>_TIER*` in **that host's own** conf
in the appropriate tier position (dependency ordering — databases before apps)
3. Verify: `fallback.sh --status` shows the container in the expected tier list
4. Run `fallback_test.sh --dry-run` to confirm the full configuration is valid
+3 -3
View File
@@ -115,7 +115,7 @@ Both servers run `fallback.sh` independently as a continuous background process.
makes all decisions from two pings every `FALLBACK_CHECK_INTERVAL` seconds:
```bash
ping REMOTE_TAILSCALE_IP # is the other server reachable?
ping "$(resolve_tailscale_ip "$REMOTE_SERVER_NAME")" # is the other server reachable?
ping EXTERNAL_IP # do I have internet? (default: 8.8.8.8)
```
@@ -220,8 +220,8 @@ determines which server is local and which is remote at runtime, then selects th
container arrays and tier delays from config via MY_ID.
```
HOST2 covers HOST1: FALLBACK_HOST2_COVERS_HOST1_TIER* (in host2.conf)
HOST1 covers HOST2: FALLBACK_HOST1_COVERS_HOST2_TIER* (in host1.conf)
HOST2 covers HOST1: FALLBACK_HOST1_TIER* (in host2.conf)
HOST1 covers HOST2: FALLBACK_HOST2_TIER* (in host1.conf)
```
Both servers run identical scripts. MY_ID selects the correct arrays. No hostname
+351
View File
@@ -0,0 +1,351 @@
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════════════════════
# PURPOSE
# Put the containers this host has marked for fallback coverage onto the partner, so that the
# partner can actually start them during an outage — and take them off again on request.
#
# OPERATIONAL MODEL
# fallback.sh covers a host by running `docker start <name>` on the partner. It never creates
# anything. So a name in FALLBACK_<me>_TIER* is a promise that only holds if the partner already
# has that container built. Measured 2026-08-23: all 12 of HOST1's covered containers were absent
# from HOST2, meaning every tier would have failed on the first real outage while the UI showed
# coverage as configured. This script is what closes that gap.
#
# Push and remove are separate, deliberate actions, never a side effect of saving the tier list.
# Editing coverage is a cheap config write; deploying a dozen containers onto another machine is
# not, and the two should not share a button.
#
# DESIGN PRINCIPLES
# Deployed, then verified STOPPED.
# A container built here and left running on the partner would be a second live instance of
# NextCloud, Gitea or PostgreSQL_Immich against the same data while this host is healthy.
# That is the danger_rsync_live_database_appdata failure with worse odds. Every deploy is
# followed by a stop and a re-inspect, and a container that will not stay stopped is an
# error, not a warning.
#
# Remove takes the container AND its appdata.
# Operator decision 2026-08-23: the button is explicit, so a removal should leave nothing
# behind to reason about later. The risk it accepts is narrow and worth naming — if the
# partner ever covered for us, ITS appdata is the newer copy and is what a handback rsyncs
# home. The NORMAL-state gate below closes the live-failover window; what it cannot see is
# a handback that partially failed and then returned to NORMAL, so the UI says so before
# asking.
#
# Two guards on the deletion itself: only paths under /mnt/*/appdata* are ever touched, and
# a bind of the appdata ROOT is refused outright — a container mounting /mnt/user/appdata
# would otherwise turn one removal into wiping every application on the partner.
#
# Refuses to run unless fallback state is NORMAL.
# Pushing or removing containers mid-outage edits the thing currently keeping services up.
#
# Coverage names are resolved to templates by <Name>, not by filename.
# my-Foo.xml routinely holds a container called something else. Matching on the filename
# silently pushes the wrong template, or nothing at all.
#
# OPERATIONAL SAFEGUARDS
# Only in NORMAL state. FALLBACK_STATE_FILE is read before anything is pushed or removed, and
# any other state refuses the action. A push during a live failover would deploy a second copy
# of a container the partner is currently running on our behalf; a remove would delete the one
# doing the covering.
#
# --status is exempt from that gate, because it only reports. Refusing to answer "what is
# deployed over there" during a failover would withhold the information precisely when it is
# most wanted.
#
# Every deploy is verified stopped, and a container that will not stay stopped is an error
# rather than a warning — see DESIGN PRINCIPLES. A second live instance against the same data
# is the failure this whole script exists inside.
#
# Push and remove are explicit modes with no default. Running the script with no flag does
# nothing; neither action can be reached by accident, and neither is a side effect of editing
# the tier list.
#
# --dry-run works in every mode and touches nothing on either host — no container is built,
# started, stopped or removed, and no template is written or deleted.
#
# Remove deletes the container's appdata on the partner as well. That is deliberate and is the
# most destructive thing here; the NORMAL-state gate above is what keeps it away from a
# partner that is mid-handback.
#
# CONFIGURATION
# master.conf
# FALLBACK_<HOST>_TIER1..N the covered container names — what --push deploys and --status
# reports on. This script reads that list; it never edits it.
#
# host*.conf
# FALLBACK_STATE_FILE overrides where fallback.sh's state is read from. Defaults to
# STATE_DIR/fallback_state.db. A missing file reads as NORMAL,
# which is the correct default on a host where fallback has never
# run.
#
# RUNTIME MODES
# coverage_deploy.sh --push deploy every covered container onto the partner (stopped)
# coverage_deploy.sh --remove stop, remove, and delete the pushed template on the partner
# coverage_deploy.sh --status report, per covered container, whether it exists there
# any mode supports --dry-run
#
# DEPENDS ON
# Plugin/<platform>/Partnership/containers.sh deploy_container_from_xml(), GPU transform
# FALLBACK_<me>_TIER1-4 the coverage list this acts on
# ══════════════════════════════════════════════════════════════════════════════════════════════
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../Plugin/$PLATFORM/Partnership/containers.sh"
SSH_TIMEOUT="${SSH_TIMEOUT:-15}"
MODE=""
DRY_RUN="${DRY_RUN:-false}"
for arg in "$@"; do
case "$arg" in
--push) MODE="push" ;;
--remove) MODE="remove" ;;
--status) MODE="status" ;;
--dry-run) DRY_RUN=true ;;
esac
done
if [[ -z "$MODE" ]]; then
error "No mode given — use --push, --remove or --status"
exit 1
fi
detect_hosts
if [[ -z "$REMOTE_ID" || "$REMOTE_SERVER_NAME" == "unknown" ]]; then
error "No partner configured — nothing to push to"
exit 1
fi
# ── Gate: only with fallback idle ─────────────────────────────────────────────────────────────
# Read rather than assumed. A missing state file means fallback has never run, which is idle
# enough; a file that says anything other than NORMAL means services are in motion right now.
FALLBACK_STATE_FILE="${FALLBACK_STATE_FILE:-${STATE_DIR}/fallback_state.db}"
_fb_state="NORMAL"
if [[ -f "$FALLBACK_STATE_FILE" ]]; then
_fb_state=$(grep -m1 '^state=' "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2)
_fb_state="${_fb_state:-NORMAL}"
fi
if [[ "$_fb_state" != "NORMAL" && "$MODE" != "status" ]]; then
error "Fallback state is $_fb_state, not NORMAL — refusing to $MODE"
error "Changing what the partner holds while a failover is live edits the thing keeping services up."
exit 1
fi
# ── The coverage list ─────────────────────────────────────────────────────────────────────────
COVERED=()
for _t in 1 2 3 4; do
_var="FALLBACK_${MY_ID}_TIER${_t}[@]"
for _c in "${!_var}"; do
[[ -n "$_c" ]] && COVERED+=("$_c")
done
done
if [[ ${#COVERED[@]} -eq 0 ]]; then
warn "No containers are covered in FALLBACK_${MY_ID}_TIER1-4 — nothing to do"
exit 0
fi
log "$ICON_FALLBACK Coverage: ${#COVERED[@]} container(s) for $REMOTE_SERVER_NAME to start during an outage"
resolve_remote_ip
MIRROR="$REMOTE_SERVER_NAME"
MIRROR_IP="$REMOTE_SERVER"
_key_var="${MY_ID}_SSH_KEY"
MIRROR_SSH_KEY="${!_key_var}"
if [[ ! -f "$MIRROR_SSH_KEY" ]]; then
error "SSH key $MIRROR_SSH_KEY not found — cannot reach $MIRROR"
exit 1
fi
# ── name -> template ──────────────────────────────────────────────────────────────────────────
# Matched on the <Name> element. Filenames lie often enough that trusting them would push the
# wrong container without saying so.
xml_for_container() {
local want="$1" f n
for f in "$TEMPLATES_DIR"/*.xml; do
[[ -f "$f" ]] || continue
n=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$f")
[[ "$n" == "$want" ]] && { echo "$f"; return 0; }
done
return 1
}
remote_has_container() {
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
-o BatchMode=yes -o StrictHostKeyChecking=no root@"$MIRROR_IP" \
"docker inspect $(printf '%q' "$1") >/dev/null 2>&1" 2>/dev/null
}
remote_state_of() {
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
-o BatchMode=yes -o StrictHostKeyChecking=no root@"$MIRROR_IP" \
"docker inspect -f '{{.State.Status}}' $(printf '%q' "$1") 2>/dev/null" 2>/dev/null
}
OK=0; FAIL=0; SKIP=0
case "$MODE" in
status)
# Written as a cache as well as printed. The assistant's fallback_state block cannot afford an
# SSH round trip per container mid-question, so it reads this file and reports its AGE — a stale
# answer stated as stale is useful, stated as current it is the exact failure this feature
# exists to prevent.
_present="" _missing=""
for c in "${COVERED[@]}"; do
if remote_has_container "$c"; then
_st=$(remote_state_of "$c")
printf ' %-28s on %s (%s)\n' "$c" "$MIRROR" "$_st"
_present+="\"$c\":\"${_st:-unknown}\","
OK=$((OK+1))
else
printf ' %-28s MISSING on %s — docker start would fail\n' "$c" "$MIRROR"
_missing+="\"$c\","
FAIL=$((FAIL+1))
fi
done
log "$ICON_FALLBACK Coverage present: $OK · missing: $FAIL"
mkdir -p "$VV_CACHE_ROOT/api" 2>/dev/null || mkdir -p /tmp/varaverk/api 2>/dev/null
_cache="${VV_CACHE_ROOT:-/tmp/varaverk}/api/fallback_presence.json"
# Written atomically — a half-written cache read mid-question would report containers as
# missing that are merely unparsed.
printf '{"present":{%s},"missing":[%s],"partner":"%s","checked":%s}\n' \
"${_present%,}" "${_missing%,}" "$MIRROR" "$(date +%s)" > "$_cache.tmp" \
&& mv -f "$_cache.tmp" "$_cache"
[[ "$FAIL" -gt 0 ]] && exit 2 || exit 0
;;
push)
# Networks first — a container whose network is absent is created and then cannot start,
# which is the failure that read as "auth 0/8, arr 0/5" during onboarding.
_nets=()
for c in "${COVERED[@]}"; do
x=$(xml_for_container "$c") || continue
net=$(sed -n 's/.*<Network>\([^<]*\)<\/Network>.*/\1/p' "$x" 2>/dev/null | head -1)
net="${net//[[:space:]]/}"
# br* is host hardware. wg* is a WireGuard-backed bridge whose meaning does NOT travel:
# recreating it on the partner as a plain bridge yields a network that exists, starts its
# containers, and routes their traffic OUTSIDE the tunnel. ChannelTube rides wg0 here.
case "$net" in
''|bridge|host|none|br[0-9]*) continue ;;
wg[0-9]*)
warn "$c uses $net — a WireGuard-backed network. NOT created on $MIRROR: a plain"
warn " bridge of the same name would route its traffic outside the tunnel. Build the"
warn " matching tunnel there first, or drop $c from coverage."
continue ;;
esac
_seen=false
for n in "${_nets[@]}"; do [[ "$n" == "$net" ]] && { _seen=true; break; }; done
[[ "$_seen" == false ]] && _nets+=("$net")
done
for net in "${_nets[@]}"; do
driver=$(timeout "${DOCKER_TIMEOUT:-30}" docker network inspect "$net" --format '{{.Driver}}' 2>/dev/null)
if [[ "$driver" != "bridge" ]]; then
warn "Network $net is '${driver:-absent}' here, not bridge — create it on $MIRROR by hand"
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would ensure network $net on $MIRROR"
continue
fi
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
-o BatchMode=yes -o StrictHostKeyChecking=no root@"$MIRROR_IP" \
"docker network inspect $(printf '%q' "$net") >/dev/null 2>&1 \
|| docker network create --driver bridge $(printf '%q' "$net") >/dev/null" 2>/dev/null \
&& log " network $net ready on $MIRROR" \
|| warn " could not ensure network $net on $MIRROR"
done
for c in "${COVERED[@]}"; do
x=$(xml_for_container "$c") || {
warn "$c — no template in $TEMPLATES_DIR names it; skipped"
SKIP=$((SKIP+1)); continue
}
if ! deploy_container_from_xml "$x" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
error "$c — deploy failed"
FAIL=$((FAIL+1)); continue
fi
if [[ "$DRY_RUN" == true ]]; then OK=$((OK+1)); continue; fi
# Deployed containers must not run here. Stop, then re-inspect — a stop that did not take
# is the one outcome that silently duplicates a live service against shared data.
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
-o BatchMode=yes -o StrictHostKeyChecking=no root@"$MIRROR_IP" \
"docker stop $(printf '%q' "$c") >/dev/null 2>&1" 2>/dev/null
st=$(remote_state_of "$c")
if [[ "$st" == "running" ]]; then
error "$c is RUNNING on $MIRROR after deploy and would not stop — stop it there before continuing"
FAIL=$((FAIL+1))
else
log " $c deployed and ${st:-stopped} on $MIRROR"
OK=$((OK+1))
fi
done
log "$ICON_FALLBACK Push complete — deployed $OK · failed $FAIL · skipped $SKIP"
[[ "$FAIL" -gt 0 ]] && exit 1 || exit 0
;;
remove)
for c in "${COVERED[@]}"; do
if ! remote_has_container "$c"; then
log " $c not on $MIRROR — nothing to remove"
SKIP=$((SKIP+1)); continue
fi
# Binds are read BEFORE the container goes — once it is removed there is nothing left to
# enumerate, and a path list gathered afterwards would silently be empty.
_binds=$(timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
-o BatchMode=yes -o StrictHostKeyChecking=no root@"$MIRROR_IP" \
"docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' $(printf '%q' "$c") 2>/dev/null \
| awk -F: '{print \$1}'" 2>/dev/null)
_wipe=()
while IFS= read -r _p; do
[[ -z "$_p" ]] && continue
# Only appdata, and never an appdata root. /mnt/user/appdata as a bind would make one
# container removal delete every application on the partner.
[[ "$_p" =~ ^/mnt/[^/]+/appdata[^/]*/.+ ]] || {
[[ "$_p" =~ ^/mnt/[^/]+/appdata[^/]*/?$ ]] && \
warn " $c binds the appdata ROOT ($_p) — refusing to delete it"
continue
}
_wipe+=("$_p")
done <<< "$_binds"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would stop and remove $c on $MIRROR"
for _p in "${_wipe[@]}"; do warn " DRY RUN — would delete appdata $_p on $MIRROR"; done
OK=$((OK+1)); continue
fi
x=$(xml_for_container "$c") && xml_name=$(basename "$x") || xml_name=""
if timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
-o BatchMode=yes -o StrictHostKeyChecking=no root@"$MIRROR_IP" \
"docker stop $(printf '%q' "$c") >/dev/null 2>&1; \
docker rm $(printf '%q' "$c") >/dev/null 2>&1; \
${xml_name:+rm -f ${TEMPLATES_DIR}/$(printf '%q' "$xml_name");} \
! docker inspect $(printf '%q' "$c") >/dev/null 2>&1" 2>/dev/null; then
log " $c removed from $MIRROR"
for _p in "${_wipe[@]}"; do
if timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
-o BatchMode=yes -o StrictHostKeyChecking=no root@"$MIRROR_IP" \
"rm -rf -- $(printf '%q' "$_p") && ! [ -e $(printf '%q' "$_p") ]" 2>/dev/null; then
log " appdata deleted on $MIRROR: $_p"
else
warn " could not delete appdata on $MIRROR: $_p"
FAIL=$((FAIL+1))
fi
done
OK=$((OK+1))
else
error "$c — removal failed or it still exists on $MIRROR"
FAIL=$((FAIL+1))
fi
done
log "$ICON_FALLBACK Remove complete — removed $OK · failed $FAIL · skipped $SKIP"
[[ "$FAIL" -gt 0 ]] && exit 1 || exit 0
;;
esac
+140 -8
View File
@@ -89,7 +89,50 @@
#
# FALLBACK_ENABLED Gate
# Exits cleanly when disabled — safe to run on servers being rebuilt without
# triggering spurious fallback actions.
# triggering spurious fallback actions. Fail-closed: anything that is not exactly
# "true" counts as disabled, so a malformed toggle cannot grant this script DDNS
# authority and cross-server container control by accident.
#
# Docker Presence Check
# Verifies the docker binary exists before the state machine starts.
#
# Remote IP Resolution
# resolve_remote_ip() must resolve the partner before any SSH operation, so a
# remote command can never be issued against an unresolved or stale address.
#
# Asymmetric Failover / Handback
# Entering FALLBACK is immediate — a down partner means users are already affected.
# Returning requires FALLBACK_HANDBACK_STRIKES consecutive remote-up checks. The
# asymmetry is deliberate: protecting fast costs a few minutes of redundant
# coverage, handing back fast on a flapping partner costs a second outage.
#
# DDNS Excluded From Tier Loops
# The Tier 1 stop and start loops explicitly skip any container that is also in
# REMOTE_DDNS_CONTAINERS. DDNS is sequenced by the handoff and cutover steps alone,
# so ordinary tier processing can never move DNS at the wrong moment.
#
# Writeback Delay Gate
# Tier writeback rsync only runs once the outage has exceeded that tier's writeback
# delay. A brief blip does not trigger a full data writeback, which would cost more
# than the outage it is compensating for.
#
# FALLBACK_RSYNC_ENABLED Gate
# Writeback is skipped entirely when disabled, and the skip is announced rather than
# silent — containers still hand back, but nobody is left assuming data moved.
#
# Play State Sync Before Cutover
# Handback retries play_state_sync up to PLAY_SYNC_HANDBACK_RETRIES times before DNS
# cuts over, so users land on current watch state. Exhausting retries warns and
# proceeds — stale resume positions are not worth holding DNS on a downed service.
#
# Partnership Suspend Abort
# If partnership goes inactive mid-fallback, _abort_fallback_containers() stops the
# fallback containers and returns to NORMAL rather than leaving this host serving a
# partner it is no longer paired with.
#
# Container Verify Wait
# CONTAINER_VERIFY_WAIT seconds elapse after each start before the running check, so
# a container that starts and immediately crashes is caught rather than counted up.
#
# Version Parity Check
# Refuses handback if remote unRAID version doesn't match. A mismatch may
@@ -271,8 +314,55 @@ if [[ "$EUID" -ne 0 ]]; then
exit 1
fi
# FALLBACK_ENABLED gate — exits cleanly when disabled (e.g. HOST2 being rebuilt)
if [[ "${FALLBACK_ENABLED:-false}" == false ]]; then
# ── Persistent dry-run ────────────────────────────────────────────────────────────────────────
# Conf-driven, not argument-driven, deliberately. array_started.sh launches every entry as a bare
# `bash script.sh &` with no arguments, so a --dry-run typed at a terminal survives exactly until
# the next array start — and then the LIVE daemon comes up in its place, silently, which is the
# one transition nobody would be watching for.
#
# Setting it here means the mode is a property of the install rather than of how the process
# happened to be started: array start, the Fallback tab's button, and a hand-run all agree.
#
# OR, never override: --dry-run on the command line still wins over a conf that says false, so an
# ad-hoc preview against a live install needs no conf edit.
if [[ "${FALLBACK_DRY_RUN:-false}" == "true" ]]; then
DRY_RUN=true
fi
# ── Persistent log ────────────────────────────────────────────────────────────────────────────
# array_started.sh launches every entry as a bare `bash script.sh &` with no redirection, so this
# daemon's output has never been captured anywhere: /var/log/varaverk has a directory for every
# other script family and none for Fallback. A month of dry-run observation would have persisted
# nothing at all.
#
# /var/log is a 128 MB tmpfs on Unraid — RAM, and cleared on reboot — so the log goes to
# LOG_ARCHIVE_DIR, which follows DATA_DIR onto real storage.
#
# Only when stdout is not a terminal. Run by hand, output still goes to the terminal exactly as
# before; run by array_started or the Fallback tab's button, it lands in the file. A plain append
# redirect rather than `tee` through process substitution: no extra child to outlive, and nothing
# for the shutdown trap to race.
FALLBACK_LOG="${LOG_ARCHIVE_DIR:-${DATA_DIR:-/tmp}/logs}/fallback.log"
if [[ ! -t 1 ]]; then
mkdir -p "$(dirname "$FALLBACK_LOG")" 2>/dev/null
# One rotation, sized rather than line-counted — the whole point of this log is a long run,
# and _orch_trim_log()'s 1000-line cap would discard weeks of it. Event-only output (no
# --log) is a few lines per incident, so this holds years; --log fills it in about a fortnight
# and then keeps the most recent fortnight plus the one before it.
_fb_max=$(( ${FALLBACK_LOG_MAX_MB:-5} * 1048576 ))
if [[ -f "$FALLBACK_LOG" ]] && (( $(stat -c %s "$FALLBACK_LOG" 2>/dev/null || echo 0) > _fb_max )); then
mv -f "$FALLBACK_LOG" "${FALLBACK_LOG}.1" 2>/dev/null
fi
exec >> "$FALLBACK_LOG" 2>&1
echo ""
echo "═══ fallback.sh started $(date '+%Y-%m-%d %H:%M:%S') — dry_run=${DRY_RUN} pid=$$ ═══"
fi
# FALLBACK_ENABLED gate — exits cleanly when disabled.
# Fail-closed: anything that isn't exactly "true" disables fallback. Matching only the
# literal "false" would let a typo ("no", "0", "FALSE") hand this script DDNS authority
# and cross-server container control on a toggle nobody meant to set.
if [[ "${FALLBACK_ENABLED:-false}" != "true" ]]; then
warn "FALLBACK_ENABLED=false — fallback monitoring disabled"
warn "Set FALLBACK_ENABLED=true in master.conf when both servers are ready"
exit 0
@@ -289,8 +379,6 @@ detect_hosts
require_partnership
resolve_remote_ip
# Validate unRAID notify script — used throughout for state change notifications
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no container or DDNS changes will be made"
# Timeout for all docker and SSH docker commands
@@ -324,6 +412,22 @@ state_set() {
}
state_init() {
# A dry run must not leave the host believing it failed over. state_set() writes
# unconditionally, and this file survives reboots and is what the real daemon — and the
# Monitor and Fallback cards — read to decide what is happening. A --dry-run walk through
# FAILOVER would have written state=FALLBACK, the tier flags and the strike counter into it
# for real, and nothing would have put them back.
#
# Copied rather than merely redirected, so the preview still starts from the live state and
# can advance through tiers exactly as a real run would. The copy lands in the RAM cache and
# dies with the reboot.
if [[ "$DRY_RUN" == true ]]; then
local live="$FALLBACK_STATE_FILE"
FALLBACK_STATE_FILE="${VV_CACHE_ROOT:-/tmp/varaverk}/fallback_state.dryrun.$$"
mkdir -p "$(dirname "$FALLBACK_STATE_FILE")"
if [[ -f "$live" ]]; then cp -f "$live" "$FALLBACK_STATE_FILE"; else : > "$FALLBACK_STATE_FILE"; fi
warn "DRY RUN — state writes redirected to $FALLBACK_STATE_FILE (live state untouched)"
fi
mkdir -p "$(dirname "$FALLBACK_STATE_FILE")"
[[ ! -f "$FALLBACK_STATE_FILE" ]] && touch "$FALLBACK_STATE_FILE"
[[ -z "$(state_get state)" ]] && state_set state "NORMAL"
@@ -715,8 +819,16 @@ run_handback() {
for job in "${jobs[@]}"; do
[[ -z "$job" ]] && continue
log "Syncing: $job"
[[ "$DRY_RUN" == false ]] && bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job" \
|| warn "DRY RUN — would rsync: $job"
# if/else, not A && B || C. In the shorthand a REAL run whose rsync exits
# non-zero falls through to the || branch and logs "DRY RUN — would rsync",
# so a failed Tier writeback reported itself as a preview and the real
# failure went unsaid. The Tier 1 block below always had this right.
if [[ "$DRY_RUN" == false ]]; then
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job" \
|| error "Tier $tier writeback FAILED: $job"
else
warn "DRY RUN — would rsync: $job"
fi
done
else
log "Tier $tier writeback skipped — outage ${outage_minutes}min < ${threshold}min"
@@ -860,6 +972,19 @@ echo " $ICON_NET Remote IP: $REMOTE_SERVER"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
FALLBACK_RUNNING=true
# The dry-run state copy is per-PID and would otherwise accumulate one file per preview run.
# EXIT as well as the signals, because a dry run is usually ended with Ctrl-C or --stop but can
# also just fall out of the loop.
dryrun_state_cleanup() {
[[ "$DRY_RUN" == true && "$FALLBACK_STATE_FILE" == *".dryrun."* ]] && rm -f "$FALLBACK_STATE_FILE"
return 0
}
# Must call _release_all_locks too. acquire_lock() registers its own EXIT trap, and bash keeps
# exactly one per signal — a bare `trap ... EXIT` here silently replaced it and orphaned
# fallback.lock, which is the precise failure the _LOCK_FILES registry in common.sh was built to
# stop. The signal trap only needs `exit 0`; that fires EXIT, which does both jobs.
trap 'dryrun_state_cleanup; _release_all_locks' EXIT
trap 'FALLBACK_RUNNING=false; warn "Fallback received shutdown signal — stopping cleanly"; exit 0' \
SIGTERM SIGINT
@@ -1000,12 +1125,19 @@ while [[ "$FALLBACK_RUNNING" == true ]]; do
fi
elif [[ "$INTERNET_UP" == false ]]; then
# Lost internet during fallback — enter DARK
# Lost internet during fallback — enter DARK — same actions as NO_INTERNET
echo ""
echo "━━━ $ICON_NET Entering DARK — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
warn "Lost internet during fallback — entering DARK state"
state_set state "DARK"
local_ddns_stop
# Stop containers configured to stop on internet loss
read -r -a stop_on_no_net <<< "$(get_stop_on_no_net)"
for container in "${stop_on_no_net[@]}"; do
[[ -n "$container" ]] && local_stop "$container"
done
notify "DARK state on $(hostname) — lost internet during fallback" \
"Fallback" "warning"
+126 -6
View File
@@ -41,9 +41,23 @@
# ==============================================================================================
#
# iptables Safety Trap
# The DROP rule is removed via trap on ANY exit — normal completion, crash, error,
# ctrl-c. Remote connectivity is always restored regardless of test outcome.
# You cannot accidentally leave the remote permanently blocked.
# The DROP rule is removed via an EXIT trap that fires on normal completion, error
# exit, script crash, ctrl-c (SIGINT) and SIGTERM — verified, not assumed. Remote
# connectivity is restored regardless of test outcome.
#
# Stale Rule Sweep
# The trap above cannot cover SIGKILL or a power cut, which are the only ways a DROP
# rule survives the test. One stranded that way makes fallback.sh see the partner as
# permanently down and hold FALLBACK indefinitely, so pre-flight clears any leftover
# rule before doing anything else — including before the reachability check, which
# would otherwise fail and blame the network for the test's own residue.
#
# Root Enforcement
# iptables and container control require root.
#
# iptables Presence Check
# platform_require_cmd confirms iptables exists before the test begins — there is no
# point entering a connectivity simulation that cannot simulate anything.
#
# FALLBACK_ENABLED Gate
# Aborts if FALLBACK_ENABLED=false. Testing a disabled fallback system is
@@ -77,13 +91,13 @@
# FALLBACK_TEST_BLOCK_WAIT
# Seconds to wait in Phase 3 for fallback.sh to detect the outage.
# Must be > FALLBACK_CHECK_INTERVAL + buffer. At 30s interval: use ≥60s.
# (default: 60)
# (shipped default: 150)
#
# FALLBACK_TEST_HANDBACK_WAIT
# Seconds to wait in Phase 6 for fallback.sh to complete handback.
# Must cover: FALLBACK_HANDBACK_STRIKES × FALLBACK_CHECK_INTERVAL + rsync
# duration + container start time. At 3 strikes × 30s + ~2min rsync +
# ~1min container start: use ≥240s. (default: 300)
# ~1min container start: use ≥240s. (shipped default: 360)
#
# ==============================================================================================
# RUNTIME MODES
@@ -104,6 +118,11 @@
# fallback_test.sh --log
# Verbose output on every check in every phase.
#
# fallback_test.sh --stop
# Stop a running test. SIGTERM only — never SIGKILL, because only this script's EXIT
# trap removes the iptables DROP rule it installed. Also sweeps a rule stranded by an
# earlier SIGKILL or power cut.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -112,6 +131,58 @@ source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ── Stop mode — runs before acquire_lock so we can target the holding instance ────────────────
#
# SIGTERM ONLY, and deliberately no SIGKILL escalation — the opposite of fallback.sh --stop.
# A running test holds an iptables DROP rule against the partner, and the only thing that removes
# it is this script's own EXIT trap. SIGKILL does not run traps, so force-killing a test strands
# the rule: the partner stays invisible, fallback.sh reads that as a permanent outage and holds
# FALLBACK indefinitely. A test that will not die is a worse outcome than a test still running,
# so this reports the stranded rule and the command to clear it rather than causing one.
if [[ " ${PARSED_ARGS[*]:-} " == *" --stop "* ]]; then
LOCKFILE="${LOCK_DIR}/fallback_test.lock"
if [[ ! -f "$LOCKFILE" ]]; then
log "No fallback_test.sh lock found — not running"
exit 0
fi
lock_content=$(cat "$LOCKFILE" 2>/dev/null)
target_pid="${lock_content%%:*}"
if [[ -z "$target_pid" ]] || ! kill -0 "$target_pid" 2>/dev/null; then
warn "Stale lock — fallback_test.sh not running (PID ${target_pid:-unknown} gone) — clearing"
rm -f "$LOCKFILE"
# A stale lock is exactly the SIGKILL/power-cut case, so the rule may still be in place.
if iptables -C OUTPUT -d "${REMOTE_SERVER:-0.0.0.0}" -j DROP 2>/dev/null; then
warn "Stranded iptables DROP rule found for $REMOTE_SERVER — removing"
iptables -D OUTPUT -d "$REMOTE_SERVER" -j DROP 2>/dev/null \
&& warn "Stranded rule removed — remote connectivity restored ✅" \
|| error "Could not remove stranded rule — run: iptables -D OUTPUT -d $REMOTE_SERVER -j DROP"
fi
exit 0
fi
warn "Stopping fallback_test.sh (PID $target_pid) — SIGTERM so its trap clears the iptables rule..."
kill -TERM "$target_pid" 2>/dev/null || true
waited=0
while kill -0 "$target_pid" 2>/dev/null && [[ "$waited" -lt 30 ]]; do
sleep 1
(( waited++ )) || true
done
if kill -0 "$target_pid" 2>/dev/null; then
error "fallback_test.sh (PID $target_pid) did not exit within 30s"
error "NOT force-killing — SIGKILL would strand the iptables DROP rule on $REMOTE_SERVER"
error "Wait, or clear manually: iptables -D OUTPUT -d $REMOTE_SERVER -j DROP"
exit 1
fi
warn "Stopped: fallback_test.sh (PID $target_pid) ✅"
if iptables -C OUTPUT -d "${REMOTE_SERVER:-0.0.0.0}" -j DROP 2>/dev/null; then
error "iptables DROP rule for $REMOTE_SERVER survived the stop — removing"
iptables -D OUTPUT -d "$REMOTE_SERVER" -j DROP 2>/dev/null \
&& warn "Rule removed ✅" || error "Could not remove — run it by hand"
else
log "No iptables DROP rule remains for $REMOTE_SERVER"
fi
exit 0
fi
FALLBACK_SCRIPT="$SCRIPT_DIR/fallback.sh"
DOCKER_TIMEOUT=15
@@ -139,6 +210,28 @@ cleanup() {
trap cleanup EXIT
# ── Stale rule sweep — the one case the trap above cannot cover ───────────────────────────────
# The EXIT trap fires on normal exit, error, ctrl-c and SIGTERM, but not on SIGKILL or a power
# cut. A DROP rule stranded that way makes fallback.sh see the partner as permanently down and
# sit in FALLBACK indefinitely — so clear any leftover from a previous run before starting.
_clear_stale_block() {
[[ -z "${REMOTE_SERVER:-}" ]] && return
local removed=0
while iptables -C OUTPUT -d "$REMOTE_SERVER" -j DROP 2>/dev/null; do
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove stale iptables block on $REMOTE_SERVER"
return
fi
iptables -D OUTPUT -d "$REMOTE_SERVER" -j DROP 2>/dev/null || break
(( removed++ ))
done
if [[ "$removed" -gt 0 ]]; then
warn "$ICON_SHIELD Removed $removed stale iptables block(s) on $REMOTE_SERVER from a previous run"
notify "Fallback test cleared $removed stale iptables block(s) on $(hostname) — a previous test was killed before cleanup" \
"Fallback Test" "warning"
fi
}
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
@@ -149,7 +242,8 @@ if [[ "$EUID" -ne 0 ]]; then
fi
# FALLBACK_ENABLED gate — no point testing if fallback is disabled
if [[ "${FALLBACK_ENABLED:-false}" == false ]]; then
# Fail-closed, matching fallback.sh — anything not exactly "true" counts as disabled.
if [[ "${FALLBACK_ENABLED:-false}" != "true" ]]; then
warn "FALLBACK_ENABLED=false — fallback test aborted"
warn "Enable fallback in master.conf before running this test"
exit 0
@@ -238,6 +332,11 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━
echo ""
echo "━━━ $ICON_SHIELD Phase 1 — Pre-flight ━━━"
# Must run before the reachability check below — a stale DROP rule from a killed run makes
# the partner look unreachable, and the test would abort blaming the network for its own
# leftover.
_clear_stale_block
# Remote reachable
if ping_remote; then
log "$REMOTE_SERVER_NAME is reachable"
@@ -283,6 +382,27 @@ else
warn "No state file found — assuming NORMAL (first run)"
fi
# fallback.sh must actually be RUNNING, not merely enabled
#
# Every phase after this one waits for the daemon to change state. FALLBACK_ENABLED=true says
# it is allowed to run; it does not say array_started.sh launched it, or that it is still alive.
# Without this the test passes pre-flight, drops a real iptables rule on the partner, waits
# FALLBACK_TEST_BLOCK_WAIT for a transition nothing is there to make, and fails Phase 3 blaming
# fallback detection. Only the EXIT trap gets connectivity back.
#
# In --dry-run nothing is blocked and nothing is waited on, so a dead daemon is worth saying but
# not worth aborting for — the walkthrough still shows the operator the shape of the run.
if pgrep -f "Fallback/fallback\.sh" >/dev/null 2>&1; then
log "fallback.sh daemon is running"
elif [[ "$DRY_RUN" == true ]]; then
warn "fallback.sh is NOT running — a real test would abort here"
else
error "fallback.sh is not running — nothing would detect the outage this test creates"
error "Start it with array_started.sh, or run with --dry-run to walk the phases"
phase_fail "Pre-flight"
exit 1
fi
# Tier 1 containers configured
if [[ ${#TIER1_CONTAINERS[@]} -eq 0 ]]; then
error "No Tier 1 containers configured for $MY_ID$REMOTE_ID"
+76 -2
View File
@@ -12,7 +12,21 @@
# Does NOT download media, search indexers, or manage applications. Has no
# side effects — no file writes, no API calls, no deletions.
#
# Currently paired with: Media/playback_aware_lidarr_discovery.sh
# Currently paired with: Arrs_Stack/playback_aware_lidarr_discovery.sh
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# A library, not a program. Consumers source it and drive the pipeline themselves:
#
# 1. score_candidate() — sum the four weighted component scores
# 2. apply_temporal_decay() — reduce by one unit per 30 days of age, floored at 0
# 3. is_duplicate_candidate() — check the consumer's own history file
# 4. make_decision() — ACCEPT or REJECT against the consumer's threshold
#
# Every input is supplied by the caller and every output is returned to it. The engine holds
# no state between calls, reads no config, and never acts on its own verdict.
#
# ==============================================================================================
# DESIGN PRINCIPLES
@@ -38,6 +52,60 @@
# the consumer's concern — the engine never acts on its own verdict.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# No Side Effects — Structural, Not Incidental
# Writes no files, makes no API calls, deletes nothing, starts nothing. This is the
# safeguard: a scoring mistake here can only ever produce a wrong number, never a wrong
# action. Keep it that way — the moment this library acquires a side effect, every consumer
# inherits it silently.
#
# No Root, No Lock, No detect_hosts — Deliberate
# Correct for a sourced library and should not be "fixed" to match the executable scripts.
# There is no state for a lock to protect, no privileged operation to gate, and no
# host-specific config to alias. It runs entirely inside the caller's process.
#
# Caller Owns the Verdict
# ACCEPT/REJECT is a return value, not an instruction. Nothing here can cause a candidate
# to be added, removed, or downloaded — the consumer decides what a verdict means.
#
# Literal Duplicate Matching
# is_duplicate_candidate() matches with grep -Fx, treating the candidate as data rather
# than a pattern. Artist and title strings routinely contain regex metacharacters, and a
# false positive here silently discards a genuinely new candidate.
#
# Decay Floors at Zero
# apply_temporal_decay() clamps at 0, so an old signal can never become a negative score
# that drags an otherwise-passing candidate below threshold.
#
# Integer Arithmetic Throughout
# All scoring is integer. No floating point means no locale-dependent decimal parsing and
# no rounding drift between hosts.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# None, by design. The engine reads no conf file and no environment variable.
#
# Thresholds, weights and strictness profiles live with the consumer — see
# LIDARR_DISCOVERY_* / SONARR_DISCOVERY_* / RADARR_DISCOVERY_* in master.conf. That is what
# keeps the core domain-agnostic: music strictness cannot leak into TV intake, because the
# engine never learns which domain it is scoring for.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# None — this file is sourced, never executed:
#
# source "$SCRIPT_DIR/../Kernel/decision_engine.sh"
#
# It has no argument parsing, no --dry-run and no --status, because it takes no action that
# a dry run could suppress.
#
# ==============================================================================================
# FUNCTIONS
# ==============================================================================================
#
@@ -65,6 +133,7 @@ score_candidate() {
local popularity_score="${2:-0}"
local recency_score="${3:-0}"
local quality_score="${4:-0}"
local TOTAL_SCORE
TOTAL_SCORE=$(( \
user_score + \
@@ -110,7 +179,12 @@ is_duplicate_candidate() {
local candidate="$1"
local history_file="$2"
grep -qi "^${candidate}$" "$history_file" 2>/dev/null
# -F -x, not "^$" anchors: the candidate is data, not a pattern. Interpolating it into a
# regex makes every metacharacter in an artist or title active — "R.E.M." matches "RxExMy",
# and an unbalanced bracket makes grep error out entirely. Either way the caller reads the
# result as "already seen" and silently skips something genuinely new. -F disables regex,
# -x anchors the whole line, which is exactly what the anchors were reaching for.
grep -qiFx -- "$candidate" "$history_file" 2>/dev/null
}
# ── make_decision ─────────────────────────────────────────────────────────────
+88 -5
View File
@@ -3,11 +3,23 @@
Getting a fresh two-server ecosystem running from scratch.
For system overview see [README.md](README.md). For individual subsystem detail see folder READMEs and script headers.
**Ten steps, and they're in this order for a reason.** Each one assumes the previous one
actually worked — not that you ran it, that it *worked*. Every step below ends with a way to
check, and skipping those checks is how you end up three steps later debugging the wrong
thing entirely.
The steps that hurt most when rushed are **3** (naming) and **8** (testing failover). Step 3
because renaming anything afterwards means chasing it through every conf, and step 8 because
untested failover isn't redundancy — it's a guess you haven't checked yet.
Budget an evening. It is not hard, but it is not five minutes either.
---
## ━━━ BEFORE YOU START ━━━
Things that must be in place before you touch any scripts.
None of this is optional and none of it is Varaverk's job to install. Get these in place
first — every step after here assumes they're already true.
---
@@ -157,6 +169,21 @@ Replace these with your actual unRAID server hostnames. These must match exactly
`detect_hosts()` compares the running server's hostname against these two values to
know which server it is on. Everything else in the ecosystem flows from this.
**Get this wrong and nothing works, but nothing errors either.** If neither value matches,
`MY_ID` is never set, and every script that depends on it either exits early or resolves
`${MY_ID}_SOMETHING` to an empty variable and quietly takes the wrong branch. Copy the value
straight out of `hostname` on each box rather than typing what you think it is:
```bash
hostname # run this on each server, paste the exact output
```
> **The 15-character trap.** unRAID truncates the Server Name to 15 characters for NetBIOS.
> If your name is longer, what you set in the WebGUI and what `hostname` returns are two
> different strings. `detect_hosts()` has a fallback that matches a truncated 15-char
> hostname against a longer configured value — but only when the live hostname is
> *exactly* 15 characters. Don't rely on it. Use the real `hostname` output.
---
### ── 3b. host1.conf — HOST1 identity ────────────────────────────────────────
@@ -206,15 +233,27 @@ HOST1_CRITICAL_SYNC_SHARES=(
"/mnt/user/appdata-Fallback/Critical-Data|critical-data"
)
# Containers fallback.sh starts on HOST2 when HOST1 goes down (Tier 1 = immediate)
HOST2_FALLBACK_HOST1_COVERS_HOST2_TIER1=() # HOST2's own covers for HOST1, and vice versa
HOST1_FALLBACK_HOST2_COVERS_HOST1_TIER1=(
# Containers to start when HOST1 goes down (Tier 1 = immediate)
# Lives in host1.conf. Named for the host being COVERED, not the host doing the covering.
FALLBACK_HOST1_TIER1=(
"Emby"
"VaultWarden"
"NginxProxyManager"
)
```
**Read that naming carefully — it catches people.** The variable is
`FALLBACK_${REMOTE_ID}_TIER${N}`, and `REMOTE_ID` is *the host that went down*. So
`FALLBACK_HOST1_TIER1` is the list HOST2 reads when HOST1 is unreachable. It defines
"what HOST1 needs covering", not "what HOST2 runs".
It lives in `host1.conf` for the same reason — HOST1 owns the description of its own
service stack. HOST2 receives it through the conf cache rather than keeping its own
opinion about what HOST1 runs. One list, one owner, no drift.
Tiers 24 activate progressively after `HOST1_TIER2_DELAY` etc., so a brief outage never
drags the entire remote stack across.
For full configuration reference see `Manual-Fallback.md`, `Manual-Rsync.md`,
and `Manual-Watchdogs.md`.
@@ -305,7 +344,31 @@ entries needed. The plugin handles all triggers natively:
- **Cron** → `Plugin/unraid/event/disks_mounted/rebuild_cron` rebuilds the cron file from `schedule.json` on every boot
Configure via the Varaverk plugin Scheduler tab (or edit `schedule.json` directly).
Individual scripts are never scheduled — only orchestrators.
The built-in job list schedules orchestrators, never individual repo scripts directly —
but the Scheduler tab's **Custom Scripts** card is the one place individual scripts
*are* scheduled directly (see below).
---
### ── Custom Scripts ────────────────────────────────────────────────────────────
The Scheduler tab has a **Custom Scripts** card for one-off scripts that aren't part of
the repo's orchestrator pipeline — personal tooling, quick fixes, anything you don't
want to wire into `master.conf`.
Scripts live in `/boot/config/plugins/user.scripts/Varaverk/Scripts/` — deliberately
**outside** the Varaverk git repo (that folder is never pushed to GitHub), in the same
place the Unraid User Scripts plugin keeps its own scripts, so it's a folder location
admins are already used to.
Two ways to get a script there:
- Click **+ Create Script** on the Scheduler tab — opens an inline editor, writes the
file to that folder, and adds a `schedule.json` entry automatically.
- Drop any `.sh` file into the folder yourself (e.g. via terminal, or Unraid's own
Custom Scripts / User Scripts plugin pointed at the same path). The Scheduler tab
**auto-detects** it — discovery is a folder scan, not a registry, so it doesn't matter
how the file got there. It shows up disabled with no cron until you configure one.
---
@@ -431,6 +494,18 @@ bash /boot/config/plugins/varaverk/Fallback/fallback_test.sh --log
The test runs in phases — blocks HOST1's Tailscale IP, waits for `fallback.sh` to
detect it and start Tier 1 containers, then unblocks and waits for handback.
**Do not skip this step.** Everything up to here you can verify by reading output. Failover
is the one part you cannot confirm by looking at it — a typo in a tier list, a container name
that doesn't exist on the other side, a DDNS container that was renamed six months ago: all
of it sits there looking completely fine until the moment it's needed.
The test costs you twenty minutes and no downtime. The alternative is finding out at 2am,
during the outage, when you have exactly one chance to get it right.
**Watch the handback as closely as the failover.** Coming back is the harder half — it has to
stop the remote's DDNS, sync the data back, start the local containers, and only then bring
local DDNS up. Failover starting correctly tells you nothing about whether handback does.
See [Manual-Fallback.md](Fallback/Manual-Fallback.md) for what each phase does and
how to interpret the output.
@@ -627,6 +702,14 @@ Each subsystem has a README with the design decisions and a Manual with the conf
reference and troubleshooting. The `--status` flag on any script shows the current
configuration and state.
**Turn things on one at a time and give each one a few days.** Everything below is off by
default on a fresh install, and that's deliberate — a stack where six new subsystems went
live the same night is a stack where you have no idea which one to blame. Enable, watch it
through a full daily cycle, then enable the next.
Anything that deletes files — the arr cleanup scripts especially — gets a `--dry-run --log`
first. Read the list. Every time, not just the first time.
```
Watchdogs/ → README-Watchdogs.md configure memory limits, container lists
Media/ → README-Media.md enable arr cleanup, discovery scripts
+41 -626
View File
@@ -1,8 +1,11 @@
# ━━━━━ MEDIA — Manual ━━━━━
Config reference, procedures, operational workflows.
Config reference, procedures, operational workflows for Media/ scripts.
For overview see README-Media.md. For per-script detail see script headers.
For arr stack config and procedures (cleanup, release fixer, sync, discovery) see
`Arrs_Stack/Manual-Arrs_Stack.md`.
---
## ━━━ PERMISSIONS MODEL ━━━
@@ -32,48 +35,6 @@ Once fixed, this script corrects 0 files per run — it becomes a pure daily fai
---
## ━━━ ARR CLEANUP — FILE CLASSIFICATION ━━━
Every file found on disk during an arr cleanup run falls into exactly one category:
```
TRACKED → arr API returned this exact path → leave it alone
PROTECTED → matches ARR_PROTECTED_PATTERNS → never delete
ORPHAN → media extension, not tracked, old enough → delete
JUNK → not a media extension, not protected → delete (any age)
RECENT → not tracked, under ARR_ORPHAN_AGE days → skip (may be mid-import)
```
**Why protected patterns are critical:** arrs generate artwork (`*.jpg`), metadata
(`*.nfo`), and subtitles/lyrics that do NOT appear in the tracked file API response.
Without protection, these would be classified as orphans and deleted — removing cover art
from every album, every movie poster, every TV show thumbnail. Requires a full rescan
to recover. Never remove artwork extensions from protected patterns.
---
## ━━━ ARR CLEANUP — SAFETY LAYERS ━━━
All 7 layers must pass before any file is touched. There is no way to push through a
failed safety check without the explicit override flag.
```
1. Container running + healthy — a stopped container has an empty API
2. API reachable — no API = no tracked file list = everything looks orphaned
3. API version matches — major version must match tested version in master.conf
4. Item count > 0 — no artists/series/movies = something is wrong with DB
5. Tracked file count > 0 — empty response = everything would be deleted
6. Tracked count >= MIN_TRACKED_PCT — dramatic drop from last run = abort and alert
7. Deletion size < MAX_DELETE_GB — last line of defense against misconfigured root path
```
Layer 7 is the catastrophic failure prevention. A misconfigured root path — pointing
cleanup at the wrong directory — means the API returns zero tracked files for a root
that actually contains thousands. Everything walks as an orphan. Everything gets deleted.
`LIDARR/SONARR/RADARR_MAX_DELETE_GB` requires `--i-know-what-im-doing` to proceed past it.
---
## ━━━ CONFIGURATION — master.conf ━━━
### Permissions
@@ -122,165 +83,28 @@ MEDIA_FILE_PATTERNS=(
---
### Lidarr Cleanup Thresholds
```bash
LIDARR_ORPHAN_AGE=7 # days — files newer than this are RECENT (mid-import window)
LIDARR_MIN_TRACKED_PCT=80 # abort if API returns < 80% of last known count
LIDARR_MAX_DELETE_GB=50 # require --i-know-what-im-doing above this
LIDARR_IMPORT_SCAN_TIMEOUT=600 # seconds to wait for pre-flight import scan
LIDARR_VERSION_MAJOR=3 # expected Lidarr major version (API safety check)
LIDARR_EXTENSIONS=("flac" "mp3" "m4a" "wav" "aac" "ogg" "opus" "wma")
LIDARR_PROTECTED_PATTERNS=("*.jpg" "*.jpeg" "*.png" "*.nfo" "*.lrc")
LIDARR_TRACKED_COUNT_FILE="$DATA_DIR/lidarr_tracked.count" # persistent baseline
ARR_CLEANUP_STATS="$DATA_DIR/arr_cleanup_stats.db" # read by coffee report
```
---
### Sonarr Cleanup Thresholds
```bash
SONARR_ORPHAN_AGE=7
SONARR_MAX_DELETE_GB=50
SONARR_IMPORT_SCAN_TIMEOUT=600
SONARR_VERSION_MAJOR=4
SONARR_EXTENSIONS=("mkv" "mp4" "avi" "m4v" "ts" "wmv" "mov")
SONARR_PROTECTED_PATTERNS=("*.jpg" "*.jpeg" "*.png" "*.nfo" "*.srt" "*.sub" "*.ass" "*.ssa")
```
Note: `*.ts` IS in extensions — transport stream is used for Live TV recordings tracked
by Sonarr. Orphaned `.ts` recordings should be cleaned like any other orphaned episode.
---
### Radarr Cleanup Thresholds
```bash
RADARR_ORPHAN_AGE=7
RADARR_MAX_DELETE_GB=50
RADARR_IMPORT_SCAN_TIMEOUT=600
RADARR_VERSION_MAJOR=6
RADARR_EXTENSIONS=("mkv" "mp4" "avi" "m4v" "wmv" "mov")
RADARR_PROTECTED_PATTERNS=("*.jpg" "*.jpeg" "*.png" "*.nfo" "*.srt" "*.sub" "*.ass" "*.ssa")
```
---
### Arr Sync
```bash
ARR_SYNC_ENABLED=true
ARR_SYNC_BLOCKLIST="$DATA_DIR/arr_sync_blocklist.tsv" # tombstone file
ARR_SYNC_CONNECT_TIMEOUT=10 # SSH connect timeout in seconds
ARR_SYNC_API_TIMEOUT=60 # curl API call timeout in seconds
DOCKER_APPDATA_BASE=/mnt/user/appdata
ARR_SYNC_LIDARR_PORT=8686
ARR_SYNC_SONARR_PORT=8989
ARR_SYNC_RADARR_PORT=7878
```
---
### Arr Recovery
```bash
ARR_IMPORT_RECOVERY_AGE=6 # hours — items newer than this are skipped
SONARR_VERSION_MAJOR=4
RADARR_VERSION_MAJOR=6
LIDARR_VERSION_MAJOR=3
ARR_RECOVERY_STATS="$DATA_DIR/arr_recovery_stats.db" # read by coffee report
```
---
### TMDb / TVDB Removed
```bash
RADARR_DROPPED_ADD_EXCLUSION=true # add removed movies to Radarr import exclusion
SONARR_DROPPED_ADD_EXCLUSION=true # add removed series to Sonarr import exclusion
```
---
### Lidarr Missing Art
```bash
FANART_API_KEY="your-fanart-tv-api-key"
LASTFM_API_KEY="your-lastfm-api-key"
LIDARR_ART_MIN_SIZE=5000 # minimum valid download size in bytes
LIDARR_ART_MAX_PARALLEL=4 # concurrent background download jobs
LIDARR_ART_RETRIES=2 # download retry attempts per image
LIDARR_ART_SLEEP_BETWEEN=1 # seconds between fanart.tv API calls (rate limit)
```
---
### Lidarr Discovery
```bash
LIDARR_DISCOVERY_THRESHOLD=70 # minimum score for Stage 1 seeds and Stage 2 adds
LIDARR_DISCOVERY_LOOKBACK_DAYS=7 # Emby play history window in days
LIDARR_DISCOVERY_MIN_PLAYS=3 # min plays before an artist is evaluated as a seed
LIDARR_DISCOVERY_MAX_ADDS=5 # max seeds (Stage 1) and max adds (Stage 2) per run
LIDARR_DISCOVERY_USER_CAP_PCT=35 # max % any one user contributes to play weight
LIDARR_DISCOVERY_REJECT_COOLDOWN=30 # days before re-evaluating a Stage 2 reject
LIDARR_DISCOVERY_HISTORY="$DATA_DIR/lidarr_discovery_history.db"
```
Requires `HOST*_LASTFM_API_KEY` in `host*.conf`.
---
### Radarr Discovery
```bash
RADARR_DISCOVERY_THRESHOLD=52 # minimum score to add a candidate
RADARR_DISCOVERY_LOOKBACK_DAYS=30 # Emby watch history window in days
RADARR_DISCOVERY_MAX_SEEDS=5 # max seed movies from Stage 1
RADARR_DISCOVERY_MAX_ADDS=5 # max movies to add per run
RADARR_DISCOVERY_MIN_VOTE_COUNT=100 # min TMDB votes for a candidate
RADARR_DISCOVERY_MIN_RATING=60 # min TMDB vote_average × 10 (60 = 6.0/10)
RADARR_DISCOVERY_REJECT_COOLDOWN=60 # days before re-evaluating a rejected movie
RADARR_DISCOVERY_SEED_LIBRARIES=("Movies") # Emby libraries to draw seeds from
RADARR_DISCOVERY_HISTORY="$DATA_DIR/radarr_discovery_history.db"
```
Requires `HOST*_TMDB_API_KEY` in `host*.conf`.
---
### Sonarr Discovery
```bash
SONARR_DISCOVERY_THRESHOLD=52 # minimum score to add a candidate
SONARR_DISCOVERY_LOOKBACK_DAYS=14 # Emby episode history window in days
SONARR_DISCOVERY_MAX_SEEDS=5 # max seed series from Stage 1
SONARR_DISCOVERY_MAX_ADDS=3 # max shows to add per run (TV is a larger commitment)
SONARR_DISCOVERY_MIN_VOTE_COUNT=50 # min TMDB votes for a candidate
SONARR_DISCOVERY_MIN_RATING=65 # min TMDB vote_average × 10 (65 = 6.5/10)
SONARR_DISCOVERY_REJECT_COOLDOWN=60 # days before re-evaluating a rejected show
SONARR_DISCOVERY_USER_EPISODE_CAP=8 # max episodes per user in seed scoring
SONARR_DISCOVERY_MONITOR_MODE="all" # "all" = all seasons monitored; "future" = upcoming only
SONARR_DISCOVERY_HISTORY="$DATA_DIR/sonarr_discovery_history.db"
# SONARR_EMBY_LIBRARIES is shared with emby_to_sonarr_sync — see Orchestrator Job Order
```
Requires `HOST*_TMDB_API_KEY` in `host*.conf`.
> **MONITOR_MODE note:** Use `"all"` (default) to have Sonarr search all existing seasons
> after adding a show. `"future"` only marks upcoming seasons as monitored — shows where
> all seasons have already aired will appear unmonitored and Sonarr will not search for them.
---
### Play State Sync
```bash
PLAY_STATE_SYNC_ENABLED=true # toggle entire sync
PLAY_STATE_SYNC_LOOKBACK_DAYS=30 # history window for played items
PLAY_SYNC_ENABLED=true # toggle entire sync
PLAY_SYNC_REMOTE=true # sync across hosts via Tailscale
# false = local servers only (this host's Emby + Jellyfin)
PLAY_SYNC_TYPES="Movie,Episode" # item types to sync — Audio excluded, music library too large
PLAY_SYNC_FAV_TYPES="MusicArtist,MusicAlbum,Movie,Series" # favourites, union sync, never unmarks
PLAY_SYNC_PROBE=true # skip per-item work when nothing changed since last run
PLAY_SYNC_PROBE_MAX_AGE_HOURS=24 # force a full comparison when the fingerprint is older
PLAY_SYNC_HANDBACK_RETRIES=5 # fallback handback: attempts before DNS cutover proceeds
PLAY_SYNC_HANDBACK_RETRY_DELAY=60 # seconds between those attempts
```
> **There is no date/lookback filter, and one must not be re-added.** An earlier version
> gated on played-date; it was removed once the real cost was measured — the 30-minute
> runtime was fork overhead per item, not API volume or item count. The fix was jq epoch
> parsing plus the response-hash probe below. Re-introducing a date window would reduce
> correctness (older items silently stop syncing) without meaningfully reducing runtime.
Emby and Jellyfin servers configured per-host:
```bash
@@ -293,41 +117,8 @@ HOST1_JELLYFIN_API_KEY=""
---
### Upgrade Webhook
```bash
# master.conf
WEBHOOK_PORT=7821 # 0 = disable listener
WEBHOOK_SECRET="" # auto-generated on first start if empty
```
When `WEBHOOK_PORT=0`, `start_webhook_listener.sh` exits cleanly and no listener starts.
When `WEBHOOK_SECRET` is empty, a 32-byte hex secret is generated on first start and
written back to `master.conf`. Run `Tools/webhook_setup.sh` to register the URL in arrs.
Webhook log: `/var/log/varaverk/upgrade_webhook.log`
---
### Orchestrator Job Order
```bash
MEDIA_MAINTENANCE_JOBS=(
"Media/media_shares_permissions.sh" # 1. permissions — always first
"Media/media_cleaner.sh anime" # 2. junk removal — before orphan scan
"Media/media_cleaner.sh media" # 3.
"Media/lidarr_cleanup.sh" # 4. arr cleanup — after permissions + clean
"Media/sonarr_cleanup.sh" # 5.
"Media/radarr_cleanup.sh" # 6.
)
```
---
## ━━━ CONFIGURATION — host*.conf ━━━
### host1.conf
```bash
# Shares this server applies permissions to
HOST1_MEDIA_PERMISSION_SHARES=(
@@ -356,224 +147,6 @@ HOST1_MEDIA_CLEAN_FOLDERS=(
"/mnt/user/stand-up_comedy"
"/mnt/user/Tv_Shows"
)
# Arr connection details — must match arr settings exactly
HOST1_LIDARR_URL="http://192.168.50.2:8686"
HOST1_LIDARR_API_KEY="..."
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music"
HOST1_LIDARR_PATH_MAP="" # container→host path translation if needed
HOST1_SONARR_URL="http://192.168.50.2:8989"
HOST1_SONARR_API_KEY="..."
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
HOST1_SONARR_PATH_MAP=""
HOST1_RADARR_URL="http://192.168.50.2:7878"
HOST1_RADARR_API_KEY="..."
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
HOST1_RADARR_PATH_MAP=""
HOST1_EMBY_URL="http://192.168.50.2:8096"
HOST1_EMBY_API_KEY="..."
```
> **LIDARR/SONARR/RADARR_MUSIC/TV/MOVIES_ROOT must exactly match the Root Folder path in
> the arr's own settings.** Arr UI → Settings → Media Management → Root Folders.
> A mismatch means every file on disk looks untracked — all appear as orphans.
> MAX_DELETE_GB is the only thing standing between a path mismatch and losing your library.
---
## ━━━ SAFE TESTING PROCEDURE ━━━
> **The arr cleanup scripts permanently delete files.** There is no recycle bin, no undo.
> Follow this procedure on first use, after any root path change, after any API key change,
> and after any significant arr library change.
### Step 1 — Dry Run With Full Logging
```bash
lidarr_cleanup.sh --dry-run --log
sonarr_cleanup.sh --dry-run --log
radarr_cleanup.sh --dry-run --log
```
### Step 2 — Review the Output
```
Are TRACKED files the ones you expect?
→ Known arr-managed files should show as TRACKED
→ If they show as ORPHAN, the root path is wrong — STOP
Is the ORPHAN count reasonable?
→ Healthy cleanup removes dozens to hundreds, not tens of thousands
→ Large count = stop, investigate root path before proceeding
Are PROTECTED patterns working?
→ Artwork (*.jpg) and subtitles (*.srt) must show as PROTECTED
→ If they show as ORPHAN, check PROTECTED_PATTERNS config
Are RECENT files being correctly skipped?
→ Files downloaded in the last 7 days should show as RECENT, not ORPHAN
```
### Step 3 — Check Numbers if Something Looks Wrong
```bash
# Root path mismatch? Compare these:
# Lidarr UI: Settings → Media Management → Root Folders
# Sonarr UI: Settings → Media Management → Root Folders
# Radarr UI: Settings → Media Management → Root Folders
# Must exactly match LIDARR_MUSIC_ROOT / SONARR_TV_ROOT / RADARR_MOVIES_ROOT
# Is the arr running?
docker ps | grep -E "Lidarr|Sonarr|Radarr"
# Library scan not complete?
# Trigger manual scan in arr UI and wait for completion
```
### Step 4 — Run Live
```bash
# Only after dry run review passes.
lidarr_cleanup.sh
sonarr_cleanup.sh
radarr_cleanup.sh
```
### Step 5 — Verify in Arr UI
```
Library count — should not have dropped significantly
healthy cleanup removes a small number, not a large percentage
Missing files — check if any monitored content shows as missing
Emby library — should show no ghost entries (notify_emby_scan handles this automatically)
```
---
## ━━━ PROCEDURES ━━━
### Adding a New Arr
```bash
# 1. Copy radarr_cleanup.sh as template
cp radarr_cleanup.sh readarr_cleanup.sh
# 2. Replace RADARR_ prefix with READARR_ throughout
# Update API endpoint, tracked file API path, extension list, protected patterns
# 3. Add to host*.conf
HOST1_READARR_URL="http://192.168.50.2:8787"
HOST1_READARR_API_KEY="your-api-key"
HOST1_READARR_BOOKS_ROOT="/mnt/user/Books"
# 4. Add thresholds to master.conf
READARR_ORPHAN_AGE=7
READARR_MAX_DELETE_GB=50
READARR_EXTENSIONS=("epub" "pdf" "mobi" "azw3" "cbz" "cbr")
READARR_PROTECTED_PATTERNS=("*.jpg" "*.jpeg" "*.png" "*.nfo")
# 5. Add to MEDIA_MAINTENANCE_JOBS in master.conf
MEDIA_MAINTENANCE_JOBS=(
...existing jobs...
"Media/readarr_cleanup.sh"
)
```
media_management.sh picks it up automatically. No orchestrator changes needed.
Run `--dry-run --log` before scheduling.
---
### Managing the Arr Sync Blocklist
```bash
# Add item to blocklist (removes from all arrs + tombstones the ID)
arr_sync.sh --blocklist-add lidarr <musicbrainz-artist-id> "reason"
arr_sync.sh --blocklist-add sonarr <tvdb-series-id> "reason"
arr_sync.sh --blocklist-add radarr <tmdb-movie-id> "reason"
# Remove from blocklist (un-tombstones the ID — does NOT re-add to arrs)
arr_sync.sh --blocklist-remove lidarr <id>
# View all blocklisted IDs
arr_sync.sh --blocklist-list
```
`--blocklist-add` is the only destructive operation — it simultaneously:
1. Writes the tombstone entry to the blocklist TSV file
2. Deletes the item from the local arr API (no file deletion)
3. SSHes each remote node and deletes from their arr API
Files become orphans on all nodes — arr_cleanup removes them on the next run.
---
## ━━━ TROUBLESHOOTING ━━━
### Arr Cleanup Deleting Files It Shouldn't
```
1. Check the protected patterns — artwork and subtitles must be listed
LIDARR_PROTECTED_PATTERNS / SONARR_PROTECTED_PATTERNS / RADARR_PROTECTED_PATTERNS
2. Check the root path matches arr settings exactly
Run: lidarr_cleanup.sh --status (shows configured root path)
Compare: Lidarr UI → Settings → Media Management → Root Folders
3. Check if files are truly orphaned
Run: lidarr_cleanup.sh --dry-run --log
Look for the specific file — verify it shows ORPHAN, not PROTECTED or TRACKED
```
### Arr Cleanup Aborting at Safety Layer 6 (Tracked Count Drop)
```
API returned far fewer tracked files than last run.
Possible causes:
- Arr database was recently rebuilt from scratch
- Large manual library removal
- Path map mismatch after arr migration
If intentional (library intentionally reduced):
Delete LIDARR_TRACKED_COUNT_FILE to reset the baseline
Run cleanup once — it will establish a new baseline
If unintentional:
Investigate before proceeding — the arr may have a problem
```
### Arr Sync Not Picking Up New Content
```
Is ARR_SYNC_ENABLED=true in master.conf?
Can this host SSH to the remote without password?
→ ssh -i [SSH_KEY] root@[remote-tailscale-ip] "hostname"
Is the arr accessible on the remote?
→ arr_sync.sh --status (shows each node's arr reachability)
→ arr_sync.sh --log (verbose output per-node, per-arr)
Is the item in the blocklist?
→ arr_sync.sh --blocklist-list
```
### Emby Still Showing Ghost Entries After Cleanup
```
notify_emby_scan() is called automatically after every arr cleanup deletion.
If ghosts persist:
1. Is Emby's API responding?
curl -s "http://[emby-ip]:8096/System/Info/Public"
2. Is EMBY_URL / EMBY_API_KEY correct in host*.conf?
Run: sonarr_cleanup.sh --status (shows Emby config)
3. Trigger manually in Emby:
Library → Manage Library → Clean Missing Files
```
---
@@ -582,16 +155,11 @@ If ghosts persist:
All scripts have two output levels controlled by `--log`.
Without `--log`, each script processes silently and always concludes with a summary
block: identity, duration, counts (files removed, items added, arrs cleaned), and a
status line. Warnings and errors are always visible.
Without `--log`, each script processes silently and concludes with a summary block.
Warnings and errors are always visible.
With `--log`, per-item detail appears: individual titles being processed, API query
progress, per-node sync results, and per-file examination output. Use this when
debugging unexpected results or validating configuration before the first scheduled run.
Dry-run output follows the same tiers — `--dry-run` alone shows the summary of what
would happen; `--dry-run --log` shows the full per-item preview list.
With `--log`, per-item detail appears individual shares being processed, file counts,
per-server sync results.
---
@@ -638,44 +206,27 @@ Show every file examined, not just those removed.
---
### lidarr_cleanup.sh / sonarr_cleanup.sh / radarr_cleanup.sh
`[script] --dry-run --log`
Preview every classification decision. **Always run this first.** See Safe Testing Procedure.
`[script]`
Live run — deletes confirmed orphans and junk, triggers Emby clean.
`[script] --log`
Live run with verbose per-file output.
`[script] --status`
Show configuration, API status, tracked file count, and last run stats.
`[script] --i-know-what-im-doing`
Bypass the MAX_DELETE_GB size threshold. Required when deletion exceeds the configured
limit. Long flag name is intentional — cannot be added accidentally.
`[script] --skip-strike-list`
Bypass the ORPHAN_AGE age check. Deletes RECENT files too — files that are under the
age threshold. Use when you know recent downloads are actually orphans.
`[script] --i-know-what-im-doing --skip-strike-list`
**NUCLEAR MODE** — age check and size threshold both bypassed. Deletes on first pass.
Use when you want a clean one-pass wipe of everything the arr doesn't track.
No recovery possible after deletion.
---
### play_state_sync.sh
`play_state_sync.sh`
Sync played/unplayed state and resume positions from local Emby to the remote Emby.
Only items played within PLAY_SYNC_DAYS are synced.
Sync played/unplayed state, resume positions and favourites across **every** configured Emby
and Jellyfin server — not just local→remote. Servers are discovered from every
`HOST*_TRANSCODE_SERVERS` entry, with remote hosts' localhost URLs rewritten to their
Tailscale IP. Newest `LastPlayedDate` wins; state only ever moves forward, never clears.
No age filter — every matched item is considered on every run. The **change probe** is what
keeps that cheap: the raw API responses are hashed and compared against the fingerprint from
the last successful run, and per-item processing is skipped entirely when nothing moved.
Fetches still happen every run, so nothing can be missed by the probe.
`play_state_sync.sh --full`
Ignore PLAY_SYNC_DAYS — sync all played items regardless of age. May be slow on large
libraries. Use after a new Emby install or database restore to rebuild full play history.
Bypass the change probe and force the full per-item comparison even when the fingerprint
matches. Use after a new Emby install or database restore, or when debugging a sync that
appears to be skipping work it should be doing.
`play_state_sync.sh --wait`
Wait for an in-progress run instead of exiting. For manual runs that would otherwise be
skipped by the scheduled every-30-minute pass. Used by `fallback.sh` during handback.
`play_state_sync.sh --dry-run`
Show what would be synced without writing any state.
@@ -685,139 +236,3 @@ Show configured servers, reachability, and user counts.
`play_state_sync.sh --log`
Verbose output — show each item comparison.
---
### arrs_failed_stalled_recovery.sh
`arrs_failed_stalled_recovery.sh`
Check all configured arrs for failed imports and stalled downloads. Blocklist + remove +
re-search for each problem item.
`arrs_failed_stalled_recovery.sh --dry-run`
Show what would be actioned per arr without making any changes.
`arrs_failed_stalled_recovery.sh --status`
Show configuration, arr reachability, and last recovery stats.
`arrs_failed_stalled_recovery.sh --log`
Verbose output per item per arr.
---
### arr_sync.sh
`arr_sync.sh`
Sync all arr types across all configured nodes.
`arr_sync.sh --dry-run`
Show what would be added/removed on each node without making changes.
`arr_sync.sh --status`
Show node configuration, arr reachability, and blocklist count.
`arr_sync.sh --log`
Verbose per-node, per-arr output.
`arr_sync.sh --blocklist-add [arr] [id] "[reason]"`
Remove item from all arrs and tombstone the ID. See Procedures above.
`arr_sync.sh --blocklist-remove [arr] [id]`
Remove tombstone — does NOT re-add item to arrs.
`arr_sync.sh --blocklist-list`
Show all tombstoned IDs.
---
### radarr_tmdb_removed.sh / sonarr_tvdb_removed.sh
`[script]`
Remove records for entries with status="deleted" (dropped from upstream database).
Files are kept. Import exclusion is added.
`[script] --delete-files`
Also delete associated files from disk. Most dropped entries have no files — they were
announced movies/series that were never downloaded.
`[script] --dry-run`
Preview what would be removed without making changes.
`[script] --status`
Show arr connection status and current count of dropped entries.
`[script] --log`
Verbose per-entry output.
---
### lidarr_missing_art.sh
`lidarr_missing_art.sh`
Fetch all missing album and artist artwork from fanart.tv and fallback sources.
Never overwrites existing files.
`lidarr_missing_art.sh --dry-run`
Show what would be downloaded without writing any files.
`lidarr_missing_art.sh --status`
Show configuration and API key status.
`lidarr_missing_art.sh --log`
Verbose per-album, per-artist output.
---
### playback_aware_lidarr_discovery.sh
`playback_aware_lidarr_discovery.sh`
Score Emby play history, run Last.fm getSimilar on top artists, add candidates above
threshold to Lidarr. Triggers ArtistSearch immediately after each successful add.
`playback_aware_lidarr_discovery.sh --dry-run`
Score and rank all Stage 1 seeds and Stage 2 candidates. No Lidarr API calls. No writes
to history file. Shows exactly what would be added and at what score.
`playback_aware_lidarr_discovery.sh --status`
Show config values, history file path and size, and API key status.
`playback_aware_lidarr_discovery.sh --log`
Verbose per-artist scoring output for both stages.
---
### playback_aware_radarr_discovery.sh
`playback_aware_radarr_discovery.sh`
Score recently watched Emby movies, run TMDB recommendations on seeds, add candidates
above threshold to Radarr. Triggers MoviesSearch immediately after each successful add.
`playback_aware_radarr_discovery.sh --dry-run`
Score and rank all Stage 1 seeds and Stage 2 candidates. No Radarr API calls. No writes
to history file.
`playback_aware_radarr_discovery.sh --status`
Show config values, history file path and size, and API key status.
`playback_aware_radarr_discovery.sh --log`
Verbose per-movie scoring output for both stages.
---
### playback_aware_sonarr_discovery.sh
`playback_aware_sonarr_discovery.sh`
Score recently watched Emby series (weighted by user diversity), run TMDB TV
recommendations on seeds, add candidates above threshold to Sonarr. Triggers SeriesSearch
immediately after each successful add.
`playback_aware_sonarr_discovery.sh --dry-run`
Score and rank all Stage 1 seeds and Stage 2 candidates. No Sonarr API calls. No writes
to history file.
`playback_aware_sonarr_discovery.sh --status`
Show config values, history file path and size, and API key status.
`playback_aware_sonarr_discovery.sh --log`
Verbose per-series scoring output — shows user diversity, recency, and volume scores per
seed; breadth, rating, and votes scores per candidate.
+55 -194
View File
@@ -1,21 +1,14 @@
# ━━━━━ MEDIA ━━━━━
Library health, consistency, sync, and behavior-driven discovery for a multi-server arr
stack. Correct permissions so arrs can manage files. Junk removal so orphan detection
isn't confused by scene debris. Library sync so every node tracks the same content.
Orphan cleanup against live arr APIs so deleted content actually leaves disk. Emby
notified automatically after every deletion. Watch state synced across Emby/Jellyfin
every 30 minutes. Quality upgrades propagate to all nodes immediately via webhook.
Weekly discovery adds new music, movies, and TV shows based on what you actually play.
Foundation-level media library management — permissions, junk removal, and play state
sync. These three scripts run before and independently of arr stack operations.
> **These scripts permanently delete files.** The arr cleanup scripts are protected by
> multiple safety layers that must all pass before anything is touched — but dry runs and
> log review are still the right first step on any new system or after any configuration
> change. The testing procedure in Manual-Media.md exists for a reason.
For arr stack scripts (orphan cleanup, release fixer, sync, discovery, webhooks) see
`Arrs_Stack/README-Arrs_Stack.md`.
---
## ━━━ THE PROBLEM THAT BUILT THIS ━━━
## ━━━ THE PROBLEMS THAT BUILT THIS ━━━
**Files Owned by Root That Arrs Can't Touch**
Download clients without explicit PUID/PGID write files owned by root. Arrs running as
@@ -33,124 +26,43 @@ noise, making it hard to spot actual orphaned media.
The fix: `media_cleaner.sh` runs before any arr cleanup and removes all known junk
patterns first. By the time arr cleanup runs, every untracked file is actual media.
**Deleted Shows and Removed Albums Still on Disk**
When you remove a series from Sonarr and the delete command fails — permission issue,
container wasn't running, path mismatch — the files stay permanently. Over years on an
active library this accumulates significantly.
The fix: arr cleanup scripts query the live API for every tracked file path, walk the
disk, and delete anything absent from the API response that's old enough to be past the
import window.
**Emby Showing Ghost Entries After Cleanup**
After arr cleanup deletes files, Emby still shows them until its next scheduled scan —
potentially hours later. Users see broken entries that produce "file not found" errors.
The fix: `notify_emby_scan()` is called automatically after every deletion. Triggers
Emby's "Clean Missing Files" task immediately.
**No Safety Net on Deletion Size**
A misconfigured root path — pointing cleanup at the wrong directory — means the API
returns zero tracked files for a root that actually contains thousands. Every file walks
as an orphan. Everything gets deleted. This is the catastrophic failure mode.
The fix: `LIDARR/SONARR/RADARR_MAX_DELETE_GB` — if total deletion size exceeds the
limit, the script stops and requires `--i-know-what-im-doing` to proceed. The flag name
is long and annoying by design. It cannot be added by accident.
**Watch State Diverging Across Servers**
With two Emby servers, played status and resume positions diverge — a film marked watched
on HOST1 shows as unwatched on HOST2. Two users on different servers get different
continue-watching rows.
The fix: `play_state_sync.sh` syncs watched/played state and resume positions every 30
minutes. Newest timestamp wins. Both servers always reflect the same play history.
---
## ━━━ WHAT THIS FOLDER DOES ━━━
Scripts divide into five functional areas:
**Library Foundation**
`media_shares_permissions.sh` — normalize ownership and permissions daily. Runs first in
every maintenance window because arr cleanup depends on correct ownership to delete files.
**Junk Removal**
`media_cleaner.sh` — remove scene debris and tool artifacts before orphan scan. Runs
second, before any arr cleanup, so orphan detection only encounters actual media files.
**Library Sync**
`arr_sync.sh` — full-mesh arr library sync across all nodes. Every node syncs with every
other — union model, no hierarchy. Once arrs agree on what to track, rsync spreads the
actual files. The architectural shift from file-first sync to arr-first sync.
**Orphan Cleanup**
`lidarr_cleanup.sh`, `sonarr_cleanup.sh`, `radarr_cleanup.sh` — API-verified orphan
removal. Five classification categories (TRACKED/PROTECTED/ORPHAN/JUNK/RECENT), seven
safety layers, automatic Emby notification after deletion.
**Database Hygiene**
`radarr_tmdb_removed.sh`, `sonarr_tvdb_removed.sh` — remove entries that upstream
databases have dropped (TMDb/TVDB status="deleted"). These generate health warnings in
arrs and can never be monitored or downloaded. Most are announced-but-never-released
entries. Files are kept by default — most have none.
**Library Enrichment**
`arrs_failed_stalled_recovery.sh` — detect and recover failed imports and stalled
downloads across all arrs. Blocklists the bad release and triggers a re-search — hands-
free overnight recovery.
`lidarr_missing_art.sh` — fetch missing album and artist artwork from fanart.tv and
fallback sources. Never overwrites existing files.
before any arr cleanup so orphan detection only encounters actual media files.
**Play State Sync**
`play_state_sync.sh` — syncs watched/played state and resume positions across all
configured Emby and Jellyfin servers. Newest timestamp wins. Runs every 30 minutes
via critical_sync_maintenance.sh.
**Upgrade Propagation**
`start_webhook_listener.sh` — Node.js HTTP server that receives Sonarr/Radarr/Lidarr
OnUpgrade webhooks. Continuous; started at array start. Writes to
`/var/log/varaverk/upgrade_webhook.log`.
`upgrade_webhook_handler.sh` — triggered by the webhook listener. Pushes the upgraded
item folder to every remote node immediately, then triggers an arr library rescan on
each remote so the upgraded file is accepted without triggering a redundant quality search.
**Discovery**
`playback_aware_lidarr_discovery.sh` — behavior-driven music discovery. Scores your
Emby play history, runs Last.fm getSimilar on top artists, adds the best matches to
Lidarr. 05 meaningful adds per week.
`playback_aware_radarr_discovery.sh` — behavior-driven movie discovery. Scores recently
watched movies, runs TMDB recommendations on seeds, adds top candidates to Radarr.
`playback_aware_sonarr_discovery.sh` — behavior-driven TV discovery. Scores recently
watched series weighted by user diversity, runs TMDB TV recommendations on seeds, adds
top shows to Sonarr. Multi-user design: one person binge-watching does not dominate seeds.
---
## ━━━ EXECUTION ORDER ━━━
Scripts run in multiple contexts — not all are part of the daily maintenance window:
**Daily via `media_management.sh` (MEDIA_MAINTENANCE_JOBS):**
**Daily via `daily_sync_maintenance.sh` (DAILY_MAINTENANCE_SCRIPTS — runs first):**
```
1. media_shares_permissions.sh — permissions first — arr cleanup depends on this
2. media_cleaner.sh anime — junk before orphan scan
3. media_cleaner.sh media
4. lidarr_cleanup.sh — after permissions + clean
5. sonarr_cleanup.sh
6. radarr_cleanup.sh
```
**Weekly arr sync (before rsync in weekly_sync_maintenance.sh):**
```
arr_sync.sh — arrs agree on library → rsync then spreads the files
```
**Daily recovery (separate schedule — 5am or every 6hr):**
```
arrs_failed_stalled_recovery.sh
```
**Weekly discovery (WEEKLY_MAINTENANCE_SCRIPTS in master.conf):**
```
playback_aware_lidarr_discovery.sh — score play history → Last.fm similar → add to Lidarr
playback_aware_radarr_discovery.sh — score watch history → TMDB recommendations → add to Radarr
playback_aware_sonarr_discovery.sh — score episode history → TMDB TV recommendations → add to Sonarr
```
These complete before any Arrs_Stack/ scripts run.
**Every 30 min via `critical_sync_maintenance.sh` (CRITICAL_MAINTENANCE_SCRIPTS):**
@@ -158,26 +70,6 @@ playback_aware_sonarr_discovery.sh — score episode history → TMDB TV recom
play_state_sync.sh — sync watched/resume state across Emby + Jellyfin
```
**Continuous (started by `array_started.sh`):**
```
start_webhook_listener.sh — Node.js webhook server; dispatch upgrade_webhook_handler.sh
```
**On every arr upgrade (triggered by webhook):**
```
upgrade_webhook_handler.sh — push upgraded folder to all remote nodes + trigger arr rescan
```
**Ad-hoc or separate schedule:**
```
lidarr_missing_art.sh — fetch missing artwork
radarr_tmdb_removed.sh — weekly cleanup of TMDb-dropped entries
sonarr_tvdb_removed.sh — weekly cleanup of TVDB-dropped entries
```
**Why permissions before everything else:** arr cleanup needs `nobody:users` ownership to
delete files. If a file is `root:root`, deletion fails silently — the file looks processed
but stays on disk.
@@ -186,96 +78,65 @@ but stays on disk.
orphans. Removing them first means orphan detection only finds actual media. Cleaner
output, more accurate detection.
**Why arr_sync before rsync:** once arrs agree on what to track, rsync spreads the actual
files. An upgrade on one node — new tracked path, old path no longer in API — gets
cleaned by arr_cleanup on all nodes after the next sync cycle.
---
## ━━━ HOST AWARENESS ━━━
Scripts run on both servers via `detect_hosts()`, which aliases all `HOST*_` prefixed vars
to their unprefixed names at runtime. No manual `HOST1`/`HOST2` comparisons exist in any script.
arr_sync.sh keeps all arr databases in bidirectional union — either server can download
to any share. Arr cleanup uses the union model: a file is only an orphan if neither arr
on either server has it indexed. Arr scripts check the aliased URL — if empty (arr not
configured on this host), they exit cleanly with no action.
to their unprefixed names at runtime.
Permissions and cleaner scripts run locally against each server's own shares, defined
in `HOST*_MEDIA_PERMISSION_SHARES` and `HOST*_MEDIA_CLEAN_FOLDERS` in host*.conf.
`play_state_sync.sh` reads both servers' Emby/Jellyfin endpoints from host*.conf and
syncs between them.
---
## ━━━ SCRIPTS IN THIS FOLDER ━━━
| Script | Role | When It Runs |
|--------|------|--------------|
| `media_shares_permissions.sh` | Apply `nobody:users` ownership + correct permissions to all media shares | Daily via `media_management.sh` |
| `media_cleaner.sh` | Remove junk files (two profiles: `anime` + `media`) | Daily via `media_management.sh` |
| `arr_sync.sh` | Full-mesh arr library sync — all nodes track the same content | Weekly before rsync |
| `lidarr_cleanup.sh` | Delete orphaned music files not tracked by Lidarr | Daily via `media_management.sh` |
| `sonarr_cleanup.sh` | Delete orphaned TV files not tracked by Sonarr | Daily via `media_management.sh` |
| `radarr_cleanup.sh` | Delete orphaned movie files not tracked by Radarr | Daily via `media_management.sh` |
| `arrs_failed_stalled_recovery.sh` | Auto-recover failed imports and stalled downloads | Daily (5am or every 6hr) |
| `lidarr_missing_art.sh` | Fetch missing album and artist artwork | Ad-hoc or separate schedule |
| `radarr_tmdb_removed.sh` | Remove movies dropped from TMDb | Ad-hoc or weekly |
| `sonarr_tvdb_removed.sh` | Remove series dropped from TVDB | Ad-hoc or weekly |
| `play_state_sync.sh` | Sync watched/played state + resume positions across Emby + Jellyfin | Every 30 min via `critical_sync_maintenance.sh` |
| `start_webhook_listener.sh` | Node.js webhook server — receive arr OnUpgrade and dispatch handler | Continuous (started by `array_started.sh`) |
| `upgrade_webhook_handler.sh` | Push upgraded item folder to remote nodes + trigger arr rescan | On each arr upgrade (via webhook) |
| `playback_aware_lidarr_discovery.sh` | Behavior-driven music discovery — Emby plays → Last.fm similar → Lidarr | Weekly via `weekly_sync_maintenance.sh` |
| `playback_aware_radarr_discovery.sh` | Behavior-driven movie discovery — Emby watches → TMDB recommendations → Radarr | Weekly via `weekly_sync_maintenance.sh` |
| `playback_aware_sonarr_discovery.sh` | Behavior-driven TV discovery — Emby episodes → TMDB TV recommendations → Sonarr | Weekly via `weekly_sync_maintenance.sh` |
| `media_shares_permissions.sh` | Apply `nobody:users` ownership + correct permissions to all media shares | Daily — runs first |
| `media_cleaner.sh` | Remove junk files (two profiles: `anime` + `media`) | Daily — runs before arr cleanup |
| `play_state_sync.sh` | Sync watched/played state + resume positions across Emby + Jellyfin | Every 30 min |
---
## ━━━ HOW THE SCRIPTS RELATE ━━━
## ━━━ THE ctime INVARIANT — READ BEFORE CHANGING PERMISSIONS ━━━
`media_shares_permissions.sh` applies every pass **conditionally** — it touches only entries
whose owner or mode is actually wrong. That is not an optimisation, and it must stay that way.
`chown` and `chmod` rewrite an inode's ctime **even when the value does not change**. A
blanket pass would therefore restamp every file in the library every night.
The arr cleanup scripts (`sonarr_cleanup.sh`, `radarr_cleanup.sh`, `lidarr_cleanup.sh`) gate
orphan deletion on ctime. mtime cannot substitute: an import preserves the release's original
timestamp, so mtime says nothing about when a file arrived here. Measured 2026-07-27 — of 400
files imported that week, **all 400 had mtimes over 7 days old, one of them 9613 days.**
So:
```
Weekly arr sync (before rsync):
arr_sync.sh ──────────────── syncs tracked IDs across all nodes
│ union model: any node adds → all nodes get it
└── then rsync spreads the actual files to all nodes
└── then arr_cleanup removes orphans on all nodes (old paths, removed content)
Daily maintenance window (media_management.sh):
media_shares_permissions.sh
│ (permissions correct — arr can now delete files)
media_cleaner.sh (anime + media)
│ (junk removed — orphan scan finds only actual media)
lidarr_cleanup.sh ──────────── queries Lidarr API → walks /Music → deletes orphans
sonarr_cleanup.sh ──────────── queries Sonarr API → walks /Tv_Shows → deletes orphans
radarr_cleanup.sh ──────────── queries Radarr API → walks /Movies → deletes orphans
└── each cleanup → notify_emby_scan() → Emby removes ghost entries
Daily recovery:
arrs_failed_stalled_recovery.sh ── importFailed/stalled → blocklist → re-search
Weekly discovery (WEEKLY_MAINTENANCE_SCRIPTS):
playback_aware_lidarr_discovery.sh ─ Emby plays → Last.fm similar → top candidates → Lidarr
playback_aware_radarr_discovery.sh ─ Emby watches → TMDB recommendations → top candidates → Radarr
playback_aware_sonarr_discovery.sh ─ Emby episodes → TMDB TV recommendations → top candidates → Sonarr
└── each discovery script fires arr search immediately after successful add
Every 30 min (critical_sync_maintenance.sh CRITICAL_MAINTENANCE_SCRIPTS):
play_state_sync.sh ─── newest timestamp wins → watched/resume state synced
across all configured Emby + Jellyfin servers
Continuous (started by array_started.sh):
start_webhook_listener.sh ── Node.js HTTP server listens on WEBHOOK_PORT
│ arr OnUpgrade fires webhook → POST to http://HOST_LAN_IP:WEBHOOK_PORT/webhook?key=SECRET
└── upgrade_webhook_handler.sh
├── rsync upgraded folder → all remote nodes immediately
└── trigger arr library rescan on each remote (accept new file, no quality search)
Ad-hoc enrichment:
lidarr_missing_art.sh ─────── discovers missing artwork → fetches from fanart.tv
radarr_tmdb_removed.sh ────── status="deleted" → remove from Radarr + add exclusion
sonarr_tvdb_removed.sh ────── status="deleted" → remove from Sonarr + add exclusion
blanket chown/chmod → every ctime resets to today
→ no file ever appears older than *_ORPHAN_AGE
→ orphan collection silently stops
→ nothing errors, nothing warns, disk just fills
```
**The failure is invisible.** No script fails, no notification fires. The only symptom is
orphans quietly accumulating until a pool fills — which is exactly how the 755 GB / 89%-full
cache pool incident happened.
Two rules follow, and both are load-bearing:
1. **`media_shares_permissions.sh` passes stay conditional.** Making any of them unconditional
breaks orphan collection ecosystem-wide.
2. **`Tools/bulk_permissions_repair.sh` is unconditional by design** — it exists to repair
known-wrong paths where correctness beats preserving a clock. That is precisely why it is a
manual, targeted tool and not scheduled. Pointing it at a whole media root pauses orphan
collection there for `*_ORPHAN_AGE` days.
Both scripts' headers carry this warning too. If you are reading this because you are about to
"simplify" the permissions job, this is the thing that breaks.
+52 -5
View File
@@ -64,11 +64,45 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# acquire_lock "wait" — wait if previous run still active
# detect_hosts() — correct folder lists per host via MY_ID aliases
# Empty array guards — warns and exits cleanly if no folders or patterns configured
# Folder existence — skips missing folders with warning, continues others
# platform_require_cmd — notify script validated before use
# Root Enforcement
# Media files are owned by container users; deleting them requires root.
#
# Profile Required
# Exits with usage if no profile is given. There is no default profile — an
# unspecified profile must never fall through to cleaning something.
#
# Lock Acquisition
# acquire_lock "wait" — waits for a previous run to finish rather than
# skipping, so a long anime pass does not cause the media pass to be dropped.
#
# Host Detection
# detect_hosts() aliases HOST*_ANIME_CLEAN_FOLDERS / HOST*_MEDIA_CLEAN_FOLDERS
# to the correct host's values.
#
# Empty Array Guards
# Exits cleanly if the resolved folder list or pattern list is empty. An empty
# pattern list would otherwise build a find with no -iname terms and match
# every file in the tree.
#
# Clean Path Depth Guard
# Every folder must be an absolute path at least three levels deep before it is
# scanned. The patterns include *.sh, *.zip, *.rar and *.exe, so a truncated
# entry like /mnt/user — which passes an existence check — would delete
# matching files across every share on the array.
#
# Folder Existence
# Missing folders are skipped with a warning; remaining folders still process.
#
# Explicit Pattern List
# Only patterns named in ANIME_FILE_PATTERNS / MEDIA_FILE_PATTERNS are removed.
# The script never infers intent from file size, age, or location.
#
# Count Before Delete
# Matching files are counted first; a folder with zero matches short-circuits
# before any rm is constructed.
#
# Dry Run Support
# --dry-run lists every file that would be deleted and removes nothing.
#
# ==============================================================================================
# CONFIGURATION
@@ -205,6 +239,19 @@ for FOLDER in "${CLEAN_FOLDERS[@]}"; do
FOLDER_NAME=$(basename "$FOLDER")
echo "━━━ $ICON_CLEAN $FOLDER_NAME ━━━"
# The pattern list includes *.sh, *.zip, *.rar and *.exe. A truncated entry such as
# /mnt/user passes the -d check below and would sweep every share on the array, so
# require an absolute path at least three levels deep before scanning anything.
_depth="${FOLDER//[^\/]/}"
if [[ -z "$FOLDER" || "$FOLDER" != /* || "${#_depth}" -lt 3 ]]; then
error "Refusing to clean unsafe path: '${FOLDER:-empty}' — expected an absolute path at least 3 levels deep"
notify "Media cleaner ($PROFILE) refused unsafe path on $(hostname): '${FOLDER:-empty}'" \
"Media Cleaner" "warning"
FAILED+=("${FOLDER_NAME:-empty}")
echo ""
continue
fi
if [[ ! -d "$FOLDER" ]]; then
warn "$FOLDER_NAME not found — skipping"
SKIPPED+=("$FOLDER_NAME")
+105 -12
View File
@@ -14,6 +14,30 @@
# or unRAID environment resets after updates.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# For each share in MEDIA_PERMISSION_SHARES:
#
# 1. Path safety and existence
# → unsafe or missing paths are refused or skipped, never scanned
#
# 2. Count wrong ownership (diagnostic)
# → find ! -user / ! -group — the number reported as "corrected"
#
# 3. Ownership pass — only if the count is non-zero
# → chown PERMISSIONS_OWNER on non-matching entries only
#
# 4. Directory mode pass
# → chmod PERMISSIONS_DIR_MODE on directories not already at that mode
#
# 5. File mode pass
# → chmod PERMISSIONS_FILE_MODE on files not already at that mode
# → "No such file" errors ignored: volatile dirs (Emby transcodes) race
#
# Every pass is conditional by design — see Conditional Passes below.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -35,13 +59,53 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# acquire_lock "wait" — wait if previous run still active (large share scans)
# detect_hosts() — correct share list per host via MY_ID aliases
# Empty array guard — warns and exits cleanly if no shares configured
# Folder existence — skips missing shares with warning, continues others
# Separate passes — directories and files chmod'd separately for correctness
# platform_require_cmd — notify script validated before use
# Silent by default — only failures produce output, success is silent
# Root Enforcement
# chown to an arbitrary owner requires root.
#
# Lock Acquisition
# acquire_lock "wait" — waits rather than skipping. Share scans are long, and
# this runs first in the daily window; skipping it would let arr cleanup run
# against uncorrected ownership.
#
# Host Detection
# detect_hosts() aliases HOST*_MEDIA_PERMISSION_SHARES to this host's shares.
#
# Empty Array Guard
# Exits cleanly if no shares are configured for this host.
#
# Share Path Depth Guard
# Every share must be an absolute path at least three levels deep before it is
# scanned. A truncated entry like /mnt/user passes an existence check and would
# chown and chmod every share on the array — which, because chown/chmod restamp
# ctime, would erase the age signal the arr cleanups depend on across the whole
# library in a single run.
#
# Folder Existence
# Missing shares are skipped with a warning; remaining shares still process.
#
# Separate Passes
# Directories and files are chmod'd in separate passes — directories need the
# execute bit for traversal, media files must not have it.
#
# Conditional Passes
# Only entries whose owner or mode is actually wrong are touched. This is not
# an optimisation: chown/chmod rewrite an inode's ctime even when the value is
# unchanged, so a blanket pass would restamp every file nightly and destroy
# ctime as an age signal. The arr cleanups gate orphan deletion on ctime, and
# mtime cannot substitute — imports preserve the release's original timestamp.
# Making any pass unconditional silently stops orphan collection.
#
# Transcode Race Tolerance
# "No such file or directory" errors from the file pass are ignored. Volatile
# directories such as Emby transcodes delete files mid-scan; that is expected,
# not a permissions failure.
#
# Dry Run Support
# --dry-run counts the dirs, files and ownership entries that would change and
# modifies nothing.
#
# Silent by Default
# Only failures and diagnostics produce output; a clean run is quiet.
#
# Diagnostic — high corrected count on every run means a container has wrong PUID/PGID:
# Correct values on unRAID: PUID=99 (nobody) PGID=100 (users)
@@ -143,9 +207,25 @@ SKIPPED=()
TOTAL_DIRS_FIXED=0
TOTAL_FILES_FIXED=0
# Split for find's -user/-group predicates, which take them separately
PERMISSIONS_USER="${PERMISSIONS_OWNER%%:*}"
PERMISSIONS_GROUP="${PERMISSIONS_OWNER##*:}"
for SHARE in "${MEDIA_PERMISSION_SHARES[@]}"; do
SHARE_NAME=$(basename "$SHARE")
# A truncated entry such as /mnt/user passes the -d check below and would chown/chmod
# every share on the array. Because chown/chmod restamp ctime, that would erase the age
# signal the arr cleanups gate orphan deletion on — across the whole library, in one run.
_depth="${SHARE//[^\/]/}"
if [[ -z "$SHARE" || "$SHARE" != /* || "${#_depth}" -lt 3 ]]; then
error "Refusing to touch unsafe path: '${SHARE:-empty}' — expected an absolute path at least 3 levels deep"
notify "Media permissions refused unsafe path on $(hostname): '${SHARE:-empty}'" \
"Media Permissions" "warning"
FAILED+=("${SHARE_NAME:-empty}")
continue
fi
if [[ ! -d "$SHARE" ]]; then
warn "$SHARE_NAME not found — skipping"
SKIPPED+=("$SHARE_NAME")
@@ -158,7 +238,7 @@ for SHARE in "${MEDIA_PERMISSION_SHARES[@]}"; do
2>/dev/null | wc -l)
FILE_COUNT=$(find "$SHARE" -type f ! -perm "${PERMISSIONS_FILE_MODE:-664}" \
2>/dev/null | wc -l)
OWNER_COUNT=$(find "$SHARE" ! -user nobody -o ! -group users \
OWNER_COUNT=$(find "$SHARE" \( ! -user "$PERMISSIONS_USER" -o ! -group "$PERMISSIONS_GROUP" \) \
2>/dev/null | wc -l)
warn "DRY RUN — $SHARE_NAME: $DIR_COUNT dirs, $FILE_COUNT files, $OWNER_COUNT ownership fixes needed"
continue
@@ -170,21 +250,34 @@ for SHARE in "${MEDIA_PERMISSION_SHARES[@]}"; do
CHMOD_FILE_OK=true
CHOWN_OK=true
# Every pass below is conditional — it touches only entries that are actually wrong.
# This is not just an optimisation. chown/chmod rewrite an inode's ctime even when the
# value is unchanged, so a blanket pass restamps every file in the share each night and
# erases ctime as an age signal. The arr cleanups need that signal to tell a file that
# just landed from one that has sat untracked for days — mtime can't do it, because an
# import preserves the release's original timestamp (measured 2026-07-27: 400 of 400
# files imported that week had mtimes over 7 days old, one of them 9613 days).
# Count files with wrong ownership before fixing (diagnostic)
WRONG_OWNER=$(find "$SHARE" \( ! -user nobody -o ! -group users \) \
WRONG_OWNER=$(find "$SHARE" \( ! -user "$PERMISSIONS_USER" -o ! -group "$PERMISSIONS_GROUP" \) \
2>/dev/null | wc -l)
# Apply ownership first — affects all files and directories
chown -R "$PERMISSIONS_OWNER" "$SHARE" 2>/dev/null || CHOWN_OK=false
if [[ "$WRONG_OWNER" -gt 0 ]]; then
find "$SHARE" \( ! -user "$PERMISSIONS_USER" -o ! -group "$PERMISSIONS_GROUP" \) \
-exec chown "$PERMISSIONS_OWNER" {} + 2>/dev/null || CHOWN_OK=false
fi
# Apply directory permissions — separate pass for correctness
# Directories need execute bit — different from files
find "$SHARE" -type d -exec chmod "${PERMISSIONS_DIR_MODE:-755}" {} + \
find "$SHARE" -type d ! -perm "${PERMISSIONS_DIR_MODE:-755}" \
-exec chmod "${PERMISSIONS_DIR_MODE:-755}" {} + \
2>/dev/null || CHMOD_DIR_OK=false
# Apply file permissions — no execute bit on media files
# Ignore "No such file" errors: race condition with volatile dirs (e.g. Emby transcodes)
_chmod_errs=$(find "$SHARE" -type f -exec chmod "${PERMISSIONS_FILE_MODE:-664}" {} + 2>&1 | \
_chmod_errs=$(find "$SHARE" -type f ! -perm "${PERMISSIONS_FILE_MODE:-664}" \
-exec chmod "${PERMISSIONS_FILE_MODE:-664}" {} + 2>&1 | \
grep -v "No such file or directory" | grep -c "chmod:" || true)
[[ "$_chmod_errs" -gt 0 ]] && CHMOD_FILE_OK=false
+186 -64
View File
@@ -18,7 +18,7 @@
# Audio → MusicBrainz Track ID
#
# ==============================================================================================
# SYNC LOGIC
# OPERATIONAL MODEL
# ==============================================================================================
#
# For each matched item across ≥2 servers:
@@ -55,21 +55,71 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# PLAY_SYNC_ENABLED gate — exits cleanly when disabled; no partial runs
# PARTNERSHIP gate — skips remote sync when partnership is inactive
# Per-server reachability — unreachable servers are skipped individually;
# one offline server does not abort the entire sync
# User match required — a user missing from a server is skipped for that
# server; no cross-account state pollution
# acquire_lock — prevents concurrent runs from racing on the same
# items during the 30-minute critical window
# Root Enforcement
# The probe fingerprint is written under STATE_DIR, which is not user-writable.
# Without root the fingerprint silently fails to persist and the change probe
# never suppresses anything.
#
# jq Dependency Check
# Exits if jq is missing. All API response parsing and the epoch comparisons
# depend on it — without jq every comparison would silently evaluate empty.
#
# PLAY_SYNC_ENABLED Gate
# Exits cleanly when disabled; no partial runs.
#
# PLAY_SYNC_REMOTE Gate
# When false, only this host's own servers are synced. Remote hosts are skipped
# before any network call is attempted.
#
# Partnership Gate
# Remote hosts are skipped when PARTNERSHIP_ENABLED=false. Local Emby↔Jellyfin
# sync still runs — a dormant partnership does not disable local work.
#
# Tailscale Resolution Guard
# A remote host whose Tailscale IP cannot be resolved is skipped rather than
# contacted at its literal localhost URL, which would otherwise point the sync
# at this host's own server and cross-contaminate state.
#
# Placeholder Credential Guard
# Servers whose API key is empty or still a placeholder are dropped from the
# list before any request is made.
#
# Per-Server Reachability
# Unreachable servers are skipped individually; one offline server does not
# abort the entire sync.
#
# User Match Required
# A user missing from a server is skipped for that server. State is never
# written to an unrelated account that happens to exist there.
#
# Forward-Only Writes
# The sync only pushes state forward — it never clears a Played flag or resets
# a resume position. The worst outcome of a bad comparison is a no-op, not
# erased watch history.
#
# Lock Acquisition
# acquire_lock prevents concurrent runs racing on the same items during the
# 30 minute critical window. --wait switches from skip to wait for manual runs.
#
# Probe Staleness Ceiling
# PLAY_SYNC_PROBE_MAX_AGE_HOURS forces a full comparison regardless of the
# hash. Fetches happen every run either way, so the probe can only skip
# per-item processing — it can never cause a change to be missed outright.
#
# Dry Run Support
# --dry-run performs all comparisons and writes no state.
#
# ==============================================================================================
# CONFIGURATION (host*.conf, aliased by detect_hosts)
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_TRANSCODE_SERVERS "Name|URL|APIKey|type" entries per host (emby/jellyfin)
# All hosts are discovered automatically — no extra config needed.
# Read directly for every HOST[0-9]+ defined — this script
# deliberately does NOT call detect_hosts(), because it needs
# every host's servers, not just this one's. Self is identified
# by comparing HOST* values against hostname -s.
# Remote host URLs have localhost rewritten to their Tailscale IP.
#
# master.conf
@@ -77,9 +127,15 @@
# PLAY_SYNC_ENABLED Master toggle (default: true)
# PLAY_SYNC_REMOTE Sync across all hosts via Tailscale (default: true)
# false = local servers only (this host's Emby + Jellyfin)
# PLAY_SYNC_DAYS How many days back to check for played items (default: 90)
# Use 0 to sync all played items (slow on large libraries).
# PLAY_SYNC_TYPES Comma-separated item types to sync (default: Movie,Episode,Audio)
# PLAY_SYNC_TYPES Comma-separated item types to sync (default: Movie,Episode —
# Audio excluded, music library too large; favorites handled separately)
# PLAY_SYNC_PROBE Skip all per-item processing when no play/resume/favorite
# state changed since the last successful run (default: true).
# The raw API responses are hashed and compared against the
# fingerprint stored in STATE_DIR — fetches still happen every
# run, so nothing can be missed.
# PLAY_SYNC_PROBE_MAX_AGE_HOURS Force a full comparison when the stored fingerprint is
# older than this many hours regardless of the hash (default: 24)
#
# ==============================================================================================
# RUNTIME MODES
@@ -95,7 +151,11 @@
# Show configured servers, reachability, and user counts.
#
# play_state_sync.sh --full
# Ignore PLAY_SYNC_DAYS — sync all played items (may be slow).
# Bypass the change probe — always run the full comparison.
#
# play_state_sync.sh --wait
# Wait for an in-progress run to finish instead of exiting. For manual runs
# that would otherwise be skipped by the every-30-minute scheduled pass.
#
# play_state_sync.sh --log
# Verbose output — show each item comparison.
@@ -123,13 +183,18 @@ parse_args "${_FILTERED[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
# The probe fingerprint lives under STATE_DIR — without root it silently fails to persist
# and the change probe can never suppress a run.
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
[[ "${PLAY_SYNC_ENABLED:-true}" != "true" ]] && echo "Play state sync disabled" && exit 0
SYNC_DAYS="${PLAY_SYNC_DAYS:-90}"
SYNC_TYPES="${PLAY_SYNC_TYPES:-Movie,Episode}"
FAV_TYPES="${PLAY_SYNC_FAV_TYPES:-MusicArtist,MusicAlbum,Movie,Series}"
[[ "$FULL_SYNC" == true ]] && SYNC_DAYS=0
log "$ICON_GEAR Config: days=${SYNC_DAYS} types=${SYNC_TYPES} favs=${FAV_TYPES} remote=${PLAY_SYNC_REMOTE:-true}"
log "$ICON_GEAR Config: types=${SYNC_TYPES} favs=${FAV_TYPES} remote=${PLAY_SYNC_REMOTE:-true} probe=${PLAY_SYNC_PROBE:-true}"
command -v jq >/dev/null 2>&1 || { error "jq is required but not installed"; exit 1; }
@@ -167,6 +232,12 @@ for _varname in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
continue
fi
# Skip remote hosts when partnership is inactive
if [[ "$_is_me" == false && "${PARTNERSHIP_ENABLED:-false}" != "true" ]]; then
log "$_host_hostname — partnership inactive (PARTNERSHIP_ENABLED=false), skipping"
continue
fi
# Resolve Tailscale IP for remote hosts
_ts_ip=""
if [[ "$_is_me" == false ]]; then
@@ -224,17 +295,6 @@ _api_post() {
fi
}
# ISO 8601 date → unix seconds (portable, no date -d on BusyBox)
_iso_to_epoch() {
local dt="$1"
[[ -z "$dt" || "$dt" == "null" ]] && echo 0 && return
# Strip fractional seconds and Z, convert to seconds
dt="${dt%.*}" # remove .NNNNNNN
dt="${dt%Z}" # remove trailing Z
dt="${dt/T/ }" # T → space
date -u -d "$dt UTC" +%s 2>/dev/null || echo 0
}
# Ticks → seconds (1 tick = 100ns, 10_000_000 ticks = 1s)
_ticks_to_sec() {
echo $(( ${1:-0} / 10000000 ))
@@ -247,7 +307,7 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC STATUS ━━━━━"
echo "$ICON_GEAR Remote sync: ${PLAY_SYNC_REMOTE:-true}"
echo "$ICON_GEAR Sync days: ${SYNC_DAYS:-all}"
echo "$ICON_GEAR Change probe: ${PLAY_SYNC_PROBE:-true}"
echo "$ICON_GEAR Item types: $SYNC_TYPES"
echo ""
for i in $(seq 0 $(( _srv_count - 1 ))); do
@@ -270,7 +330,7 @@ fi
# ==============================================================================================
echo "━━━ $ICON_SYNC Play State Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no state will be written"
[[ "$FULL_SYNC" == true ]] && log "Full sync mode — ignoring PLAY_SYNC_DAYS"
[[ "$FULL_SYNC" == true ]] && log "Full sync mode — change probe bypassed"
START=$(date +%s)
TOTAL_SYNCED=0
@@ -310,6 +370,77 @@ for i in $(seq 0 $(( _srv_count - 1 ))); do
done < <(echo "${SRV_USERS[$i]}" | jq -r '.[] | [.Id, .Name] | @tsv' 2>/dev/null)
done
# ── Step 2.3: Fetch play/resume/favorite state for every matched user ────────
# Raw responses are cached for the sync passes below and hashed for the change
# probe. Fetching costs seconds — the per-item comparison is what costs minutes,
# so it only runs when a response actually changed since the last successful run.
declare -A RESP_STATE # "lname|si" → deduplicated played+resumable items JSON
declare -A RESP_FAV # "lname|si|ftype" → favorites response JSON
IFS=',' read -ra _fav_type_list <<< "$FAV_TYPES"
for lname in "${!USER_MAP[@]}"; do
read -ra _pairs <<< "${USER_MAP[$lname]}"
[[ "${#_pairs[@]}" -lt 2 ]] && continue
for _pair in "${_pairs[@]}"; do
IFS=':' read -r _si _uid <<< "$_pair"
# All played items — no limit, covers both date-stamped and batch-marked (null date) entries
_endpoint="Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending"
_resp=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" "$_endpoint")
if [[ -z "$_resp" ]]; then
warn " ${SRV_NAME[$_si]} — failed to fetch items for $lname"
RESP_STATE["$lname|$_si"]=""
else
# Resume positions (not yet marked played)
_endpoint2="Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&SortBy=DatePlayed&SortOrder=Descending&Filters=IsResumable"
_resp2=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" "$_endpoint2")
# Combine and deduplicate by Id
[[ -z "$_resp2" ]] && _resp2='{"Items":[]}'
RESP_STATE["$lname|$_si"]=$(printf '%s\n%s' "$_resp" "$_resp2" | jq -s \
'[.[0].Items // [], .[1].Items // []] | add // [] | unique_by(.Id)' 2>/dev/null)
fi
for _ftype in "${_fav_type_list[@]}"; do
RESP_FAV["$lname|$_si|$_ftype"]=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Users/${_uid}/Items?Recursive=true&IncludeItemTypes=${_ftype}&Filters=IsFavorite&Fields=ProviderIds")
done
done
done
# ── Step 2.4: Change probe — skip the comparison when nothing changed ────────
# Failed fetches hash as empty strings, so reachability transitions also read
# as changes and trigger a full pass once the server comes back.
PROBE_FILE="$STATE_DIR/play_state_sync_probe"
CUR_HASH=$(
{
while IFS= read -r _k; do
printf '%s:' "$_k"; printf '%s' "${RESP_STATE[$_k]}" | md5sum
done < <(printf '%s\n' "${!RESP_STATE[@]}" | sort)
while IFS= read -r _k; do
printf '%s:' "$_k"; printf '%s' "${RESP_FAV[$_k]}" | md5sum
done < <(printf '%s\n' "${!RESP_FAV[@]}" | sort)
} | md5sum | awk '{print $1}'
)
if [[ "${PLAY_SYNC_PROBE:-true}" == "true" && "$FULL_SYNC" == false && "$DRY_RUN" == false && -f "$PROBE_FILE" ]]; then
_prev_hash=$(sed -n '1p' "$PROBE_FILE" 2>/dev/null)
_prev_epoch=$(sed -n '2p' "$PROBE_FILE" 2>/dev/null)
[[ "$_prev_epoch" =~ ^[0-9]+$ ]] || _prev_epoch=0
_probe_max_age=$(( ${PLAY_SYNC_PROBE_MAX_AGE_HOURS:-24} * 3600 ))
if [[ "$CUR_HASH" == "$_prev_hash" ]] && (( $(date +%s) - ${_prev_epoch:-0} < _probe_max_age )); then
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC SUMMARY ━━━━━"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "$ICON_DONE No play/resume/favorite changes since last sync — comparison skipped"
success "Done ✅"
exit 0
fi
fi
# ── Step 2.5: Pre-build provider ID → item ID lookup map ─────────────────────
# AnyProviderIdEquals is broken in Jellyfin 10.11+ (ignores the filter entirely).
# Pre-fetching all items' provider IDs once and using a local map avoids the broken
@@ -375,13 +506,6 @@ for _psi in $(seq 0 $(( _srv_count - 1 ))); do
done
# ── Step 3: Sync per matched user ────────────────────────────────────────────
_date_filter=""
if [[ "$SYNC_DAYS" -gt 0 ]]; then
_cutoff=$(date -u -d "$SYNC_DAYS days ago" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || \
date -u -v "-${SYNC_DAYS}d" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)
[[ -n "$_cutoff" ]] && _date_filter="&MinDateLastSaved=$_cutoff"
fi
for lname in "${!USER_MAP[@]}"; do
read -ra _pairs <<< "${USER_MAP[$lname]}"
@@ -404,29 +528,14 @@ for lname in "${!USER_MAP[@]}"; do
declare -A ITEM_MAP # provider_key → JSON per-server data
for _si in "${!U_IDX[@]}"; do
_uid="${U_UID[$_si]}"
# All played items — no limit, covers both date-stamped and batch-marked (null date) entries
_endpoint="Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending"
_resp=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" "$_endpoint")
if [[ -z "$_resp" ]]; then
warn " ${SRV_NAME[$_si]} — failed to fetch items for $lname"
continue
fi
# Resume positions (not yet marked played)
_endpoint2="Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&SortBy=DatePlayed&SortOrder=Descending&Filters=IsResumable"
_resp2=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" "$_endpoint2")
# Combine and deduplicate by Id
[[ -z "$_resp2" ]] && _resp2='{"Items":[]}'
_combined=$(printf '%s\n%s' "$_resp" "$_resp2" | jq -s \
'[.[0].Items // [], .[1].Items // []] | add // [] | unique_by(.Id)' 2>/dev/null)
_combined="${RESP_STATE[$lname|$_si]:-}"
[[ -z "$_combined" ]] && continue
_count=$(echo "$_combined" | jq 'length' 2>/dev/null || echo 0)
log " ${SRV_NAME[$_si]}$_count item(s) with state for $lname"
# Build item lookup by provider key
while IFS=$'\t' read -r iid itype season ep imdb tmdb tvdb mbtrack played ticks lplayed pcount; do
while IFS=$'\t' read -r iid itype season ep imdb tmdb tvdb mbtrack played ticks lplayed pcount epoch; do
# Build canonical provider key
_pkey=""
case "$itype" in
@@ -444,7 +553,7 @@ for lname in "${!USER_MAP[@]}"; do
esac
[[ -z "$_pkey" ]] && continue
_epoch=$(_iso_to_epoch "$lplayed")
_epoch="${epoch:-0}"
_entry="${_si}|${iid}|${played}|${pcount}|${ticks}|${_epoch}|${lplayed}"
if [[ -n "${ITEM_MAP[$_pkey]}" ]]; then
@@ -465,7 +574,11 @@ for lname in "${!USER_MAP[@]}"; do
(.UserData.Played // false | tostring),
(.UserData.PlaybackPositionTicks // 0 | tostring),
(.UserData.LastPlayedDate // "null"),
(.UserData.PlayCount // 0 | tostring)
(.UserData.PlayCount // 0 | tostring),
((.UserData.LastPlayedDate // null) | if . == null then "0"
else (((sub("\\.[0-9]+"; "") | sub("[+-][0-9]{2}:?[0-9]{2}$"; "Z")
| if endswith("Z") then . else . + "Z" end
| fromdateiso8601)? // 0) | tostring) end)
] | @tsv' 2>/dev/null)
done
@@ -646,8 +759,6 @@ FAV_TOTAL_SYNCED=0
FAV_TOTAL_SKIPPED=0
FAV_TOTAL_ERRORS=0
IFS=',' read -ra _fav_type_list <<< "$FAV_TYPES"
for lname in "${!USER_MAP[@]}"; do
read -ra _pairs <<< "${USER_MAP[$lname]}"
[[ "${#_pairs[@]}" -lt 2 ]] && continue
@@ -664,8 +775,7 @@ for lname in "${!USER_MAP[@]}"; do
for _si in "${!UF_IDX[@]}"; do
_uid="${UF_UID[$_si]}"
for _ftype in "${_fav_type_list[@]}"; do
_resp=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Users/${_uid}/Items?Recursive=true&IncludeItemTypes=${_ftype}&Filters=IsFavorite&Fields=ProviderIds")
_resp="${RESP_FAV[$lname|$_si|$_ftype]:-}"
[[ -z "$_resp" ]] && continue
_fcount=$(echo "$_resp" | jq '.Items | length' 2>/dev/null || echo 0)
[[ "$_fcount" -eq 0 ]] && continue
@@ -780,10 +890,22 @@ echo "$ICON_DONE Synced: $TOTAL_SYNCED"
echo ""
echo " Favorites — synced: $FAV_TOTAL_SYNCED skipped: $FAV_TOTAL_SKIPPED errors: $FAV_TOTAL_ERRORS"
TOTAL_ALL_ERRORS=$(( TOTAL_ERRORS + FAV_TOTAL_ERRORS ))
# Fingerprint is the PRE-sync state — our own writes above changed the targets,
# so the next run does one more full pass and then settles into probe skips.
# Re-hashing post-sync instead would swallow plays that landed mid-run.
if [[ "$DRY_RUN" == false && "$TOTAL_ALL_ERRORS" -eq 0 ]]; then
printf '%s\n%s\n' "$CUR_HASH" "$START" > "$PROBE_FILE" 2>/dev/null || true
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes written"
elif [[ "$TOTAL_ERRORS" -eq 0 ]]; then
exit 0
elif [[ "$TOTAL_ALL_ERRORS" -eq 0 ]]; then
success "Done ✅"
exit 0
else
warn "Done with $TOTAL_ERRORS error(s)"
warn "Done with $TOTAL_ALL_ERRORS error(s)"
exit 1
fi
-602
View File
@@ -1,602 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ================================= Radarr Cleanup =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Delete orphaned movie files not tracked by Radarr. Queries the API for all
# tracked movie file paths, walks the library on disk, and removes anything
# untracked that is old enough to be past the import window. Triggers an Emby
# library clean after each deletion run so ghost entries disappear immediately.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Every file encountered on disk is classified into one of five categories:
#
# TRACKED — Radarr API knows this exact path → leave it alone
# PROTECTED — matches RADARR_PROTECTED_PATTERNS → never delete
# ORPHAN — video file, not tracked, older than RADARR_ORPHAN_AGE → delete
# JUNK — not a video extension, not protected → delete regardless of age
# RECENT — not tracked, under RADARR_ORPHAN_AGE → skip (may be mid-import)
#
# Radarr generates movie artwork (*.jpg), metadata (*.nfo), and manages subtitles
# (*.srt, *.sub, *.ass) but does NOT include these in its tracked file API response.
# Without PROTECTED classification these would be deleted — breaking Radarr and
# Emby metadata display.
#
# After deletions: notify_emby_scan() triggers Emby "Clean Missing Files" task.
# Emby removes ghost entries immediately — no user-facing file-not-found errors.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# API as Ground Truth
# What Radarr tracks is authoritative. Files not in the API response are
# orphans — Radarr has no record of them and they serve no purpose.
# The script never infers ownership from directory structure alone.
#
# Age Gate Before Deletion
# Files under RADARR_ORPHAN_AGE are left alone regardless of tracked status.
# Radarr's import pipeline writes files before registering them — acting
# immediately would delete files mid-import.
#
# Emby Cleanup Is Part of the Job
# Deleting a file without telling Emby leaves ghost entries that show as
# broken items. Triggering the Emby clean is not optional — it completes
# the deletion from the user's perspective.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Six gates — ALL must pass before any file is touched:
# 1. Container running and not starting/unhealthy
# 2. API reachable
# 3. API version matches RADARR_VERSION_MAJOR in master.conf
# 4. Movie count > 0
# 5. Tracked file count > 0
# 6. Deletion size < RADARR_MAX_DELETE_GB — or --i-know-what-im-doing required
#
# acquire_lock "wait" — large scans take time, wait for previous run to finish
# jq + curl validation — exits if either tool missing
# DOCKER_TIMEOUT — container checks protected against daemon hangs
# notify_emby_scan() — triggers Emby clean after deletion
# platform_require_cmd — notify script validated before use
# Silent by default — orphans/junk warn(), clean library logs silently
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_RADARR_URL / HOST*_RADARR_API_KEY / HOST*_RADARR_MOVIES_ROOT
# HOST*_RADARR_PATH_MAP — container path → host path translation
# All aliased by detect_hosts() — script uses unprefixed names
#
# master.conf
#
# RADARR_ORPHAN_AGE — days before untracked file eligible for deletion
# RADARR_MAX_DELETE_GB — require --i-know-what-im-doing above this
# RADARR_EXTENSIONS — video file extensions for orphan classification
# RADARR_PROTECTED_PATTERNS — file patterns never deleted
# RADARR_VERSION_MAJOR — expected Radarr major version for API safety check
# RADARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)
# ARR_CLEANUP_STATS — stats file path (read by coffee report)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# radarr_cleanup.sh — normal run
# radarr_cleanup.sh --dry-run — preview, no deletions
# radarr_cleanup.sh --log — verbose output
# radarr_cleanup.sh --status — show config and exit
# radarr_cleanup.sh --i-know-what-im-doing — bypass size threshold
# radarr_cleanup.sh --i-know-what-im-doing --skip-strike-list — NUCLEAR MODE
#
# NUCLEAR MODE: both flags bypass age check AND size threshold. User accepts full
# responsibility — the flag name is long and annoying by design.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Special flag pre-processing ───────────────────────────────────────────────────────────────
I_KNOW=false
SKIP_STRIKES=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--i-know-what-im-doing) I_KNOW=true ;;
--skip-strike-list) SKIP_STRIKES=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────
if [[ "$I_KNOW" == true ]] && [[ "$SKIP_STRIKES" == true ]] && [[ "$DRY_RUN" != true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ WARNING — NUCLEAR MODE ACTIVE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Flags: --i-know-what-im-doing --skip-strike-list"
echo " Strike system: BYPASSED — deletes on first pass"
echo " Size threshold: BYPASSED — no GB limit"
echo " Data recovery: NOT POSSIBLE after deletion"
echo ""
echo " Review --dry-run output before proceeding."
echo " You have 10 seconds to cancel (Ctrl+C)..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
sleep 10
echo " Proceeding..."
echo ""
fi
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for Radarr API calls"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
notify "Radarr cleanup failed on $(hostname) — jq not installed" "Radarr Cleanup" "warning"
exit 1
fi
acquire_lock "wait"
TMP_DIR="/tmp/radarr_cleanup_$$"
mkdir -p "$TMP_DIR"
trap "_release_all_locks; rm -rf $TMP_DIR" EXIT
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID and aliases RADARR_URL, RADARR_API_KEY, RADARR_MOVIES_ROOT
detect_hosts
DOCKER_TIMEOUT=15
RADARR_CONTAINER="Radarr"
# Build path map from MY_ID's Radarr path map
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_RADARR_PATH_MAP"
eval "for key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$key\"]=\"\${${local_path_map_var}[\$key]}\"
done"
require_var RADARR_URL
require_var RADARR_API_KEY
require_var RADARR_MOVIES_ROOT
if [[ ! -d "$RADARR_MOVIES_ROOT" ]]; then
error "Movies root not found: $RADARR_MOVIES_ROOT"
notify "Radarr cleanup failed on $(hostname) — movies root not found: $RADARR_MOVIES_ROOT" \
"Radarr Cleanup" "warning"
exit 1
fi
log "$ICON_GEAR Config: url=${RADARR_URL} root=${RADARR_MOVIES_ROOT}"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $RADARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
[[ "$SKIP_STRIKES" == true ]] && warn "OVERRIDE — --skip-strike-list active — age check bypassed"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Radarr URL: $RADARR_URL"
echo "$ICON_GEAR Movies root: $RADARR_MOVIES_ROOT"
echo "$ICON_TIME Orphan age: ${RADARR_ORPHAN_AGE} days"
echo "$ICON_GEAR Max delete: ${RADARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)"
echo "$ICON_GEAR Radarr ver: v${RADARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Extensions: ${RADARR_EXTENSIONS[*]}"
echo "$ICON_GEAR Protected patterns: ${RADARR_PROTECTED_PATTERNS[*]}"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "$ICON_GEAR I know: $I_KNOW"
echo "$ICON_GEAR Skip strikes: $SKIP_STRIKES"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Safety Layer 1 — Container Health ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
CONTAINER_RUNNING=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Running}}' "$RADARR_CONTAINER" 2>/dev/null)
if [[ "$CONTAINER_RUNNING" != "true" ]]; then
error "$RADARR_CONTAINER is not running — aborting"
notify "Radarr cleanup aborted on $(hostname) — container not running" \
"Radarr Cleanup" "warning"
exit 1
fi
CONTAINER_HEALTH=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Health.Status}}' "$RADARR_CONTAINER" 2>/dev/null)
case "$CONTAINER_HEALTH" in
healthy) info "$RADARR_CONTAINER is healthy" ;;
"") info "$RADARR_CONTAINER has no health check — proceeding" ;;
starting)
error "$RADARR_CONTAINER is still starting — aborting"
notify "Radarr cleanup aborted on $(hostname) — container still starting" \
"Radarr Cleanup" "warning"
exit 1 ;;
unhealthy)
error "$RADARR_CONTAINER is unhealthy — aborting"
notify "Radarr cleanup aborted on $(hostname) — container unhealthy" \
"Radarr Cleanup" "warning"
exit 1 ;;
*) warn "$RADARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution" ;;
esac
info "Safety layer 1 passed — container healthy"
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
radarr_api() {
local endpoint="$1"
local response http_code body
response=$(curl -sf \
--max-time 30 \
-H "X-Api-Key: $RADARR_API_KEY" \
-w "\n%{http_code}" \
"${RADARR_URL}/api/v3/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "Radarr API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
is_video_file() {
local ext="${1##*.}"
ext="${ext,,}"
for valid_ext in "${RADARR_EXTENSIONS[@]}"; do
[[ "$ext" == "$valid_ext" ]] && return 0
done
return 1
}
is_protected_file() {
local filename
filename=$(basename "$1")
for pattern in "${RADARR_PROTECTED_PATTERNS[@]}"; do
# shellcheck disable=SC2254
case "$filename" in
$pattern) return 0 ;;
esac
done
return 1
}
format_bytes() {
local bytes=$1
if (( bytes > 1073741824 )); then
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
elif (( bytes > 1048576 )); then
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
else
echo "${bytes}B"
fi
}
# ==============================================================================================
# ━━━ Pre-flight: Radarr Import Scan ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Pre-flight: Radarr Import Scan ━━━"
# Fetch root folders from Radarr API and translate container paths to host paths
mapfile -t SCAN_ROOTS < <(
radarr_api "rootfolder" | \
jq -r '.[].path' 2>/dev/null | \
while IFS= read -r cp; do translate_path "$cp"; done
)
if [[ "${#SCAN_ROOTS[@]}" -eq 0 ]]; then
error "No root folders returned from Radarr API — aborting"
notify "Radarr cleanup aborted on $(hostname) — no root folders from API" \
"Radarr Cleanup" "warning"
exit 1
fi
info "Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}"
info "Triggering ProcessMonitoredDownloads pre-flight"
SCAN_PAYLOAD='{"name": "ProcessMonitoredDownloads"}'
SCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $RADARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$SCAN_PAYLOAD" \
"${RADARR_URL}/api/v3/command" 2>/dev/null)
SCAN_CMD_ID=$(echo "$SCAN_RESPONSE" | jq -r '.id // empty' 2>/dev/null)
if [[ -z "$SCAN_CMD_ID" ]]; then
warn "Could not trigger import scan — proceeding without pre-flight"
else
info "Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion..."
POLL_TIMEOUT=${RADARR_IMPORT_SCAN_TIMEOUT:-600}
POLLED=0
while [[ "$POLLED" -lt "$POLL_TIMEOUT" ]]; do
SCAN_STATUS=$(curl -sf --max-time 10 \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/command/${SCAN_CMD_ID}" 2>/dev/null | \
jq -r '.status // empty' 2>/dev/null)
case "$SCAN_STATUS" in
completed) info "Import scan complete ✅"; break ;;
failed) warn "Import scan reported failed — proceeding anyway"; break ;;
esac
sleep 10
(( POLLED += 10 ))
[[ $(( POLLED % 60 )) -eq 0 ]] && log " Still scanning... (${POLLED}s elapsed)"
done
[[ "$POLLED" -ge "$POLL_TIMEOUT" ]] && \
warn "Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway"
fi
# ==============================================================================================
# ━━━ Fetch Radarr Tracked Files ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Fetching Radarr Tracked Files ━━━"
# Safety Layer 2 — API reachability
if ! check_api "$RADARR_URL" "Radarr" 10; then
notify "Radarr cleanup aborted on $(hostname) — API unreachable" "Radarr Cleanup" "warning"
exit 1
fi
# Safety Layer 3 — API version check
check_arr_version "$RADARR_URL" "$RADARR_API_KEY" "v3" "$RADARR_VERSION_MAJOR" "Radarr" || exit 1
info "Querying Radarr API..."
# Fetch all movies
MOVIES_RESPONSE=$(radarr_api "movie") || {
error "Failed to fetch movies from Radarr"
notify "Radarr cleanup failed on $(hostname) — could not fetch movies" \
"Radarr Cleanup" "warning"
exit 1
}
MOVIE_IDS=$(echo "$MOVIES_RESPONSE" | jq -r '.[].id' 2>/dev/null)
MOVIE_COUNT=$(echo "$MOVIE_IDS" | grep -c "." 2>/dev/null || echo 0)
# Safety Layer 4 — movie count > 0
if [[ "$MOVIE_COUNT" -eq 0 ]]; then
error "API returned 0 movies — aborting to prevent mass deletion"
notify "Radarr cleanup aborted on $(hostname) — 0 movies returned" \
"Radarr Cleanup" "warning"
exit 1
fi
info "Found $MOVIE_COUNT movies — fetching movie files..."
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
> "$TRACKED_FILE"
MOVIE_INDEX=0
while IFS= read -r movie_id; do
[[ -z "$movie_id" ]] && continue
(( MOVIE_INDEX++ ))
[[ $(( MOVIE_INDEX % 100 )) -eq 0 ]] && \
log "Fetching files: $MOVIE_INDEX/$MOVIE_COUNT movies..."
MOVIE_FILES=$(radarr_api "moviefile?movieId=${movie_id}" 2>/dev/null)
if [[ -n "$MOVIE_FILES" ]]; then
while IFS= read -r api_path; do
[[ -z "$api_path" ]] && continue
translate_path "$api_path" >> "$TRACKED_FILE"
done < <(echo "$MOVIE_FILES" | jq -r '.[].path // .path' 2>/dev/null)
fi
done <<< "$MOVIE_IDS"
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
# Eliminates the main performance bottleneck for large libraries
declare -A TRACKED_MAP
while IFS= read -r _tracked_path; do
[[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1
done < "$TRACKED_FILE"
unset _tracked_path
info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths"
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
# Safety Layer 5 — tracked count > 0
if [[ "$TRACKED_COUNT" -eq 0 ]]; then
error "API returned 0 tracked files — aborting to prevent mass deletion"
notify "Radarr cleanup aborted on $(hostname) — 0 tracked files returned" \
"Radarr Cleanup" "warning"
exit 1
fi
info "$MOVIE_COUNT movies | $TRACKED_COUNT tracked movie files"
# ==============================================================================================
# ━━━ Scan Movies Root ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning Movies Root ━━━"
info "Root: $RADARR_MOVIES_ROOT | Orphan age: ${RADARR_ORPHAN_AGE} days"
START=$(date +%s)
ORPHAN_COUNT=0
JUNK_COUNT=0
RECENT_COUNT=0
PROTECTED_COUNT=0
ORPHAN_BYTES=0
JUNK_BYTES=0
AGE_SECONDS=$(( RADARR_ORPHAN_AGE * 86400 ))
NOW=$(date +%s)
MAX_DELETE_BYTES=$(awk "BEGIN {printf \"%d\", $RADARR_MAX_DELETE_GB * 1073741824}")
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
if [[ -n "${TRACKED_MAP[$filepath]:-}" ]]; then
log "TRACKED: $filepath"
continue
fi
if is_protected_file "$filepath"; then
log "$ICON_PROTECTED PROTECTED: $filepath"
(( PROTECTED_COUNT++ ))
continue
fi
FILE_SIZE=$(stat -c%s "$filepath" 2>/dev/null || echo 0)
if is_video_file "$filepath"; then
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_STRIKES" != true ]]; then
log "RECENT (skipping): $filepath"
(( RECENT_COUNT++ ))
continue
fi
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
fi
done < <(
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && find "$host_path" -type f 2>/dev/null
done | sort -u
)
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
# ==============================================================================================
# ━━━ Safety Layer 6 — Deletion Size Threshold ━━━
# ==============================================================================================
if [[ "$TOTAL_DELETE_BYTES" -gt "$MAX_DELETE_BYTES" ]]; then
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $TOTAL_DELETE_BYTES / 1073741824}")
if [[ "$I_KNOW" != true ]]; then
echo ""
error "Deletion would exceed ${RADARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted"
error "Review ORPHAN lines above carefully before proceeding"
error "Rerun with: --i-know-what-im-doing"
error "To also bypass age check: add --skip-strike-list"
notify "Radarr cleanup halted on $(hostname)${TOTAL_HUMAN} requires --i-know-what-im-doing" \
"Radarr Cleanup" "warning"
exit 1
else
warn "OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
[[ -n "${TRACKED_MAP[$filepath]:-}" ]] && continue
is_protected_file "$filepath" && continue
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if is_video_file "$filepath"; then
[[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && \
[[ "$SKIP_STRIKES" != true ]] && continue
fi
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < <(
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && find "$host_path" -type f 2>/dev/null
done | sort -u
)
info "Cleaning up empty folders..."
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && \
find "$host_path" -mindepth 1 -type d -empty -delete 2>/dev/null
done
info "Empty folders removed"
fi
END=$(date +%s)
ORPHAN_HUMAN=$(format_bytes "$ORPHAN_BYTES")
JUNK_HUMAN=$(format_bytes "$JUNK_BYTES")
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR CLEANUP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Tracked: $TRACKED_COUNT files ($MOVIE_COUNT movies)"
echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles, metadata)"
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${RADARR_ORPHAN_AGE} days)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no files deleted"
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
notify "Radarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
"Radarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Write stats for sunday_morning_coffee_report.sh
if [[ "$DRY_RUN" == false ]] && [[ -n "${ARR_CLEANUP_STATS:-}" ]]; then
echo "$(date '+%Y-%m-%d')|radarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}" \
>> "$ARR_CLEANUP_STATS" 2>/dev/null || true
fi
exit 0
+1 -1
View File
@@ -230,7 +230,7 @@ data — it just doesn't trigger a notification for that condition.
| State File | Source | What It Shows |
|-----------|--------|---------------|
| `FALLBACK_STATE_FILE` | `Fallback/fallback.sh` | Current fallback state (NORMAL/FALLBACK/etc.) |
| `SYS_WATCHDOG_FAILED_FILE` | `Watchdogs/docker_watchdog.sh` | Container skip list — needs human attention |
| `DOCKER_WATCHDOG_FAILED_FILE` | `Watchdogs/docker_watchdog.sh` | Container skip list — needs human attention |
| `WATCHDOG_STATE_FILE` | `Watchdogs/docker_watchdog.sh` | Active container strike counts |
| `SYS_WATCHDOG_STATE_FILE` | `Watchdogs/stability_watchdog.sh` | Active system watchdog strikes |
| `BANDWIDTH_LOG` | `bandwidth_monitor.sh` | Yesterday's transfer history |
+1 -1
View File
@@ -153,7 +153,7 @@ Daily 8am:
weekly_health_digest.sh ── reads ──────────► FALLBACK_STATE_FILE
── reads ──────────► WATCHDOG_STATE_FILE
── reads ──────────► SYS_WATCHDOG_STATE_FILE
── reads ──────────► SYS_WATCHDOG_FAILED_FILE
── reads ──────────► DOCKER_WATCHDOG_FAILED_FILE
── reads ──────────► BANDWIDTH_LOG
── reads ──────────► TRANSCODE_DAILY_LOG
── reads ──────────► TUNING_MONITOR_LOG
Executable → Regular
+61 -10
View File
@@ -19,6 +19,24 @@
# to HOST*_DAILY_SYNC_SHARES. Both aliased by detect_hosts().
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Pre-flight — connectivity to the remote, remote array mounted, version parity
# 2. Resolve the share list (BACKUP_VERIFY_SHARES, else DAILY_SYNC_SHARES)
# 3. Per share:
# a. Randomly sample BACKUP_VERIFY_SAMPLE files above BACKUP_VERIFY_MIN_SIZE
# b. Compute each file's MD5 locally
# c. Compute the same file's MD5 on the remote over SSH
# d. Classify: MATCH | MISMATCH | MISSING
# 4. Report per-share and overall counts; notify on any MISMATCH and on
# significant MISSING counts
#
# Sampling rather than full verification is deliberate — a complete checksum of every
# mirrored file would take longer than the interval between runs. Random sampling over
# a weekly cadence surfaces systematic corruption without ever reading the whole library.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -56,9 +74,6 @@
# SSH Timeout
# SSH_TIMEOUT caps all SSH calls. One hung connection does not block the run.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
@@ -107,6 +122,7 @@ source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
SSH_TIMEOUT=15
BACKUP_VERIFY_MD5_TIMEOUT_MAX="${BACKUP_VERIFY_MD5_TIMEOUT_MAX:-600}"
# ==============================================================================================
# ━━━ Setup ━━━
@@ -213,6 +229,7 @@ TOTAL_CHECKED=0
TOTAL_MATCH=0
TOTAL_MISMATCH=0
TOTAL_MISSING=0
TOTAL_UNVERIFIED=0
SHARES_WITH_ISSUES=()
for share in "${VERIFY_SHARES[@]}"; do
@@ -250,6 +267,7 @@ for share in "${VERIFY_SHARES[@]}"; do
SHARE_MATCH=0
SHARE_MISMATCH=0
SHARE_MISSING=0
SHARE_UNVERIFIED=0
for local_file in "${SAMPLE_FILES[@]}"; do
[[ -z "$local_file" ]] && continue
@@ -261,19 +279,49 @@ for share in "${VERIFY_SHARES[@]}"; do
continue
fi
# Remote checksum via SSH — timeout protected
remote_md5=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
# The path is interpolated into a remote shell command, so it must be escaped for
# reuse as one word. A bare '$local_file' inside single quotes breaks on the first
# apostrophe — "Frieren - Beyond Journey's End" ended the quote early, md5sum fell
# back to reading stdin, and the empty-input hash d41d8cd9... was reported as a
# MISMATCH against a file that is byte-identical on the remote.
printf -v remote_q '%q' "$local_file"
# Existence and content are separate questions. Asking them together means a slow
# checksum is indistinguishable from an absent file.
remote_exists=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
-o StrictHostKeyChecking=no \
root@"$REMOTE_SERVER" \
"md5sum '$local_file' 2>/dev/null | awk '{print \$1}'" 2>/dev/null)
"test -f $remote_q && echo yes" 2>/dev/null </dev/null)
(( TOTAL_CHECKED++ ))
if [[ -z "$remote_md5" ]]; then
if [[ "$remote_exists" != "yes" ]]; then
warn "$ICON_ERROR MISSING: $(basename "$local_file")"
(( SHARE_MISSING++ ))
(( TOTAL_MISSING++ ))
continue
fi
# md5sum of a multi-GB file cannot finish inside a connect-sized timeout. Budget by
# size — a 5.9GB file needs ~30s and was being killed at 15s, then counted MISSING
# even though it was present and correct.
local_size=$(stat -c%s "$local_file" 2>/dev/null || echo 0)
md5_timeout=$(( local_size / 52428800 + SSH_TIMEOUT ))
(( md5_timeout > BACKUP_VERIFY_MD5_TIMEOUT_MAX )) && md5_timeout=$BACKUP_VERIFY_MD5_TIMEOUT_MAX
remote_md5=$(timeout "$md5_timeout" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
-o StrictHostKeyChecking=no \
root@"$REMOTE_SERVER" \
"md5sum $remote_q 2>/dev/null | awk '{print \$1}'" 2>/dev/null </dev/null)
if [[ -z "$remote_md5" ]]; then
# Present but unreadable within budget. Reporting this as a mismatch or a miss
# would be a claim the run did not earn.
warn "$ICON_WARN UNVERIFIED (checksum timed out after ${md5_timeout}s): $(basename "$local_file")"
(( SHARE_UNVERIFIED++ ))
(( TOTAL_UNVERIFIED++ ))
elif [[ "$local_md5" == "$remote_md5" ]]; then
log "MATCH: $(basename "$local_file")"
(( SHARE_MATCH++ ))
@@ -288,8 +336,8 @@ for share in "${VERIFY_SHARES[@]}"; do
done
# Per-share result — only visible if issues found
if [[ "$SHARE_MISMATCH" -gt 0 || "$SHARE_MISSING" -gt 0 ]]; then
warn "$SHARE_NAME — match: $SHARE_MATCH missing: $SHARE_MISSING mismatch: $SHARE_MISMATCH"
if [[ "$SHARE_MISMATCH" -gt 0 || "$SHARE_MISSING" -gt 0 || "$SHARE_UNVERIFIED" -gt 0 ]]; then
warn "$SHARE_NAME — match: $SHARE_MATCH missing: $SHARE_MISSING mismatch: $SHARE_MISMATCH unverified: $SHARE_UNVERIFIED"
SHARES_WITH_ISSUES+=("$SHARE_NAME")
else
echo "$SHARE_NAME — all $SHARE_MATCH files match ✅"
@@ -310,10 +358,11 @@ echo "$ICON_VERIFY Checked: $TOTAL_CHECKED files"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 ]]; then
if [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 || "$TOTAL_UNVERIFIED" -gt 0 ]]; then
echo "$ICON_SUCCESS Match: $TOTAL_MATCH"
warn "Missing: $TOTAL_MISSING"
[[ "$TOTAL_MISMATCH" -gt 0 ]] && echo "$ICON_ERROR Mismatch: $TOTAL_MISMATCH"
[[ "$TOTAL_UNVERIFIED" -gt 0 ]] && warn "Unverified: $TOTAL_UNVERIFIED (present, checksum timed out)"
fi
if [[ "$DRY_RUN" == true ]]; then
@@ -322,6 +371,8 @@ elif [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 ]]; then
echo "$ICON_ERROR Status: ISSUES FOUND — ${#SHARES_WITH_ISSUES[@]} share(s) need attention: ${SHARES_WITH_ISSUES[*]}"
notify "Backup verify FAILED on $(hostname)$REMOTE_SERVER_NAME — mismatches: $TOTAL_MISMATCH missing: $TOTAL_MISSING — shares: ${SHARES_WITH_ISSUES[*]}" \
"Backup Verify" "warning"
elif [[ "$TOTAL_UNVERIFIED" -gt 0 ]]; then
warn "Status: $TOTAL_MATCH verified, $TOTAL_UNVERIFIED could not be checksummed in time — NOT a clean run"
else
echo "$ICON_DONE Status: all $TOTAL_CHECKED files match across ${#VERIFY_SHARES[@]} shares ✅"
fi
+18 -3
View File
@@ -17,6 +17,24 @@
# last 7 days activity timeline, any transfers or days exceeding BANDWIDTH_WARN_GB.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Log mode (--log-transfer), called by rsync.sh after every sync:
# 1. Append one line: YYYY-MM-DD|HH:MM|profile|duration|status|bytes
# 2. Trim entries older than BANDWIDTH_LOG_RETENTION days
# One bounded write per rsync run — never grows without limit, never rewrites history.
#
# Report mode (default), scheduled weekly:
# 1. Read the accumulated log
# 2. Aggregate per profile — run count, total bytes, average duration, failures
# 3. Build a 7-day activity timeline
# 4. Flag any single transfer or any single day exceeding BANDWIDTH_WARN_GB
#
# The two modes never run together: logging is a side effect of rsync, reporting is a
# scheduled read. Report mode never writes to the log.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -46,9 +64,6 @@
# Log Directory Guard
# Creates the log directory if it doesn't exist. Exits cleanly if unwritable.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
+32 -32
View File
@@ -15,6 +15,23 @@
# separate message lists all CRITICAL domains. Not one notification per domain.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Validate openssl is present (platform_require_cmd) — without it nothing can be checked
# 2. Per domain in CERT_MONITOR_DOMAINS:
# a. Open a real TLS connection with openssl s_client
# b. Parse notAfter from the served certificate
# c. Compute days remaining
# d. Classify: HEALTHY (silent) | WARNING (≤ CERT_WARN_DAYS)
# | CRITICAL (≤ CERT_CRIT_DAYS) | FAILED (no connect / no parse)
# 3. Batch by severity — one notification listing all WARNING domains, a separate
# one listing all CRITICAL domains
#
# A domain that fails to connect is reported as FAILED rather than assumed healthy or
# assumed expired — an unreachable host and an expiring cert are different problems.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -44,8 +61,10 @@
# CERT_TIMEOUT caps each openssl connection attempt. One unreachable domain
# does not block the remaining domains.
#
# Notification Validated
# platform_require_cmd confirms openssl and notify script are present before use.
# openssl Validated
# platform_require_cmd confirms openssl is present before any domain is checked — every
# check depends on it, so a missing binary is reported as itself rather than as every
# domain failing. The notify script is validated separately by the platform adapter.
#
# ==============================================================================================
# CONFIGURATION
@@ -159,43 +178,24 @@ fi
check_cert() {
local domain="$1"
local port="${2:-443}"
_CERT_DAYS=""
_CERT_EXPIRY=""
local expiry_str
expiry_str=$(echo | timeout "$CERT_TIMEOUT" openssl s_client \
-connect "${domain}:${port}" \
-servername "$domain" \
2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [[ -z "$expiry_str" ]]; then
if ! check_cert_expiry "$domain" "$port" "$CERT_TIMEOUT"; then
if [[ -n "$_CERT_EXPIRY_RAW" ]]; then
error "$ICON_CERT $domain — could not parse expiry date: $_CERT_EXPIRY_RAW"
else
error "$ICON_CERT $domain — could not retrieve certificate (unreachable or no TLS)"
fi
return 3
fi
local expiry_epoch
expiry_epoch=$(date -d "$expiry_str" +%s 2>/dev/null)
if [[ -z "$expiry_epoch" ]]; then
error "$ICON_CERT $domain — could not parse expiry date: $expiry_str"
return 3
fi
local now days_remaining expiry_display
now=$(date +%s)
days_remaining=$(( (expiry_epoch - now) / 86400 ))
expiry_display=$(date -d "$expiry_str" '+%Y-%m-%d' 2>/dev/null)
_CERT_DAYS=$days_remaining
_CERT_EXPIRY=$expiry_display
if [[ "$days_remaining" -le "$CERT_CRIT_DAYS" ]]; then
error "$ICON_CERT $domain — CRITICAL: ${days_remaining} days remaining (expires $expiry_display)"
if [[ "$_CERT_DAYS" -le "$CERT_CRIT_DAYS" ]]; then
error "$ICON_CERT $domain — CRITICAL: ${_CERT_DAYS} days remaining (expires $_CERT_EXPIRY)"
return 2
elif [[ "$days_remaining" -le "$CERT_WARN_DAYS" ]]; then
warn "$ICON_CERT $domain — WARNING: ${days_remaining} days remaining (expires $expiry_display)"
elif [[ "$_CERT_DAYS" -le "$CERT_WARN_DAYS" ]]; then
warn "$ICON_CERT $domain — WARNING: ${_CERT_DAYS} days remaining (expires $_CERT_EXPIRY)"
return 1
else
log "$ICON_CERT $domain — OK: ${days_remaining} days remaining (expires $expiry_display)"
log "$ICON_CERT $domain — OK: ${_CERT_DAYS} days remaining (expires $_CERT_EXPIRY)"
return 0
fi
}
@@ -285,7 +285,7 @@ fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ── Write JSON status cache ───────────────────────────────────────────────────
_CERT_CACHE_FILE="$SCRIPTS_DIR/State_Files/cert_status.json"
_CERT_CACHE_FILE="$STATE_DIR/cert_status.json"
{
printf '{"checked_at":%d,"host":"%s","warn_days":%d,"crit_days":%d,"dry_run":%s,"domains":[\n' \
"$(date +%s)" "$MY_ID" "$CERT_WARN_DAYS" "$CERT_CRIT_DAYS" \
+22 -6
View File
@@ -18,6 +18,22 @@
# configuration issue. Silent on clean runs.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Reachability — Emby API responding; unreachable exits cleanly rather than
# reporting an empty library as a real result
# 2. Server info and uptime
# 3. Active sessions — count, and the transcode-to-direct-play ratio
# 4. Library counts — movies, episodes, songs
# 5. Activity history over the last EMBY_REPORT_DAYS
# 6. Top EMBY_REPORT_TOP_N items and most active users
# 7. Ramdisk transcode status, read from the shared transcode state
#
# Every figure is queried fresh. The only notification is the transcode-ratio warning;
# everything else is report output.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -54,9 +70,6 @@
# detect_hosts() aliases HOST*_EMBY_URL and HOST*_EMBY_API_KEY → EMBY_URL / EMBY_API_KEY.
# Each server reports on its own Emby instance automatically.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
@@ -218,6 +231,9 @@ TRANSCODE_NOW=$(echo "$SESSIONS" | \
2>/dev/null || echo 0)
DIRECT_NOW=$(( ACTIVE_COUNT - TRANSCODE_NOW ))
TRANSCODE_PCT=0
[[ "$ACTIVE_COUNT" -gt 0 ]] && TRANSCODE_PCT=$(( TRANSCODE_NOW * 100 / ACTIVE_COUNT ))
echo " $ICON_EMBY Active streams: $ACTIVE_COUNT"
echo " $ICON_EMBY Direct play: $DIRECT_NOW"
echo " $ICON_EMBY Transcoding: $TRANSCODE_NOW"
@@ -313,7 +329,7 @@ echo ""
echo "━━━ $ICON_RAM Transcode Status ━━━"
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
RAMDISK_USED_KB=$(df "$RAMDISK_PATH" --output=used 2>/dev/null | tail -1 | tr -d ' ')
RAMDISK_USED_GB=$(awk "BEGIN {printf \"%.2f\", ${RAMDISK_USED_KB:-0} / 1048576}")
RAMDISK_USED_GB=$(kb_to_gb "$RAMDISK_USED_KB")
SYMLINK=$(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "unknown")
echo " $ICON_RAM Ramdisk usage: ${RAMDISK_USED_GB}GB / ${RAMDISK_SIZE:-8G}"
echo " $ICON_LINK Symlink target: $SYMLINK"
@@ -333,7 +349,7 @@ END=$(date +%s)
echo "━━━━━ $ICON_SUMMARY EMBY REPORT SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_EMBY Server: $SERVER_NAME (v$SERVER_VERSION)"
echo "$ICON_EMBY Active: $ACTIVE_COUNT streams ($DIRECT_NOW direct / $TRANSCODE_NOW transcode)"
echo "$ICON_EMBY Active: $ACTIVE_COUNT streams ($DIRECT_NOW direct / $TRANSCODE_NOW transcode, ${TRANSCODE_PCT}%)"
echo "$ICON_EMBY Library: $MOVIE_COUNT movies $EPISODE_COUNT episodes $SONG_COUNT songs"
echo "$ICON_EMBY Period: $TOTAL_PLAYS play events in last ${EMBY_REPORT_DAYS} days"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
@@ -341,7 +357,7 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━
# Only notify on issues — high transcode rate may indicate config problem
if [[ "$DRY_RUN" == false ]]; then
if [[ "$TOTAL_PLAYS" -gt 10 && "${TRANSCODE_PCT:-0}" -gt 80 ]]; then
if [[ "$TOTAL_PLAYS" -gt 10 && "$TRANSCODE_PCT" -gt 80 ]]; then
notify "Emby report on $(hostname) — high transcode rate: ${TRANSCODE_PCT}% of $TOTAL_PLAYS plays — check direct play config" \
"Emby Report" "warning"
fi
+7
View File
@@ -53,6 +53,13 @@
# collect_hosts() populates ALL_HOST_IDS — if no HOST* vars are defined the
# output sections iterate over an empty array and exit cleanly.
#
# No Root, No Lock, No detect_hosts — Deliberate
# This is the one script in the ecosystem that intentionally omits all three, and
# they should not be added. It writes nothing, so there is no state for a lock to
# protect and no privileged operation to justify a root gate. It reports on every
# node rather than acting as one, so detect_hosts() would narrow it to this host's
# aliases — the opposite of what it is for. Every HOST* var is read directly instead.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
+26 -25
View File
@@ -19,6 +19,26 @@
# from master.conf if dynamix.cfg is not found.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Validate smartctl is present (platform_require_cmd)
# 2. Resolve temperature thresholds — dynamix.cfg first, master.conf as fallback
# 3. Enumerate drives, skipping anything in SMART_IGNORE_DRIVES
# 4. Per drive, read live SMART attributes and evaluate:
# overall status FAILED → critical
# Reallocated_Sector_Ct > 0 → concerning
# Current_Pending_Sector > 0 → concerning
# Offline_Uncorrectable > 0 → critical
# Temperature_Celsius vs warn/crit thresholds
# Power_On_Hours → informational only
# NVMe drives expose different attribute names — detected and mapped automatically.
# 5. Report; notify only when something crosses a threshold. Silent when all pass.
#
# Read-only throughout — this queries attributes the drive already maintains and never
# starts a self-test. Running one is smart_long_test.sh's job.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -55,8 +75,9 @@
# Reads hot/max/hotssd/maxssd from dynamix.cfg so smart_health.sh and unRAID's
# dashboard use the same thresholds. Falls back to master.conf values if not found.
#
# Notifications Validated
# platform_require_cmd confirms smartctl and notify script are present before use.
# smartctl Validated
# platform_require_cmd confirms smartctl is present before any drive is queried. The
# notify script is validated separately by the platform adapter.
#
# ==============================================================================================
# CONFIGURATION
@@ -156,11 +177,7 @@ if [[ "$SHOW_STATUS" == true ]]; then
for drive in /dev/sd? /dev/nvme?; do
[[ ! -e "$drive" ]] && continue
drive_name=$(basename "$drive")
ignored=false
for ignore in "${SMART_IGNORE_DRIVES[@]}"; do
[[ "$drive_name" == "$ignore" ]] && ignored=true && break
done
if [[ "$ignored" == true ]]; then
if is_in_list "$drive_name" "${SMART_IGNORE_DRIVES[@]}"; then
echo " $ICON_WARN $drive — ignored"
else
echo " $ICON_SMART $drive — would check"
@@ -201,18 +218,7 @@ get_drive_temp() {
echo "${temp:-}"
}
# Detect if a drive is SSD/NVMe (rotational=0)
is_ssd() {
local drive="$1"
local dev_name
dev_name=$(basename "$drive" | sed 's/nvme[0-9]/nvme0/')
local rotational="/sys/block/$(basename "$drive")/queue/rotational"
[[ -f "$rotational" ]] && [[ "$(cat "$rotational" 2>/dev/null)" == "0" ]] && return 0
# NVMe is always SSD
[[ "$drive" == *nvme* ]] && return 0
return 1
}
# is_ssd() — provided by common.sh
# ==============================================================================================
# ━━━ SMART Health Check ━━━
# ==============================================================================================
@@ -232,12 +238,7 @@ for drive in /dev/sd? /dev/nvme?; do
drive_name=$(basename "$drive")
# Check ignore list
ignored=false
for ignore in "${SMART_IGNORE_DRIVES[@]}"; do
[[ "$drive_name" == "$ignore" ]] && ignored=true && break
done
if [[ "$ignored" == true ]]; then
if is_in_list "$drive_name" "${SMART_IGNORE_DRIVES[@]}"; then
log "$drive_name — ignored (SMART_IGNORE_DRIVES)"
DRIVES_SKIP+=("$drive_name")
continue
+17 -20
View File
@@ -18,6 +18,22 @@
# WebGUI slowdowns or timeouts under load.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Each run snapshots:
# inotify: instances in use vs INOTIFY_MAX_INSTANCES kernel limit.
# Top 5 consumers by instance count. Warns above INOTIFY_WARN_PCT.
# php-fpm: active workers vs PHP_MAX_CHILDREN limit.
# Warns above PHP_FPM_WARN_PCT.
#
# Log line format (one per run, trimmed to TUNING_LOG_RETENTION days):
# DATE|TIME|INOTIFY_USED|INOTIFY_LIMIT|INOTIFY_PCT|INOTIFY_WARN|
# PHPFPM_ACTIVE|PHPFPM_MAX|PHPFPM_PCT|PHPFPM_WARN
# INOTIFY_WARN and PHPFPM_WARN are 1/0 flags. weekly_health_digest.sh counts
# warnings over the week to show trend severity.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -36,22 +52,6 @@
# signal — routine snapshots below the threshold produce nothing.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Each run snapshots:
# inotify: instances in use vs INOTIFY_MAX_INSTANCES kernel limit.
# Top 5 consumers by instance count. Warns above INOTIFY_WARN_PCT.
# php-fpm: active workers vs PHP_MAX_CHILDREN limit.
# Warns above PHP_FPM_WARN_PCT.
#
# Log line format (one per run, trimmed to TUNING_LOG_RETENTION days):
# DATE|TIME|INOTIFY_USED|INOTIFY_LIMIT|INOTIFY_PCT|INOTIFY_WARN|
# PHPFPM_ACTIVE|PHPFPM_MAX|PHPFPM_PCT|PHPFPM_WARN
# INOTIFY_WARN and PHPFPM_WARN are 1/0 flags. weekly_health_digest.sh counts
# warnings over the week to show trend severity.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
@@ -65,9 +65,6 @@
# Trim uses tmp file + mv — partial writes during log rotation cannot corrupt
# the accumulated history.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
@@ -212,7 +209,7 @@ fi
# ==============================================================================================
PHPFPM_MAX="${PHP_MAX_CHILDREN:-250}"
PHPFPM_ACTIVE=$(ps aux 2>/dev/null | grep -c "php-fpm: pool" || echo 0)
PHPFPM_ACTIVE=$(ps aux 2>/dev/null | grep -c "php-fpm: pool" || true)
PHPFPM_ACTIVE="${PHPFPM_ACTIVE//[^0-9]/}"
PHPFPM_ACTIVE="${PHPFPM_ACTIVE:-0}"
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# ==============================================================================================
# ================================== Uptime Report =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# The weekly read of what Tools/uptime_probe.sh has been recording every minute: anything down
# right now, and anything that was not perfect over the last seven days. Runs in the Sunday
# Morning Coffee Report.
#
# Silent on a clean week. A report that always says something is a report nobody reads, so this
# prints nothing and notifies nothing when every domain was 100%.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# uptime_probe.php --report exits 1 when it has something to say and 0 when it does not, so the
# decision to notify is the exit code rather than this script parsing the text it just printed.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Silence is the normal output.
# A report that always says something is a report nobody reads. A perfect week prints nothing
# and notifies nothing, so anything that does appear in the Sunday report is worth the glance.
#
# The exit code is the decision, not the text.
# uptime_probe.php --report exits 1 when it has something to say and 0 when it does not. This
# script never parses the output it just printed to work out whether to notify — a report whose
# wording changed would otherwise silently stop notifying.
#
# It reads; it never probes.
# The measurements are already taken, once a minute, by Tools/uptime_probe.sh. Re-probing at
# report time would describe Sunday morning rather than the week being reported on.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Read-only. Reads the stored history and prints; records nothing, and cannot alter the data it
# is reporting on.
#
# UPTIME_PROBE_ENABLED gates the whole run — with the probe off there is no history worth
# reporting, and this says nothing rather than reporting an empty week as a perfect one.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# uptime_report.sh the weekly read. Silent when every domain was 100%.
#
# Called from COFFEE_REPORT_SCRIPTS; takes no arguments and has no other mode. For live figures
# or a per-domain table, use Tools/uptime_probe.sh --status.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# UPTIME_PROBE_ENABLED nothing here runs when the probe is switched off
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
acquire_lock
detect_hosts
if [[ "${UPTIME_PROBE_ENABLED:-true}" == "false" ]]; then
log "$ICON_GEAR Uptime probe disabled — nothing to report"
exit 0
fi
REPORT="$(php "$SCRIPT_DIR/../Plugin/unraid/Tools/uptime_probe.php" --report 2>/dev/null)"
RC=$?
if [[ $RC -eq 0 || -z "$REPORT" ]]; then
echo "$ICON_DONE All monitored domains at 100% this week ✅"
exit 0
fi
echo "$REPORT"
DOWN_COUNT=$(grep -c "DOWN" <<< "$REPORT" || true)
if [[ "$DOWN_COUNT" -gt 0 ]]; then
notify "$DOWN_COUNT domain(s) currently unreachable on $(hostname)" "Uptime" "warning"
else
notify "Some domains had downtime this week on $(hostname)" "Uptime" "normal"
fi
exit 0
Executable → Regular
+26 -28
View File
@@ -14,24 +14,6 @@
# into a single digest. Reads only — writes nothing, changes nothing.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Aggregator, Not Generator
# This script reads state files that other scripts maintain. It never produces
# health data itself — it only presents what is already there. Each source
# script remains responsible for its own state; this script is the envelope.
#
# Profile-Driven Notification
# The cron schedule never changes. The DIGEST_PROFILE in master.conf controls
# when notifications actually send — switching from daily noise to weekly
# summaries is a one-line conf change, not a cron edit.
#
# Read-Only, No Side Effects
# Writes nothing, changes nothing, triggers nothing. Safe to run at any time
# for a health snapshot without affecting any running service or state file.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
@@ -58,6 +40,24 @@
# RAMDISK_PATH / TRANSCODE_LINK — current transcode location and usage
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Aggregator, Not Generator
# This script reads state files that other scripts maintain. It never produces
# health data itself — it only presents what is already there. Each source
# script remains responsible for its own state; this script is the envelope.
#
# Profile-Driven Notification
# The cron schedule never changes. The DIGEST_PROFILE in master.conf controls
# when notifications actually send — switching from daily noise to weekly
# summaries is a one-line conf change, not a cron edit.
#
# Read-Only, No Side Effects
# Writes nothing, changes nothing, triggers nothing. Safe to run at any time
# for a health snapshot without affecting any running service or state file.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
@@ -76,8 +76,10 @@
# smart profile produces no output and no notification when nothing worth
# reporting is found.
#
# Notifications Validated
# platform_require_cmd confirms notify and openssl are present before use.
# openssl Validated — Non-Fatal
# platform_require_cmd checks openssl and, unlike the other monitors, only warns if it
# is missing: the SSL section is skipped and the rest of the digest still runs. The
# notify script is validated separately by the platform adapter.
#
# ==============================================================================================
# CONFIGURATION
@@ -270,7 +272,7 @@ fi
# ── Transcode Ramdisk ─────────────────────────────────────────────────────────────────────────
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
RAMDISK_USED_KB=$(df "$RAMDISK_PATH" --output=used 2>/dev/null | tail -1 | tr -d ' ')
RAMDISK_USED_GB=$(awk "BEGIN {printf \"%.2f\", ${RAMDISK_USED_KB:-0} / 1048576}")
RAMDISK_USED_GB=$(kb_to_gb "$RAMDISK_USED_KB")
SYMLINK_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "unknown")
DIGEST_LINES+=("$ICON_RAM Transcodes: ${RAMDISK_USED_GB}GB used → $SYMLINK_TARGET")
@@ -309,7 +311,7 @@ if [[ -f "${BANDWIDTH_LOG:-}" ]] && [[ -s "$BANDWIDTH_LOG" ]]; then
YESTERDAY=$(date -d "yesterday" '+%Y-%m-%d')
YESTERDAY_BYTES=$(awk -F'|' -v d="$YESTERDAY" '$1==d{sum+=$6} END{print sum+0}' \
"$BANDWIDTH_LOG")
YESTERDAY_GB=$(awk "BEGIN {printf \"%.2f\", ${YESTERDAY_BYTES:-0} / 1073741824}")
YESTERDAY_GB=$(bytes_to_gb "$YESTERDAY_BYTES")
YESTERDAY_LARGE=$(awk -F'|' -v d="$YESTERDAY" '$1==d && $7=="LARGE"' \
"$BANDWIDTH_LOG" | wc -l)
@@ -329,12 +331,8 @@ if [[ ${#CERT_MONITOR_DOMAINS[@]} -gt 0 ]] && command -v openssl >/dev/null 2>&1
CERT_ISSUES=()
for domain in "${CERT_MONITOR_DOMAINS[@]}"; do
[[ -z "$domain" ]] && continue
expiry_str=$(echo | timeout "${CERT_TIMEOUT:-10}" openssl s_client \
-connect "${domain}:443" -servername "$domain" \
2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [[ -n "$expiry_str" ]]; then
expiry_epoch=$(date -d "$expiry_str" +%s 2>/dev/null)
days_remaining=$(( (expiry_epoch - $(date +%s)) / 86400 ))
if check_cert_expiry "$domain" 443 "${CERT_TIMEOUT:-10}"; then
days_remaining="$_CERT_DAYS"
if [[ "$days_remaining" -le "${CERT_CRIT_DAYS:-7}" ]]; then
CERT_ISSUES+=("$domain: ${days_remaining}d CRITICAL")
SHOULD_SEND=true
Executable → Regular
+55 -40
View File
@@ -15,6 +15,24 @@
# comparison. In --dry-run mode, console only — nothing written to the log.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Five report sections (each skips gracefully if its data source is unavailable):
#
# ZFS pool health — status, state, errors per pool. Pools in
# ZFS_REPORT_IGNORE_POOLS excluded from the report
# (still fully monitored by unRAID — report-only exclusion).
# ARC statistics — current ARC vs max, metadata pressure, hit rate.
# Warns if ARC utilisation exceeds ZFS_REPORT_ARC_WARN_PCT, or if
# ARC headroom (max - current) drops below ZFS_REPORT_ARC_FREE_WARN_GB.
# Memory status — total, free, available RAM (informational only — see note below).
# Warns if available < ZFS_REPORT_AVAIL_WARN_GB.
# Docker memory — top ZFS_REPORT_DOCKER_TOP containers by memory usage.
# Useful for spotting containers approaching watchdog limits.
# Kernel pressure — vmstat snapshot (3 samples).
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
@@ -33,23 +51,13 @@
# available, Docker not responding — those sections skip, the rest still run.
# A partial report is more useful than no report.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Five report sections (each skips gracefully if its data source is unavailable):
#
# ZFS pool health — status, state, errors per pool. Pools in
# ZFS_REPORT_IGNORE_POOLS excluded from the report
# (still fully monitored by unRAID — report-only exclusion).
# ARC statistics — current ARC vs max, metadata pressure, hit rate.
# Warns if ARC utilisation exceeds ZFS_REPORT_ARC_WARN_PCT.
# Memory status — total, free, available RAM.
# Warns if free < ZFS_REPORT_FREE_WARN_GB or
# available < ZFS_REPORT_AVAIL_WARN_GB.
# Docker memory — top ZFS_REPORT_DOCKER_TOP containers by memory usage.
# Useful for spotting containers approaching watchdog limits.
# Kernel pressure — vmstat snapshot (3 samples).
# ARC Headroom, Not System Free RAM
# ZFS ARC deliberately grows to use most of the RAM the system isn't otherwise
# using — that's the point of a page cache. System-wide "free" RAM being low is
# therefore normal and not a signal of anything, so the memory warning is based
# on ARC headroom (ARC_MAX - ARC_CURRENT) instead — how much room ARC itself has
# left before it hits its configured ceiling. "Available" RAM (which accounts for
# reclaimable cache) is still checked separately as a true system-pressure signal.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
@@ -71,9 +79,6 @@
# Docker Stats Timeout
# DOCKER_TIMEOUT caps docker stats calls. A hung daemon does not block the report.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
@@ -102,8 +107,8 @@
# ZFS_REPORT_ARC_WARN_PCT
# Warn if ARC is using more than this percentage of its configured max. (default: 90)
#
# ZFS_REPORT_FREE_WARN_GB
# Warn if free RAM is below this threshold in GB. (default: 10)
# ZFS_REPORT_ARC_FREE_WARN_GB
# Warn if ARC headroom (ARC_MAX - ARC_CURRENT) drops below this many GB. (default: 10)
#
# ZFS_REPORT_AVAIL_WARN_GB
# Warn if available RAM is below this threshold in GB. (default: 20)
@@ -159,7 +164,7 @@ done
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
log "Ignoring pools: ${ZFS_REPORT_IGNORE_POOLS[*]:-none}"
log "$ICON_GEAR Config: arc-warn=${ZFS_REPORT_ARC_WARN_PCT}% free-warn=${ZFS_REPORT_FREE_WARN_GB}GB avail-warn=${ZFS_REPORT_AVAIL_WARN_GB}GB docker-top=${ZFS_REPORT_DOCKER_TOP}"
log "$ICON_GEAR Config: arc-warn=${ZFS_REPORT_ARC_WARN_PCT}% arc-free-warn=${ZFS_REPORT_ARC_FREE_WARN_GB}GB avail-warn=${ZFS_REPORT_AVAIL_WARN_GB}GB docker-top=${ZFS_REPORT_DOCKER_TOP}"
# Tee output to log file unless dry run
if [[ "$DRY_RUN" == false ]]; then
@@ -178,7 +183,7 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_ZFS Log file: $ZFS_REPORT_LOG"
echo "$ICON_ZFS ARC warn: ${ZFS_REPORT_ARC_WARN_PCT}%"
echo "$ICON_MEM Free RAM warn: ${ZFS_REPORT_FREE_WARN_GB}GB"
echo "$ICON_ZFS ARC free warn: ${ZFS_REPORT_ARC_FREE_WARN_GB}GB"
echo "$ICON_MEM Avail warn: ${ZFS_REPORT_AVAIL_WARN_GB}GB"
echo "$ICON_CONTAINERS Docker top: $ZFS_REPORT_DOCKER_TOP"
echo "$ICON_ZFS Ignore pools: ${ZFS_REPORT_IGNORE_POOLS[*]:-none}"
@@ -256,21 +261,32 @@ echo "━━━ $ICON_ZFS ARC Statistics ━━━"
if [[ ! -f /proc/spl/kstat/zfs/arcstats ]]; then
warn "ZFS arcstats not available — skipping ARC section"
else
ARC_MAX=$(cat /sys/module/zfs/parameters/zfs_arc_max 2>/dev/null || \
awk '/^c_max / {print $3}' /proc/spl/kstat/zfs/arcstats)
# zfs_arc_max reads 0 when it has been left at the default, which is a value rather than a
# failure — so the || fallback never fires for the case that actually needs it, exactly like a
# grep -c that prints 0 and exits 1. Zero here would reach the ARC_PCT division below, and awk
# treats division by zero as fatal: it prints nothing, ARC_PCT comes back empty, and the whole
# ARC section reports blanks. c_max is the cap the kernel is really enforcing either way.
ARC_MAX=$(cat /sys/module/zfs/parameters/zfs_arc_max 2>/dev/null || echo 0)
ARC_MAX="${ARC_MAX//[^0-9]/}"
if [[ "${ARC_MAX:-0}" -eq 0 ]]; then
ARC_MAX=$(awk '/^c_max / {print $3}' /proc/spl/kstat/zfs/arcstats 2>/dev/null)
ARC_MAX="${ARC_MAX:-0}"
fi
ARC_SIZE=$(awk '/^size / {print $3}' /proc/spl/kstat/zfs/arcstats)
ARC_META_USED=$(awk '/^arc_meta_used / {print $3}' /proc/spl/kstat/zfs/arcstats)
ARC_MAX_GB=$(awk "BEGIN {printf \"%.1f\", $ARC_MAX / 1073741824}")
ARC_CUR_GB=$(awk "BEGIN {printf \"%.1f\", $ARC_SIZE / 1073741824}")
ARC_META_GB=$(awk "BEGIN {printf \"%.1f\", $ARC_META_USED / 1073741824}")
ARC_MAX_GB=$(bytes_to_gb "$ARC_MAX" 1)
ARC_CUR_GB=$(bytes_to_gb "$ARC_SIZE" 1)
ARC_META_GB=$(bytes_to_gb "$ARC_META_USED" 1)
ARC_PCT=$(awk "BEGIN {printf \"%.1f\", $ARC_SIZE * 100 / $ARC_MAX}")
ARC_PCT_INT=$(printf "%.0f" "$ARC_PCT")
ARC_FREE_GB=$(awk "BEGIN {printf \"%d\", ($ARC_MAX - $ARC_SIZE) / 1073741824}")
echo " $ICON_ZFS ARC Max: ${ARC_MAX_GB}GB"
echo " $ICON_ZFS ARC Current: ${ARC_CUR_GB}GB"
echo " $ICON_ZFS ARC Meta Used: ${ARC_META_GB}GB"
echo " $ICON_ZFS ARC Utilization: ${ARC_PCT}%"
echo " $ICON_ZFS ARC Free: ${ARC_FREE_GB}GB"
if [[ "$ARC_PCT_INT" -ge "$ZFS_REPORT_ARC_WARN_PCT" ]]; then
warn "ARC utilization ${ARC_PCT}% — above ${ZFS_REPORT_ARC_WARN_PCT}% threshold"
@@ -279,6 +295,13 @@ else
log "ARC utilization ${ARC_PCT}% — within threshold ✅"
fi
if [[ "$ARC_FREE_GB" -lt "$ZFS_REPORT_ARC_FREE_WARN_GB" ]]; then
warn "ARC free ${ARC_FREE_GB}GB — below ${ZFS_REPORT_ARC_FREE_WARN_GB}GB threshold"
WARNINGS+=("ARC headroom low: ${ARC_FREE_GB}GB")
else
log "ARC free ${ARC_FREE_GB}GB — within threshold ✅"
fi
echo ""
META_MRU_GHOST=$(awk '/^mru_ghost_metadata / {print $3}' \
/proc/spl/kstat/zfs/arcstats 2>/dev/null || echo 0)
@@ -287,8 +310,8 @@ else
META_MISSES=$(awk '/^demand_metadata_misses / {print $3}' \
/proc/spl/kstat/zfs/arcstats 2>/dev/null || echo 0)
MRU_GB=$(awk "BEGIN {printf \"%.2f\", $META_MRU_GHOST / 1073741824}")
MFU_GB=$(awk "BEGIN {printf \"%.2f\", $META_MFU_GHOST / 1073741824}")
MRU_GB=$(bytes_to_gb "$META_MRU_GHOST")
MFU_GB=$(bytes_to_gb "$META_MFU_GHOST")
echo " $ICON_ZFS MRU Ghost: ${MRU_GB}GB"
echo " $ICON_ZFS MFU Ghost: ${MFU_GB}GB"
@@ -302,20 +325,12 @@ echo "━━━ $ICON_MEM Memory Status ━━━"
FREE_HUMAN=$(free -h | awk '/Mem:/ {print $4}')
AVAIL_HUMAN=$(free -h | awk '/Mem:/ {print $7}')
TOTAL_HUMAN=$(free -h | awk '/Mem:/ {print $2}')
FREE_GB=$(free -g | awk '/Mem:/ {print $4}')
AVAIL_GB=$(free -g | awk '/Mem:/ {print $7}')
echo " $ICON_MEM Total RAM: $TOTAL_HUMAN"
echo " $ICON_MEM Free RAM: $FREE_HUMAN"
echo " $ICON_MEM Free RAM: $FREE_HUMAN (informational — ARC intentionally uses most of this)"
echo " $ICON_MEM Available RAM: $AVAIL_HUMAN"
if [[ "$FREE_GB" -lt "$ZFS_REPORT_FREE_WARN_GB" ]]; then
warn "Free RAM ${FREE_HUMAN} — below ${ZFS_REPORT_FREE_WARN_GB}GB threshold"
WARNINGS+=("Low free RAM: ${FREE_HUMAN}")
else
log "Free RAM ${FREE_HUMAN} — within threshold ✅"
fi
if [[ "$AVAIL_GB" -lt "$ZFS_REPORT_AVAIL_WARN_GB" ]]; then
warn "Available RAM ${AVAIL_HUMAN} — below ${ZFS_REPORT_AVAIL_WARN_GB}GB threshold"
WARNINGS+=("Low available RAM: ${AVAIL_HUMAN}")
+559
View File
@@ -0,0 +1,559 @@
# Varaverk AI Integration — Design Notes
**Status: retrieval is built; integration is not.** As of 2026-08-02 the `AI_*` and
`HOST*_OLLAMA_*` variables exist in both confs and both templates, and `AI/` holds a working
index and query path — see the RAG section at the end of this document and `AI/README-AI.md`.
Everything else below remains design only. **No Varaverk script consults AI.** Every
`AI_ASSIST_*` toggle is false, `AI_CONF_WRITE_ENABLED` is false with an empty whitelist, and
host resolution across the mesh is specified but not implemented. Originally captured
2026-08-01 so the reasoning survives.
Ollama itself *is* installed, tuned and verified on HOST1 — `qwen2.5-coder:14b` for
generation, `nomic-embed-text` for embeddings, 16k context, pinned to the RTX 3080. See
Hardware Budget for measured numbers. That is the substrate, not the integration.
**Origin:** the RTX 3080 was freed when the Windows gaming VM was retired. It is bound to the
`nvidia` driver, not `vfio` — not reserved for passthrough, so there is no VM contention to
design around. Two goals at once: somewhere to learn local LLMs, and something Varaverk can
genuinely use.
**Build order** — deliberately lowest-risk first. Each stage must be boring before the next
one starts:
1. Chat assistant / settings helper / onboarding assistant — a wrong answer costs nothing
2. Watchdog and discovery context — a wrong answer costs a bad suggestion, still gated
3. Cleanup and sync decision aid — closest to destructive, last to be trusted
The regular script is always the backup, at every stage.
---
## The governing principle
**Varaverk works exactly as well with AI off as with it on.**
Every script — including scripts written after this lands — is designed and hardened
without AI first. AI is added afterwards as enhancement, never as a dependency. A script
that cannot do its job when `AI_ENABLED=false` is a broken script, not an AI feature.
This is the constraint everything else in this document answers to. If a design decision
makes AI load-bearing, that decision is wrong.
Corollary: AI never makes a destructive decision. The session that produced the current
safeguard layer (depth guards, strike thresholds, verification-after-write) exists because
config values and scan results feed `rm -rf`, `chown -R`, and rsync `--delete`. AI advises
at the points where a script currently stops and defers to a human. The deterministic guard
still pulls the trigger.
---
## Two independent off-switches
| switch | meaning | source |
|---|---|---|
| `AI_ENABLED` | intent — do we want AI at all | `master.conf` |
| resolver result | availability — is there a reachable host | runtime probe |
**Both must produce the identical code path when off.** A caller that gets "no AI" from
either must run its normal, non-AI logic — not a degraded variant, not a skipped step.
`AI_ENABLED` follows the fail-closed idiom standardised across the ecosystem:
```bash
[[ "${AI_ENABLED:-false}" != "true" ]] && <normal path>
```
Not `== false`. Anything that isn't exactly `true` means off, so a typo can never switch AI
on. (This is the same bug that was fixed in `fallback.sh` — see its `FALLBACK_ENABLED` gate.)
---
## Configuration schema
Follows the existing rule: **thresholds and toggles → `master.conf`; hardware, paths,
container names and per-host identity → `host*.conf`.**
### `host*.conf` — per-host, because only one node actually has the GPU
```bash
# ━━━ Ollama / AI ━━━
HOST1_OLLAMA_URL="http://localhost:11434" # empty on nodes without a local Ollama
HOST1_OLLAMA_CONTAINER="Ollama" # for docker_watchdog / restart lists
HOST1_OLLAMA_GPU_UUID="GPU-309357d8-2a13-09e0-84ac-fcfdcdf5c626"
HOST1_OLLAMA_MODEL="qwen2.5-coder:14b" # generation
HOST1_OLLAMA_EMBED_MODEL="nomic-embed-text" # embeddings — qwen cannot embed
```
A node with an empty `OLLAMA_URL` is not an error — it falls through to the resolver and
uses the mesh. HOST2 gets the section with blanks, exactly like the NPM/lldap credentials
it is already waiting on.
### `master.conf` — shared behaviour
```bash
# ━━━ AI ━━━
AI_ENABLED=false # master switch — fail-closed, != "true" means off
AI_CONNECT_TIMEOUT=5 # probe timeout when resolving a host
AI_REQUEST_TIMEOUT=240 # must clear a cold load — measured 1m45s after tuning.
# KEEP_ALIVE=-1 means this only bites after a restart,
# but a first call that times out is the worst first
# impression a caller can have. Re-measure against a
# real RAG query before fixing this number.
AI_RESOLVE_CACHE_TTL=300 # don't re-probe the mesh on every script invocation
AI_MAX_RETRIES=1 # AI is enhancement — do not retry hard
# Per-feature toggles — enable narration long before enabling decision aid
AI_ASSIST_REPORTS=false # tier 1 — digest / coffee report narration
AI_ASSIST_WATCHDOG=false # tier 2 — context on a flagged condition
AI_ASSIST_DISCOVERY=false # tier 2 — discovery / classification judgment calls
AI_ASSIST_CLEANUP=false # tier 2 — HELD orphans, stuck-import triage
AI_ASSIST_ONBOARD=false # tier 3 — onboarding / settings assistance
# Conf writes — separate switch, off by default, see Conf Write Access below
AI_CONF_WRITE_ENABLED=false
AI_CONF_WRITE_KEYS=() # explicit whitelist; never paths or credentials
```
**Per-feature toggles are load-bearing, not decoration.** They are what lets AI narrate the
weekly digest for months before it is ever allowed near a cleanup decision. `AI_ENABLED` is
necessary but not sufficient — every feature stays individually off until it has earned it.
> When these land, `Deployment/master.conf.template` and `Deployment/host.conf.template`
> must be updated in the same pass. That rule is not optional in this repo.
---
## Host resolution
AI runs on the owner's node only. Remote mesh nodes reach it over Tailscale. No remote node
needs a GPU, a model, or an Ollama container — only the resolver.
`resolve_ollama_host()` mirrors the existing Gitea locator in `git_pull_execute.sh`:
```
Ollama answering on localhost:11434? → use it (owner's node)
else discover_remote_nodes()
→ resolve_tailscale_ip(node)
→ probe each :11434 → first responsive wins
else → no AI host (== AI_ENABLED=false)
```
Helpers already exist in `common.sh`: `discover_remote_nodes()` (767),
`resolve_tailscale_ip()` (812), `check_connectivity()` (866).
**Probe the API, not the container.** The Gitea locator checks `docker ps`. Do not copy that
here — a container can be up while the model is unloaded, still pulling, or wedged. Probe
`/api/tags`. Same principle written into `network_watchdog.sh`'s design principles:
*verify the path, not the process*.
**Cache the resolution** in `/tmp` state, like the arr cache. A cleanup script should not pay
a Tailscale round-trip to discover AI it may never call.
### Known consequence
AI lives on HOST1, so **during a fallback — HOST1 down, HOST2 covering — the mesh has no AI.**
That is precisely when a triage assistant would be most useful. Accepted: a second GPU on
HOST2 is a lot of hardware for that window, and AI is enhancement-only by design. Worth
knowing rather than discovering.
---
## Where AI is allowed to act
Ranked by how much damage a wrong answer does.
**Tier 1 — narration and summary (safe, do first)**
- Sunday morning coffee report — turn metrics into prose
- `weekly_health_digest.sh` — summarise, highlight what changed
- Explain *why* a container is crash-looping from its logs
**Tier 2 — triage and context on an existing flag (the real value)**
Places where a script already detects something and stops:
- `system_watchdog` / `stability_watchdog` flags a condition → AI adds context, correlates
with recent logs, suggests likely cause
- Sonarr stuck-import triage — the "matched by series ID" recipe is textbook LLM work
- `HELD` entries from `arr_download_orphan_cleaner.sh`
- `reverse-anime-leak` from the classification scans — currently report-only *because* it is
a judgment call. That is exactly the shape AI suits.
**Tier 3 — assisted configuration (needs the guardrails below)**
- Onboarding a new host — the main motivation for conf write access
- AI-assisted settings tuning: rsync profiles, fallback tiers, auth stack
**Never**
- Deciding what to delete
- Choosing a path for any destructive operation
- Anything that bypasses a strike counter, age gate, or verification step
---
## Conf write access
Wanted mainly for onboarding and assisted settings. This is the highest-risk item here.
**Current state: there is no recovery path.**
```
.gitignore:4 Configurations/host*.conf
.gitignore:5 Configurations/master.conf
.gitignore:6 Configurations/*.bak
```
Confs are gitignored — no git history to revert to — and so are the `.bak` files, so the
backup is not versioned either. The only fallback is a single `.bak` slot written by
`conf_upgrade`, and it goes stale immediately:
| file | modified | its `.bak` |
|---|---|---|
| `master.conf` | Jul 28 18:52 | Jul 28 18:52 |
| `host1.conf` | Aug 1 21:00 | **Jul 3 17:46** |
A bad write to `master.conf` currently falls back to a file that may predate a month of edits.
**Fix this before any AI writes anything.**
### Required before conf-write ships
1. **Key whitelist, not file access.** Thresholds and toggles only — `*_WARN_GB`,
`*_STRIKE_LIMIT`, `*_ENABLED`, retention days. Never a path, never a credential, never a
container list. A wrong threshold is recoverable; a wrong path is what the depth guards
exist to catch.
2. **Timestamped backups, plural**`master.conf.2026-08-01T21:00`, retained. Not one
clobbered slot.
3. **Validate before commit**`bash -n` the candidate, then confirm `load_config.sh`
sources it cleanly. Never install a conf that has not been proven to parse.
4. **Diff always logged.** An AI conf change should be at least as visible as a container
restart.
5. **Lock against concurrent readers** — never rewrite a conf while scripts are mid-run.
**Consider un-ignoring `Configurations/` into a private repo.** Then `git diff` and
`git revert` become the recovery mechanism and the history is free. This overlaps the
existing GitHub-mirror TODO, which is already blocked on the same question.
---
## Security
Ollama has **no authentication of any kind**, and its API includes `DELETE /api/delete`
(wipe models) and `POST /api/pull` (fill the disk). It currently binds `0.0.0.0:11434` with
`OLLAMA_ORIGINS=*` — reachable from the entire LAN, not just Tailscale.
The design only needs loopback (owner) plus the Tailscale interface (mesh). `0.0.0.0` is
strictly wider than required, for no benefit. Restrict to loopback + Tailscale, or use
Tailscale ACLs to allow only mesh nodes. Node-level ACLs fit the mesh model better than
app-level auth Ollama cannot provide anyway.
**Done 2026-08-01:** `/ext-varaverk` is now mounted `ro` (was `rw` into live prod).
Verified on the running container — `rw=false`.
**Still open:** the LAN exposure above. Deliberately not folded into the tuning rebuild,
since bind-address versus Tailscale ACL is a decision rather than a setting.
---
## Hardware budget
RTX 3080, 10 GB, pinned to Ollama by UUID — isolated from the Quadro P2000 that Emby
transcodes on. Do not let AI onto the P2000.
**Tuned and measured 2026-08-01.** These are observed values, not estimates.
| | before | after |
|---|---|---|
| `OLLAMA_NUM_PARALLEL` | 2 | **1** |
| `OLLAMA_KV_CACHE_TYPE` | f16 | **q8_0** |
| `OLLAMA_CONTEXT_LENGTH` | 4096 | **16384** |
| `OLLAMA_FLASH_ATTENTION` | false | **true** |
| VRAM used | 9298 MiB (91%) | **8811 MiB (86%)** |
| warm latency | 2.6s | **1.85s** |
| cold load | 28.6s | **1m45s** |
`/api/ps` confirms `ctx=16384` — the increase is real, not just an env var.
**4× the context for less VRAM than before.** Flash Attention plus the quantized KV cache
more than paid for the increase. Cold load got much slower, which is irrelevant while
`OLLAMA_KEEP_ALIVE=-1` pins both models — but it is felt after any container restart.
### Flash Attention is mandatory, not optional
`OLLAMA_KV_CACHE_TYPE=q8_0` **will not load** without it:
```
llama_init_from_model: V cache quantization requires flash_attn
llama-server process no longer running: exit status 1
```
Quantized K/V cache requires Flash Attention. Supported on Ampere and newer; the 3080
qualifies. If the KV cache type is ever changed back toward a quantized value, Flash
Attention must be on or the model silently fails to load and every call errors.
### Tuning order still matters
If these are ever re-tuned from defaults, the order is load-bearing — raising context first
at high utilisation will OOM:
1. `OLLAMA_NUM_PARALLEL` → 1 (each slot multiplies KV cache)
2. `OLLAMA_FLASH_ATTENTION` → true (prerequisite for the next step)
3. `OLLAMA_KV_CACHE_TYPE` → q8_0 (roughly halves KV memory)
4. *then* `OLLAMA_CONTEXT_LENGTH` upward
32k was considered and rejected — projected ~1000 MiB of KV, leaving under 450 MiB headroom.
16k is the comfortable ceiling for a 14B on this card.
### Concurrency
**You cannot have 14B + long context + real parallelism on 10 GB.** Pick two.
`OLLAMA_MAX_QUEUE=512` means excess requests queue rather than fail, and at ~2s responses,
two or three users serialised is barely noticeable. Multi-user hits are expected to be rare.
So: parallelism stays at 1, the queue absorbs bursts, and the VRAM goes to **context**
which is what RAG actually needs.
### Applying template changes
**Unraid's "Apply" does not reliably recreate the container.** Observed 2026-08-01: the
template was saved correctly but the container was only *restarted*, so the env vars never
took effect — `Created` stayed unchanged while `Started` advanced. Env changes require a
remove-and-recreate.
Force it with Unraid's own script:
```bash
/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container Ollama
docker start Ollama # rebuild stops it — Ollama is not in unraid-autostart
```
Verify with `docker inspect Ollama --format '{{.Created}}'` — the timestamp must move.
Checking the env vars alone is not enough; a restart leaves the old ones in place and
looks like nothing happened.
---
## RAG
No Python on Unraid, and none needed. Everything required is already present:
`sqlite3 3.53`, `jq 1.8`, `node 22`, `php 8.4`, `awk`.
- **chunks + vectors** → SQLite, one table
- **similarity** → cosine in PHP or Node; milliseconds at this corpus size, no vector DB
container needed
- **embed + generate** → Ollama HTTP, same `curl` pattern as every other integration here
Note `qwen2.5-coder` returns `501 — does not support embeddings`. Embedding is
`nomic-embed-text`'s job. Batch embedding works (n inputs → n vectors in one call) and is
required — indexing 50k lines one HTTP call at a time is not viable.
---
### The corpus — and why its shape matters more than its size
As of 2026-08-01, after the header audit and the per-folder documentation pass:
| Layer | Size | What it answers |
|-------|------|-----------------|
| Script headers | 115 files × 6 sections = **690 chunks**, 14,685 lines | "What does *this script* do, and why that way" |
| Folder docs | 18 `README-*.md` + 13 `Manual-*.md` | "How does this *group* work" / "how do I do the thing" |
| Top-level | `README.md`, `Manual.md` | "What is this system" |
| Conf templates | 2,175 lines, **~55% comment** | The schema, self-describing |
| Bash bodies | 51,166 lines | Implementation — index last, lowest weight |
Markdown total: **13,516 lines.** Still small enough that cosine over the whole set is
milliseconds.
**Chunking is already solved, and the audit is what solved it.** Every script carries
`PURPOSE / OPERATIONAL MODEL / DESIGN PRINCIPLES / OPERATIONAL SAFEGUARDS / CONFIGURATION /
RUNTIME MODES` — **115 of 115, no exceptions.** Split on `^# SECTION NAME$` and every chunk is
a semantically coherent unit by construction. The single worst failure mode in naive RAG —
a fixed-size window cutting mid-thought and embedding two half-ideas as one vector — cannot
happen here. Median header is 117 lines, so a section lands around 130200 tokens: comfortably
inside `nomic-embed-text`'s window, no sub-splitting needed.
**Store the section name as a column, not just as chunk text.** This is the highest-value
thing the audit bought and it should not be thrown away at index time. Section type is a free
metadata filter, so retrieval can route before it computes similarity:
| Question shape | Filter to |
|----------------|-----------|
| "what stops X and Y overlapping" | `OPERATIONAL SAFEGUARDS` |
| "what variable controls X" | `CONFIGURATION` |
| "does this take --dry-run" | `RUNTIME MODES` |
| "why is it built this way" | `DESIGN PRINCIPLES` |
| "what does this script do" | `PURPOSE` |
Hybrid retrieval essentially for free, because every chunk already has a type.
Suggested table shape:
```sql
CREATE TABLE vv_chunks (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL, -- repo-relative
kind TEXT NOT NULL, -- header | readme | manual | template | body
section TEXT, -- PURPOSE, OPERATIONAL SAFEGUARDS, ... (NULL for md/body)
heading TEXT, -- md ## heading, for doc chunks
content TEXT NOT NULL,
vector BLOB NOT NULL, -- 768 float32
indexed INTEGER NOT NULL -- epoch; re-embed on mtime change only
);
```
### Why this corpus is worth more than an equivalent pile of code
A model can read `mover_stop.sh` and describe what it does. What it *cannot* derive from any
amount of source is that a thing was done deliberately. The audit wrote those down:
- the API cache writers are lockless and unprivileged **on purpose** — regenerable within a
minute, every consumer has a live fallback
- `removeCompletedDownloads` / `removeFailedDownloads` both true is **intended**, not an
oversight
- the arr cleanup ctime gate depends on `media_shares_permissions.sh` staying conditional —
reverting either silently stops orphan collection
- `mesh_monitor.sh`, `adapter.sh`, `decision_engine.sh`, `containers.sh` and
`api_cache_writer.sh` carry no root check and no lock **by design** — each documents why in
its own header (libraries that must not `exit`, read-only probes, or regenerable output
with a live fallback)
Without those in the index, the most likely contribution from an AI assistant reviewing this
repo is a confident regression: *"I notice this script lacks a lock."* Weight
`DESIGN PRINCIPLES` and `OPERATIONAL SAFEGUARDS` heavily for any suggest-a-change flow —
they are the guardrails against the assistant helpfully undoing a decision.
### Indexing is safe by default — keep it that way
`Configurations/*.conf` is gitignored; `Deployment/*.template` is tracked and carries all the
explanatory comments. The corpus therefore describes the full schema while structurally
**never containing a credential**, because the credential-bearing files were never in the repo
to begin with.
Treat that as a deliberate boundary, not a happy accident:
- **index tracked files only** — never walk `Configurations/` or `data/`
- a live conf value that the model genuinely needs should arrive through a *tool call* at
query time, subject to the same redaction rules as everything else in the Security section,
not be baked into a vector at index time
- an embedded secret is unrevocable in a way a logged one is not — there is no rotation story
for a value already averaged into a 768-dim float
### Known gap — the PHP layer is not covered
78 PHP files under `Plugin/unraid/`; **2** carry a `PURPOSE` block. The entire web UI —
`pages/`, `api/`, `include/` — is effectively invisible to retrieval.
Consequence: any "AI helper per Varaverk page" feature has this as a hard prerequisite. A
page-scoped assistant that cannot retrieve the page's own logic is worse than no assistant.
`include/` is the high-value subset to do first — 16 files, and both the pages and the API
endpoints route through the same `vv_*()` builders, so documenting it once covers both
callers. This is a follow-on pass, not a blocker for indexing bash.
---
## Scheduled AI
Same orchestrator tiers as everything else, gated on `AI_ENABLED` plus a reachable host.
Natural fits: weekly digest narration, a periodic pass over `HELD`/report-only findings that
have accumulated, post-incident summaries after a watchdog event.
Must obey the existing tier discipline — an AI job that fails or times out is a non-fatal
step like any other, and never blocks the rest of its tier.
---
## UI
- **Dedicated AI page** in the plugin.
- **Persistent conversation across pages.** A floating widget is not required — a chat column
is fine. The constraint is that Unraid's WebGUI is multi-page PHP with full reloads and no
SPA shell, so persistence means conversation state lives server-side keyed by session, with
the client re-hydrating per page.
- **Per-page AI helpers** — contextual assistance scoped to whatever that page is about.
---
## "Too bad we can't just run the LLM inside Varaverk and cut out Ollama"
There is a real answer: you don't cut Ollama out, you **absorb it**.
Ollama does non-trivial work — model lifecycle, GPU scheduling, keep-alive, batching, an HTTP
API. Reimplementing that in bash is not a good trade. But Varaverk already manages containers
better than most things manage containers. Ollama becomes just another managed container:
- add to `HOST*_WATCHDOG_CONTAINERS` so `docker_watchdog.sh` keeps it healthy
- add to a restart list so it gets the same proactive treatment as everything else
- give it a fallback tier if AI should survive a host outage
- let `docker_update.sh` handle its image updates
That is more Varaverk-native than embedding a model runtime would be, and it costs nothing
new — the machinery already exists and was audited this session.
---
## Open questions
- Un-ignore `Configurations/` into a private repo for conf history? (blocks conf-write, and
overlaps the GitHub-mirror TODO)
- Does the AI page need auth separate from the Unraid WebGUI, given remote mesh members?
- Retention/privacy for conversation history — logs may contain paths, container names,
possibly credentials pasted by a user.
- Is a 7B worth it to buy context + parallelism headroom, or is 14B quality worth the
serialisation? Defer until an actual problem is felt.
---
## RAG — built 2026-08-02
Retrieval is live. `AI/` holds the implementation; `AI/README-AI.md` documents it in full. What
follows is only what changed relative to the plan recorded above.
**Corpus is larger than estimated.** ~2,950 chunks across ~180 files, not the 690 header chunks
projected. Sub-chunking is why — see below.
**Named-paragraph sub-chunking was necessary, and was not in the plan.** Section-level chunks
alone were too coarse. `rsync.sh` documents fourteen safeguards in one 2.8k-char
`OPERATIONAL SAFEGUARDS` block; a query about one of them scored 0.558, below unrelated chunks,
because the other thirteen dominated the vector. Splitting on the named-paragraph titles the
header convention already uses took the same query to 0.718 and first place. The parent section
name is carried onto each sub-chunk so routing still works.
**Two chunker bugs worth remembering.** The last section in a header (RUNTIME MODES in bash,
DEPENDS ON in a page) ran to EOF and swept up every unrelated comment in the file —
`scheduler.php` alone produced an 11k-char chunk of unrelated inline comments. And title
detection must require the *next* line to be indented; without that, any wrapped prose line
became a spurious chunk boundary mid-sentence.
**Section routing is a boost, not a filter.** Intent detection is a heuristic and must not be
able to exclude the chunk holding the answer. `--section=` forces a hard filter when wanted.
**Vectors arrive pre-normalised.** `nomic-embed-text` returns L2-normalised vectors (measured
norm 1.0000001), so cosine is a plain dot product. No normalising step, no magnitude cache.
**`node:sqlite` over a native module.** Still flagged experimental, chosen because it needs no
native compilation on Unraid. Acceptable because the index is disposable — if a Node upgrade
breaks it, rebuild takes minutes. PHP reads the same float32 blobs with `unpack('f*', $blob)`
when the UI needs them.
**Retrieval quality, measured.** 9/10 top-3 hit rate on known-answer questions; the tenth had
the answer at ranks 2 and 3, so 10/10 for answer-present-in-context at k=6. Full retrieval plus
generation runs about 43s warm.
### The finding that validated the whole thing
First real end-to-end question asked which variable controls the mover's grace window. The model
answered `MOVER_STOP_TIMEOUT`, "defaults to 30 seconds", citing `mover_stop.sh CONFIGURATION`.
Variable correct; the 30 was wrong — the real value is 300. The model was quoting the header
verbatim. **The header was stale.**
A sweep for the same pattern found six stale `(default: N)` claims across the repo — all
corrected in the same pass. This is the operating principle for the folder:
> Retrieval is exactly as accurate as the documentation it points at. When an answer looks
> wrong, check the cited source before blaming the model.
It also means the index is a documentation-drift detector, not only a question-answering tool.
### Still deliberately not built
Nothing consults this. Every `AI_ASSIST_*` toggle is false, `AI_CONF_WRITE_ENABLED` is false
with an empty key whitelist, and no watchdog, cleanup or fallback path calls it. Host resolution
across the Tailscale mesh is specified above but not implemented — `ai_index.sh` and
`ai_query.sh` currently require a local `HOST*_OLLAMA_URL` and fail with a clear message when it
is empty, rather than silently probing the mesh.
-125
View File
@@ -1,125 +0,0 @@
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
claude
need a script that check file locations. like if a kids movie is in movies. it needs to detect this and update the location in the arrs. so it would tell radarr to move shrek from movies to kids movies and trck the new file
look at a way to better determine when a container was manually shutdown for docker watchdog
we may need to add this to our preflight before lidarr cleanup. check if lidarr need corrected before
removal. since we have a strike list we can save results of previous scan then run this script then
continue. lidarr has been wierd for the last year. maybe we
────────────────────────────────────────────────────────────────
now we should be able to allow or have other host be able to start a shared srvice if desired using
shared auth stack, maybe a container array in host*.conf folder that can add the container to the other
servers, just thinkig of future design
. fix fallback strike list timing, maybe 30 seconds. them a t 90 seconds 3 stike triggers. just gotta test buffer. never had the strike system
. verify silent toggle switches back on good notifications
Saved. Here's where we land and what to pick up:
What happened: Lidarr was silently discarding valid files because it selected the wrong MusicBrainz release edition (Brazil CD, Japan Digital, etc.) while your files were tagged for a different one (US CD, Vinyl). The track IDs don't match across editions, so RescanFolders always returned "Importing 0 tracks." Not a file problem — a database mismatch. The 5GB threshold in lidarr_cleanup.sh was doing exactly what it should.
Fixed this session: 123 albums bulk-corrected (read MUSICBRAINZ_ALBUMID from FLAC tags, switched Lidarr's release selection, RefreshArtist). Full root rescan still running in the background.
Next session — pick up here:
1. Design the daily fixer script (Media/lidarr_release_fixer.sh) — FLAC + MP3 (ID3 TXXX) tag reading, same bulk-fix logic, runs from daily orchestrator. Strike system in lidarr_cleanup.sh already provides the protection window.
2. 42 albums where file MBID isn't in Lidarr at all (unofficial/re-tagged) — manual import or ignore.
3. 217 MP3 albums — need ID3 TXXX frame reader added to the fixer.
4. Check the full root rescan completed and see if zero-file count dropped further below 927.
WAY LATER
now we should be able to allow or have other host be able to start a shared srvice if desired using
shared auth stack, maybe a container array in host*.conf folder that can add the container to the other
servers, just thinkig of future design
when a owner offboards and there is more than 1 server left, one needs to become the owner, we could do this in multiple ways or a combination, strongest server and BANDWIDTH,whos contributing more.... maybe we just promt and ask them
look into an overall, setup script, pull as many vars as possible without user having to add. like docker names, and so on.
fallback, should partnership be authoritive. like no partners fallback fails and after x amount of time dissables untill a new remote is found
i need a way to get rid of git. the apllication becomes the source for each mesh cluster... so it gets dl from github, or ca. i need the app layer to be able to open and edit settings, like open conf first. things have to filled out for it to work. , and partnership deploy to second server, third server........... and push the correct host.conf. and only update that file if new things are added or completely wiped....... cant mod a var nut can rearrang the script if structure changes again......... even if git it used as a core and is auto set from the start..... the app layer is king moving forward, and scipts are cores that just get called. i have all the flexabiliy in the world being able to change things and the app layers call and use it............
we may need a startup guide.. first launch, open master.conf. then host1.
we need to updata the folder Unraid_Scripts to Varaverk every where and update git script, and update all traces and scripts that call it and update the user script plugin......
with this app layer i think we modle the user script to our system and no longer user facing..... maybe
uide. owner sets up first.. establishes himself in master.conf as host1.... sets up the rest of master.conf.. little changes may be needed.. then fills out host1.conf.... then makes sure machines are shared in tail scale.. start onboard.......... then host2, host3, what ever is in master.conf it will reachout, uinstall correct containers,and leave the state file... install varaverk on host 2 and host 3. on install it sees the state file and defuaults to mirror. they fill out their setting and all should start working.......
working on getting this down to just web ui. on intitial no state file opens master.conf, locks you out of enabling any orchs untill set up.....
+32 -34
View File
@@ -222,10 +222,9 @@ ARRAY_START_SCRIPTS=(
# containers inherit limits at startup
"Docker_Essentials/docker_network_connect.sh" # ensure networks + connections BEFORE
# watchdogs check container states
"Tools/claude_startup.sh" # symlink Claude persistent storage on /boot
# ── Continuous scripts — run until array stops ─────────────────────────────
"Media/start_webhook_listener.sh" # start webhook listener before arrs POST events
"Arrs_Stack/start_webhook_listener.sh" # start webhook listener before arrs POST events
"Fallback/fallback.sh" # fallback LAST — needs everything else stable
)
@@ -539,7 +538,7 @@ Runs on both servers; `detect_hosts()` determines which direction each sync goes
# Drive temperature exit codes respected — skip share or abort all on CRIT.
#
# 3. Post-sync: DAILY_MAINTENANCE_SCRIPTS (everything except git pull)
# media_management.sh → permissions + cleaners + arr cleanup
# Media/ + Arrs_Stack/ scripts → permissions, cleaners, arr cleanup, classification
# docker_daily_restart.sh → nightly container restarts
# ─────────────────────────────────────────────────────────────────────────────
```
@@ -558,12 +557,12 @@ Runs on both servers; `detect_hosts()` determines which direction each sync goes
#
# HOST1 runs this script at 1am:
# → pushes HOST1_DAILY_SYNC_SHARES (Movies, Tv_Shows, Music) → HOST2
# → media_management.sh on HOST1's shares
# → Media/ + Arrs_Stack/ scripts on HOST1's shares
# → docker_daily_restart.sh on HOST1's containers
#
# HOST2 runs this script at 1am:
# → pushes HOST2_DAILY_SYNC_SHARES (Anime_Shows, Anime_Movies) → HOST1
# → media_management.sh on HOST2's shares
# → Media/ + Arrs_Stack/ scripts on HOST2's shares
# → docker_daily_restart.sh on HOST2's containers
# ─────────────────────────────────────────────────────────────────────────────
```
@@ -583,9 +582,9 @@ DAILY_MAINTENANCE_SCRIPTS=(
"Media/media_shares_permissions.sh" # POST-SYNC — permissions before arr
"Media/media_cleaner.sh anime" # POST-SYNC — junk before orphan scan
"Media/media_cleaner.sh media" # POST-SYNC
"Media/lidarr_cleanup.sh" # POST-SYNC — orphan cleanup last
"Media/sonarr_cleanup.sh" # POST-SYNC
"Media/radarr_cleanup.sh" # POST-SYNC
"Arrs_Stack/lidarr_cleanup.sh" # POST-SYNC — orphan cleanup last
"Arrs_Stack/sonarr_cleanup.sh" # POST-SYNC
"Arrs_Stack/radarr_cleanup.sh" # POST-SYNC
"Docker_Essentials/docker_daily_restart.sh" # POST-SYNC — restarts after everything
)
@@ -625,9 +624,9 @@ DAILY_MAINTENANCE_SCRIPTS=(
"Media/media_cleaner.sh anime"
"Media/media_cleaner.sh media"
"Media/my_new_script.sh" # ← add here in the correct order
"Media/lidarr_cleanup.sh"
"Media/sonarr_cleanup.sh"
"Media/radarr_cleanup.sh"
"Arrs_Stack/lidarr_cleanup.sh"
"Arrs_Stack/sonarr_cleanup.sh"
"Arrs_Stack/radarr_cleanup.sh"
"Docker_Essentials/docker_daily_restart.sh"
)
@@ -637,7 +636,7 @@ DAILY_MAINTENANCE_SCRIPTS=(
"Media/media_shares_permissions.sh"
# "Media/media_cleaner.sh anime" # ← temporarily disabled
"Media/media_cleaner.sh media"
"Media/lidarr_cleanup.sh"
"Arrs_Stack/lidarr_cleanup.sh"
...
)
# ─────────────────────────────────────────────────────────────────────────────
@@ -943,41 +942,40 @@ array_started.sh
## ━━━ ADDING A NEW ORCHESTRATOR ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
If you find yourself running 3+ related scripts on the same schedule, wrap them
in a new orchestrator. Model directly on `media_management.sh` which has the
in a new orchestrator. Model directly on `daily_sync_maintenance.sh` which has the
complete pattern — dry-run passthrough, status display, pass/fail tracking, summary.
Child-script execution goes through the shared `run_orch_child()` helper in
`common.sh` — never hand-roll a per-file `run_job()` loop. It resolves the entry
against `$ECOSYSTEM_ROOT`, threads `--dry-run`/`--log` from `$DRY_RUN`/`$ENABLE_LOGGING`
automatically (never `$VERBOSE` — nothing in this codebase assigns it), and tracks
into `JOB_PASS`/`JOB_FAIL` arrays the caller declares.
```bash
# Minimal skeleton — the full pattern in its simplest form:
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ECOSYSTEM_ROOT/load_config.sh"
parse_args "$@"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
PASS=()
FAIL=()
JOB_PASS=()
JOB_FAIL=()
# Read job list from master.conf — never hardcode jobs in the orchestrator
for script_entry in "${MY_MAINTENANCE_JOBS[@]:-}"; do
[[ -z "$script_entry" ]] && continue
read -r -a parts <<< "$script_entry"
script_path="$SCRIPTS_ROOT/${parts[0]}"
script_name=$(basename "${parts[0]}")
extra_args=("${parts[@]:1}")
[[ "$DRY_RUN" == true ]] && extra_args+=("--dry-run")
if bash "$script_path" "${extra_args[@]}"; then
PASS+=("$script_name")
else
FAIL+=("$script_name")
fi
run_orch_child "$script_entry"
done
# One summary — one notification
echo "Passed: ${#PASS[@]} Failed: ${#FAIL[@]}"
[[ ${#FAIL[@]} -gt 0 ]] && \
notify "My maintenance failed on $(hostname) ($MY_ID) — ${FAIL[*]}" \
# One summary — one notification. On a frequent (sub-daily) cadence, keep the
# healthy path to a single line and reserve the full breakdown for failure/--log —
# see watchdog_orchestrator.sh or transcode_management.sh for that split.
echo "Passed: ${#JOB_PASS[@]} Failed: ${#JOB_FAIL[@]}"
if [[ ${#JOB_FAIL[@]} -gt 0 ]]; then
notify "My maintenance failed on $(hostname) ($MY_ID) — ${JOB_FAIL[*]}" \
"My Orchestrator" "warning"
exit 1
fi
exit 0
```
+52 -31
View File
@@ -55,13 +55,33 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — all launched scripts require root
# acquire_lock — prevents duplicate array start launches
# detect_hosts() — MY_ID in notifications
# platform_require_cmd — notify validated before use
# chmod +x auto-fix — non-executable scripts fixed before launch
# Full path on failure — shows exact path for debugging
# notify on failures — alert if any script fails to launch
# Root Enforcement
# Every script launched here requires root.
#
# Lock Acquisition
# acquire_lock prevents duplicate array start launches. Unraid can fire the array
# start hook more than once, and a second pass would re-launch continuous scripts
# that are already running.
#
# Host Detection
# detect_hosts() sets MY_ID for notifications.
#
# Empty Job List Guard
# Exits with an error and a notification if ARRAY_START_SCRIPTS is empty. An empty
# list would silently bring the array up with no ramdisk, no network setup, no
# watchdogs and no fallback — while reporting a clean start.
#
# Executable Auto-Fix
# Non-executable scripts are chmod +x'd before launch. A permission bit lost to a
# git checkout or a file copy should not silently disable a boot-time component.
#
# Full Path on Failure
# Failures report the exact resolved path, so a missing script is immediately
# distinguishable from a script that ran and failed.
#
# Failure Notification
# Any script that fails to launch raises a notification — array start is unattended,
# so a silent failure here would only surface much later as a missing service.
#
# ==============================================================================================
# CONFIGURATION
@@ -109,6 +129,16 @@ acquire_lock
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
if [[ ${#ARRAY_START_SCRIPTS[@]} -eq 0 ]]; then
error "ARRAY_START_SCRIPTS is empty — no array start scripts will run"
error "Check ARRAY_START_SCRIPTS in master.conf"
notify "array start scripts skipped on $(hostname) ($MY_ID) — ARRAY_START_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — scripts will not be launched"
# ==============================================================================================
@@ -163,9 +193,8 @@ log "Launching ${#ARRAY_START_SCRIPTS[@]} script(s)..."
echo ""
START=$(date +%s)
LAUNCHED=0
FAILED=0
FAILED_SCRIPTS=()
JOB_PASS=()
JOB_FAIL=()
for relative_path in "${ARRAY_START_SCRIPTS[@]}"; do
[[ -z "$relative_path" ]] && continue
@@ -177,8 +206,7 @@ for relative_path in "${ARRAY_START_SCRIPTS[@]}"; do
if [[ ! -f "$SCRIPT_PATH" ]]; then
error "$SCRIPT_NAME — not found"
error " Expected: $SCRIPT_PATH"
(( FAILED++ ))
FAILED_SCRIPTS+=("$SCRIPT_NAME")
JOB_FAIL+=("$SCRIPT_NAME")
continue
fi
@@ -187,15 +215,14 @@ for relative_path in "${ARRAY_START_SCRIPTS[@]}"; do
warn "$SCRIPT_NAME — not executable, fixing..."
chmod +x "$SCRIPT_PATH" || {
error "$SCRIPT_NAME — chmod +x failed"
(( FAILED++ ))
FAILED_SCRIPTS+=("$SCRIPT_NAME")
JOB_FAIL+=("$SCRIPT_NAME")
continue
}
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would launch: $SCRIPT_NAME"
(( LAUNCHED++ ))
JOB_PASS+=("$SCRIPT_NAME")
continue
fi
@@ -209,19 +236,18 @@ for relative_path in "${ARRAY_START_SCRIPTS[@]}"; do
if kill -0 "$PID" 2>/dev/null; then
# Still running → continuous script
log "$SCRIPT_NAME — running (PID $PID) ✅"
(( LAUNCHED++ ))
JOB_PASS+=("$SCRIPT_NAME")
else
# Exited — check if one-shot success or failure
wait "$PID"
EXIT_CODE=$?
if [[ "$EXIT_CODE" -eq 0 ]]; then
echo "$SCRIPT_NAME — completed (one-shot) ✅"
(( LAUNCHED++ ))
JOB_PASS+=("$SCRIPT_NAME")
else
error "$SCRIPT_NAME — exited with code $EXIT_CODE"
error " Path: $SCRIPT_PATH"
(( FAILED++ ))
FAILED_SCRIPTS+=("$SCRIPT_NAME")
JOB_FAIL+=("$SCRIPT_NAME")
fi
fi
@@ -235,18 +261,13 @@ END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY ARRAY START SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SUCCESS Launched: $LAUNCHED"
[[ "$FAILED" -gt 0 ]] && echo "$ICON_ERROR Failed: $FAILED${FAILED_SCRIPTS[*]}"
echo "$ICON_SUCCESS Launched: ${#JOB_PASS[@]}"
[[ ${#JOB_FAIL[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${#JOB_FAIL[@]}${JOB_FAIL[*]}"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no scripts launched"
elif [[ "$FAILED" -gt 0 ]]; then
warn "Status: $FAILED script(s) failed — ${FAILED_SCRIPTS[*]}"
notify "Array start on $(hostname) ($MY_ID) — $FAILED script(s) failed: ${FAILED_SCRIPTS[*]}" \
"Array Start" "warning"
else
echo "$ICON_DONE Status: all $LAUNCHED script(s) launched ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# The configured list is the denominator — a script the conf names but that never launched is
# skipped, not absent, and only shows up if something counts it.
JOB_COUNT="${#ARRAY_START_SCRIPTS[@]}"
orchestrator_summary "ARRAY START" "$START" "Array Start"
exit $?
+40 -58
View File
@@ -40,11 +40,28 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — all stop scripts require root
# acquire_lock — prevents concurrent array stop runs
# detect_hosts() — MY_ID in notifications and logs
# platform_require_cmd — notify validated before use
# notify on failures — alert if any stop script fails
# Root Enforcement
# Every stop script launched here requires root.
#
# Lock Acquisition
# acquire_lock prevents concurrent array stop runs. Two overlapping shutdown
# sequences would fight over the same containers.
#
# Host Detection
# detect_hosts() sets MY_ID for notifications and logs.
#
# Empty Job List Guard
# Exits with an error and a notification if ARRAY_STOP_SCRIPTS is empty. An empty
# list means the array stops without saving the conf cache or gracefully stopping
# containers — the failure would only be discovered at the next boot.
#
# Non-Fatal Steps
# A failing stop script is recorded and the remaining ones still run. Abandoning the
# shutdown sequence partway would leave more state unsaved than continuing does.
#
# Failure Notification
# Any failing stop script raises a notification. Shutdown is unattended and its
# failures are invisible until they cause a problem on the way back up.
#
# ==============================================================================================
# CONFIGURATION
@@ -97,6 +114,16 @@ fi
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
if [[ ${#ARRAY_STOP_SCRIPTS[@]} -eq 0 ]]; then
error "ARRAY_STOP_SCRIPTS is empty — no array stop scripts will run"
error "Check ARRAY_STOP_SCRIPTS in master.conf"
notify "array stop scripts skipped on $(hostname) ($MY_ID) — ARRAY_STOP_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no stop scripts will be executed"
# ==============================================================================================
@@ -134,8 +161,8 @@ echo "$ICON_GEAR Running ${#ARRAY_STOP_SCRIPTS[@]} stop script(s) sequentially..
echo ""
START=$(date +%s)
PASSED=()
FAILED=()
JOB_PASS=()
JOB_FAIL=()
STEP=0
for entry in "${ARRAY_STOP_SCRIPTS[@]}"; do
@@ -143,44 +170,11 @@ for entry in "${ARRAY_STOP_SCRIPTS[@]}"; do
(( STEP++ ))
read -r -a parts <<< "$entry"
script_path="$ECOSYSTEM_ROOT/${parts[0]}"
script_name=$(basename "${parts[0]}")
extra_args=("${parts[@]:1}")
echo "━━━ $ICON_GEAR Step $STEP: $script_name${extra_args:+ ${extra_args[*]}} ━━━"
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
FAILED+=("$script_name")
echo ""
continue
fi
if [[ ! -x "$script_path" ]]; then
warn "$script_name — not executable, fixing..."
chmod +x "$script_path" || {
error "$script_name — chmod +x failed"
FAILED+=("$script_name")
echo ""
continue
}
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would run: $script_name ${extra_args[*]}"
PASSED+=("$script_name")
echo ""
continue
fi
if bash "$script_path" "${extra_args[@]}"; then
echo "$script_name — done ✅"
PASSED+=("$script_name")
else
warn "$script_name — failed (exit $?) — continuing to next step"
FAILED+=("$script_name")
fi
run_orch_child "$entry"
echo ""
done
@@ -192,22 +186,10 @@ END=$(date +%s)
echo "━━━━━ $ICON_SUMMARY ARRAY STOP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
[[ ${#PASSED[@]} -gt 0 ]] && echo "$ICON_DONE Passed: ${PASSED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
[[ ${#JOB_PASS[@]} -gt 0 ]] && echo "$ICON_DONE Passed: ${JOB_PASS[*]}"
[[ ${#JOB_FAIL[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${JOB_FAIL[*]}"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: all $STEP step(s) complete ✅"
notify "Array stop complete on $(hostname) ($MY_ID) — $STEP step(s) done" \
"Array Stop" "normal"
else
warn "Status: ${#FAILED[@]} step(s) failed — ${FAILED[*]}"
notify "Array stop on $(hostname) ($MY_ID) — ${#FAILED[@]} step(s) failed: ${FAILED[*]}" \
"Array Stop" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#FAILED[@]} -gt 0 ]] && exit 1
exit 0
JOB_COUNT="$STEP"
orchestrator_summary "ARRAY STOP" "$START" "Array Stop"
exit $?
+64 -44
View File
@@ -44,11 +44,31 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — rsync and container stop/start require root
# acquire_lock "strict" — no pile-up; skip cycle if prior run still active
# detect_hosts() — MY_ID and REMOTE_ID for routing and logs
# resolve_remote_ip — confirms remote reachability before any transfer
# RSYNC_ENABLED gate — global kill switch respected before any rsync call
# Root Enforcement
# rsync over SSH and container stop/start both require root.
#
# Lock Acquisition
# acquire_lock in strict mode — a cycle is skipped rather than queued if the previous
# one is still running. At a 30-minute cadence, queuing would let a slow sync stack
# windows behind it.
#
# Host Detection
# detect_hosts() sets MY_ID and REMOTE_ID for routing and logs.
#
# Empty Job List Guard
# Exits with an error and a notification if CRITICAL_MAINTENANCE_SCRIPTS is empty —
# a silently empty critical tier would stop downloader resets and play-state sync
# while still reporting success every 30 minutes.
#
# Remote IP Resolution
# resolve_remote_ip confirms the partner is reachable before any transfer is attempted.
#
# RSYNC_ENABLED Gate
# The global kill switch is respected before any rsync call, so disabling rsync
# ecosystem-wide genuinely stops it here too.
#
# Non-Fatal Steps
# A failing job is recorded and the rest of the tier still runs.
#
# ==============================================================================================
# CONFIGURATION
@@ -80,6 +100,7 @@
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
@@ -97,6 +118,16 @@ fi
acquire_lock "strict"
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
if [[ ${#CRITICAL_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
error "CRITICAL_MAINTENANCE_SCRIPTS is empty — no critical maintenance scripts will run"
error "Check CRITICAL_MAINTENANCE_SCRIPTS in master.conf"
notify "critical maintenance scripts skipped on $(hostname) ($MY_ID) — CRITICAL_MAINTENANCE_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
resolve_remote_ip
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
@@ -199,29 +230,12 @@ fi
# ==============================================================================================
# ━━━ Critical Maintenance Scripts ━━━
# ==============================================================================================
if [[ ${#CRITICAL_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
for script_entry in "${CRITICAL_MAINTENANCE_SCRIPTS[@]}"; do
JOB_PASS=()
JOB_FAIL=()
for script_entry in "${CRITICAL_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" || "$script_entry" == \#* ]] && continue
SCRIPT_PATH="$SCRIPT_DIR/../${script_entry%% *}"
SCRIPT_ARGS="${script_entry#* }"
[[ "$SCRIPT_ARGS" == "$script_entry" ]] && SCRIPT_ARGS=""
[[ "$DRY_RUN" == true ]] && SCRIPT_ARGS="$SCRIPT_ARGS --dry-run"
SCRIPT_NAME=$(basename "$SCRIPT_PATH")
if [[ ! -f "$SCRIPT_PATH" ]]; then
warn "$SCRIPT_NAME not found at $SCRIPT_PATH — skipping"
continue
fi
log "Running: $SCRIPT_NAME"
bash "$SCRIPT_PATH" $SCRIPT_ARGS
EXIT_CODE=$?
[[ "$EXIT_CODE" -ne 0 ]] && \
warn "$SCRIPT_NAME exited with code $EXIT_CODE"
done
fi
run_orch_child "$script_entry"
done
# ==============================================================================================
# ━━━ Partnership Check ━━━
@@ -230,9 +244,28 @@ if [[ "${PARTNERSHIP_ENABLED:-false}" == true ]]; then
PARTNER_DRY=""
[[ "$DRY_RUN" == true ]] && PARTNER_DRY="--dry-run"
# A successful rsync proves the partner answered; a failed one is evidence it did not. But
# rsync being switched off is neither — and it used to be read as "unseen", so the offline
# counter climbed every 30 minutes toward the 30-day auto-offboard on a partnership whose
# only fault was that RSYNC_ENABLED=false. That is how a deliberately paused sync ends up
# dismantling the partnership it was paused for. With no rsync attempt there is nothing to
# report, so the check runs without touching the counter either way.
# Tier 2 counts as "switched off" here exactly as much as Tier 1 does. The guard below used
# to test RSYNC_ENABLED alone, but it is CRITICAL_RSYNC_ENABLED that governs whether this
# orchestrator attempts an rsync at all — so with Tier 1 open and Tier 2 closed, no transfer
# was attempted, RSYNC_OK stayed false, and the run fell through to --remote-unseen and
# incremented the counter every 30 minutes against a partner that was answering fine.
#
# Onboard Step 1d now leaves precisely that posture on purpose — Tier 1 open so provisioning
# can run, every Tier 2 gate closed so nothing is scheduled. A freshly onboarded, perfectly
# healthy partnership would have auto-offboarded itself 30 days later.
if [[ "$RSYNC_OK" == true ]]; then
bash "$SCRIPT_DIR/../Partnership/partnership_manager.sh" \
--check --remote-seen $PARTNER_DRY
elif [[ "${RSYNC_ENABLED:-false}" != true || "${CRITICAL_RSYNC_ENABLED:-false}" != true ]]; then
echo "Critical rsync gated off — partnership check runs, offline counter untouched"
bash "$SCRIPT_DIR/../Partnership/partnership_manager.sh" \
--check $PARTNER_DRY
else
bash "$SCRIPT_DIR/../Partnership/partnership_manager.sh" \
--check --remote-unseen $PARTNER_DRY
@@ -247,20 +280,7 @@ fi
END=$(date +%s)
DURATION=$(format_duration $(( END - START )))
# Silent when healthy — only show summary if there were failures or notable events
if [[ ${#FAIL[@]} -gt 0 ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY CRITICAL SYNC SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $DURATION"
[[ ${#PASS[@]} -gt 0 ]] && echo "Synced: ${PASS[*]}"
echo "$ICON_ERROR Failed: ${FAIL[*]}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
notify "Critical sync failed on $(hostname) ($MY_ID) — ${FAIL[*]}" \
"Critical Sync" "warning"
exit 1
else
echo "Critical sync complete — $MY_ID${DURATION}${#PASS[@]} share(s)"
fi
exit 0
# Standard ending, quiet mode — 30-min cadence, so a healthy cycle stays one line.
[[ ${#PASS[@]} -gt 0 ]] && echo "Synced: ${PASS[*]}"
orchestrator_summary "CRITICAL SYNC" "$START" "Critical Sync" quiet
exit $?
+53 -59
View File
@@ -59,13 +59,40 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — rsync and docker operations require root
# acquire_lock — prevents concurrent daily windows
# detect_hosts() — aliases correct per-host share lists
# check_connectivity — verified before any rsync
# check_remote_rootfs — aborts rsync if remote rootfs nearly full
# Non-fatal jobs — a failed job logs and continues; remaining jobs still run
# notify on failure — successful daily run produces no notification
# Root Enforcement
# rsync and docker operations require root.
#
# Lock Acquisition
# acquire_lock prevents two daily windows overlapping — the window is long and a
# second pass would contend for the same shares and containers.
#
# Host Detection
# detect_hosts() aliases the correct per-host share and script lists.
#
# Empty Job List Guard
# Exits with an error and a notification if DAILY_MAINTENANCE_SCRIPTS is empty. This
# is the largest tier in the ecosystem — an empty list would silently skip git pull,
# permissions, cleaners, arr cleanup and docker updates while reporting a clean run.
#
# Connectivity Check
# check_connectivity is verified before any rsync is attempted.
#
# Remote Rootfs Check
# check_remote_rootfs aborts rsync if the remote rootfs is nearly full, rather than
# pushing data to a partner that cannot hold it.
#
# Drive Temperature Escalation
# rsync.sh's exit code is honoured per share: exit 1 (temp WARN) skips that share and
# continues; exit 2 (temp CRITICAL) sets ABORT_ALL_SYNCS so every remaining share in
# the window is skipped and a notification is raised. Continuing to hammer drives that
# are already too hot is how a thermal warning becomes a dead disk.
#
# Non-Fatal Jobs
# A failed job is logged and the remaining jobs still run. Partial completion of a
# maintenance window beats abandoning it at the first error.
#
# Quiet on Success
# A successful daily run produces no notification — only failures surface.
#
# ==============================================================================================
# CONFIGURATION
@@ -103,8 +130,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RSYNC_SCRIPT="$ECOSYSTEM_ROOT/Rsync/rsync.sh"
parse_args "$@"
@@ -123,6 +150,16 @@ if ! command -v docker &>/dev/null; then
fi
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
if [[ ${#DAILY_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
error "DAILY_MAINTENANCE_SCRIPTS is empty — no daily maintenance scripts will run"
error "Check DAILY_MAINTENANCE_SCRIPTS in master.conf"
notify "daily maintenance scripts skipped on $(hostname) ($MY_ID) — DAILY_MAINTENANCE_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
resolve_remote_ip
acquire_lock
@@ -184,35 +221,6 @@ for script_entry in "${DAILY_MAINTENANCE_SCRIPTS[@]}"; do
fi
done
# Helper — run a maintenance script, track pass/fail
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
echo "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
error "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
WINDOW_START=$(date +%s)
JOB_PASS=()
JOB_FAIL=()
@@ -230,7 +238,7 @@ if [[ ${#PRE_SYNC_SCRIPTS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_GIT Pre-sync ━━━"
for script_entry in "${PRE_SYNC_SCRIPTS[@]}"; do
run_job "$script_entry"
run_orch_child "$script_entry"
done
fi
@@ -240,7 +248,7 @@ fi
echo ""
echo "━━━ $ICON_SYNC Arr Sync ━━━"
ARR_SYNC_SCRIPT="$SCRIPTS_ROOT/Media/arr_sync.sh"
ARR_SYNC_SCRIPT="$ECOSYSTEM_ROOT/Arrs_Stack/arr_sync.sh"
if [[ "${ARR_SYNC_ENABLED:-true}" != "true" ]]; then
echo "ARR_SYNC_ENABLED=false — skipping"
elif [[ ! -f "$ARR_SYNC_SCRIPT" ]]; then
@@ -336,7 +344,7 @@ if [[ ${#POST_SYNC_SCRIPTS[@]} -gt 0 ]]; then
echo "━━━ $ICON_CLEAN Post-sync Maintenance ━━━"
for script_entry in "${POST_SYNC_SCRIPTS[@]}"; do
echo ""
run_job "$script_entry"
run_orch_child "$script_entry"
done
fi
@@ -346,11 +354,6 @@ WINDOW_END=$(date +%s)
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY DAILY MAINTENANCE SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S')$(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo ""
echo "$ICON_SYNC Shares ($SHARE_COUNT):"
for entry in "${SHARE_TIMES[@]}"; do
@@ -374,16 +377,7 @@ if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo ""
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
if [[ "$TOTAL_FAIL" -gt 0 ]]; then
warn "Status: $TOTAL_FAIL failure(s)"
notify "Daily maintenance completed with failures on $(hostname) ($MY_ID) — shares: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Daily Maintenance" "warning"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 1
else
echo "$ICON_DONE Status: all complete — ${#PASS[@]} share(s) synced, ${#JOB_PASS[@]} job(s) run"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# Standard ending — derives skipped from SHARE_COUNT, so a run with rsync gated off reports
# PARTIAL instead of "all complete".
orchestrator_summary "DAILY MAINTENANCE" "$WINDOW_START" "Daily Maintenance"
exit $?
+79 -76
View File
@@ -12,7 +12,8 @@
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. conf_sync.sh --pull-only — refresh partner conf cache in RAM (/tmp/.cache/vv/d/)
# 1. conf_sync.sh — refresh partner conf cache in RAM (/tmp/varaverk/conf/),
# both directions: pull theirs, push ours
# 2. arr_sync.sh — sync Lidarr/Sonarr/Radarr libraries across all nodes
# 3. Rsync window (optional) — INTERMEDIATE_SYNC_SHARES, if any configured
# 4. INTERMEDIATE_MAINTENANCE_SCRIPTS — artwork fetch and any future 4-hour jobs
@@ -31,10 +32,6 @@
# arrs search for content they don't know is already owned. Running every 4
# hours closes that gap.
#
# Idempotent Artwork
# lidarr_missing_art.sh skips existing files and runs fast after initial fill.
# Pairing it here means artwork catches up within 4 hours of a new album landing.
#
# Optional Rsync Layer
# INTERMEDIATE_SYNC_SHARES is empty by default — the rsync step is skipped
# entirely when nothing is configured. Add shares only if a subset of data
@@ -44,13 +41,36 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — scripts called here require root
# acquire_lock — prevents concurrent intermediate windows
# detect_hosts() — aliases correct per-host share lists
# check_connectivity — verified before any rsync (skipped if no shares)
# check_remote_rootfs — aborts rsync if remote rootfs nearly full
# Non-fatal jobs — a failed arr_sync warns but does not block rsync or artwork fetch
# Silent on success — runs 4x/day, only failures warrant notification
# Root Enforcement
# Every script called from here requires root.
#
# Lock Acquisition
# acquire_lock prevents concurrent intermediate windows.
#
# Host Detection
# detect_hosts() aliases the correct per-host share lists.
#
# Empty Job List Guard
# Exits with an error and a notification if INTERMEDIATE_MAINTENANCE_SCRIPTS is empty,
# rather than running six no-op windows a day that all report success.
#
# Connectivity Check
# check_connectivity is verified before any rsync, and skipped entirely when no shares
# are configured — there is nothing to reach a partner for.
#
# Remote Rootfs Check
# check_remote_rootfs aborts rsync if the remote rootfs is nearly full.
#
# Drive Temperature Escalation
# rsync.sh's exit code is honoured per share: exit 1 skips that share, exit 2 aborts
# every remaining sync in the window and notifies.
#
# Non-Fatal Jobs
# A failing arr_sync warns but does not block rsync or the artwork fetch that follow it.
#
# Minimal on Success
# Runs six times a day, so the full breakdown only prints on failure or with --log.
# A quiet run is the normal outcome and should not fill the log.
#
# ==============================================================================================
# CONFIGURATION
@@ -88,8 +108,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RSYNC_SCRIPT="$ECOSYSTEM_ROOT/Rsync/rsync.sh"
parse_args "$@"
@@ -105,39 +125,20 @@ fi
acquire_lock
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
error "INTERMEDIATE_MAINTENANCE_SCRIPTS is empty — no intermediate maintenance scripts will run"
error "Check INTERMEDIATE_MAINTENANCE_SCRIPTS in master.conf"
notify "intermediate maintenance scripts skipped on $(hostname) ($MY_ID) — INTERMEDIATE_MAINTENANCE_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
resolve_remote_ip
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Helper — run a maintenance job, track pass/fail ───────────────────────────────────────────
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
echo "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
warn "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
@@ -182,23 +183,27 @@ echo ""
echo "━━━ $ICON_GEAR Intermediate Sync — $MY_ID$(date '+%Y-%m-%d %H:%M:%S') ━━━"
# ==============================================================================================
# ━━━ Conf Pull ━━━
# ━━━ Conf Sync ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Conf Pull ━━━"
echo "━━━ $ICON_GEAR Conf Sync ━━━"
CONF_SYNC_SCRIPT="$SCRIPTS_ROOT/System_Essentials/conf_sync.sh"
# Full sync, not --pull-only. The push half was written as an event-driven fast path for the
# conf-save hook, but no such hook was ever built — so outside array start nothing pushed this
# host's conf to its partners at all, and a partner's copy of our conf stayed at whatever it was
# when we last rebooted. Pull alone kept our view of them fresh while their view of us decayed.
CONF_SYNC_SCRIPT="$ECOSYSTEM_ROOT/System_Essentials/conf_sync.sh"
if [[ ! -f "$CONF_SYNC_SCRIPT" ]]; then
warn "conf_sync.sh not found — skipping partner conf refresh"
else
_conf_args=("--pull-only")
_conf_args=()
[[ "$DRY_RUN" == true ]] && _conf_args+=("--dry-run")
if bash "$CONF_SYNC_SCRIPT" "${_conf_args[@]}"; then
echo "Partner conf cache refreshed ✅"
JOB_PASS+=("conf_sync.sh --pull-only")
JOB_PASS+=("conf_sync.sh")
else
warn "Partner conf pull failed — cache may be stale"
JOB_FAIL+=("conf_sync.sh --pull-only")
warn "Partner conf sync failed — cache may be stale"
JOB_FAIL+=("conf_sync.sh")
fi
unset _conf_args
fi
@@ -209,7 +214,7 @@ fi
echo ""
echo "━━━ $ICON_SYNC Arr Sync ━━━"
ARR_SYNC_SCRIPT="$SCRIPTS_ROOT/Media/arr_sync.sh"
ARR_SYNC_SCRIPT="$ECOSYSTEM_ROOT/Arrs_Stack/arr_sync.sh"
if [[ "${ARR_SYNC_ENABLED:-true}" != "true" ]]; then
echo "ARR_SYNC_ENABLED=false — skipping"
elif [[ ! -f "$ARR_SYNC_SCRIPT" ]]; then
@@ -306,7 +311,7 @@ if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
for script_entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
echo ""
run_job "$script_entry"
run_orch_child "$script_entry"
done
fi
@@ -315,14 +320,21 @@ WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S')$(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo ""
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
if [[ "$SHARE_COUNT" -gt 0 ]]; then
# Full breakdown on failure or --log; minimal one-liner otherwise (4-hour cadence — keep it quiet).
SHOW_FULL=false
[[ "$TOTAL_FAIL" -gt 0 || "$ENABLE_LOGGING" == true ]] && SHOW_FULL=true
if [[ "$SHOW_FULL" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S')$(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo ""
if [[ "$SHARE_COUNT" -gt 0 ]]; then
echo "$ICON_SYNC Shares ($SHARE_COUNT):"
for entry in "${SHARE_TIMES[@]}"; do
sname="${entry%%:*}"
@@ -335,27 +347,18 @@ if [[ "$SHARE_COUNT" -gt 0 ]]; then
done
echo " Passed: ${#PASS[@]}/$SHARE_COUNT Failed: ${#FAIL[@]}/$SHARE_COUNT"
echo ""
fi
fi
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo "$ICON_GEAR Jobs:"
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
echo ""
fi
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
echo "$ICON_DONE Status: all complete ✅ — ${#JOB_PASS[@]} job(s) run, ${#PASS[@]}/$SHARE_COUNT share(s) synced"
else
warn "Status: $TOTAL_FAIL failure(s)"
notify "Intermediate sync failed on $(hostname) ($MY_ID) — shares: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Intermediate Sync" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
exit 0
# Standard ending, quiet mode — 4-hour cadence, so an OK cycle is one parseable line and
# anything skipped or failed expands to the full block on its own.
_mode=quiet; [[ "$ENABLE_LOGGING" == true ]] && _mode=full
orchestrator_summary "INTERMEDIATE SYNC" "$WINDOW_START" "Intermediate Sync" "$_mode"
exit $?
+48 -62
View File
@@ -45,12 +45,34 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — ZFS scrub, SMART tests require root
# acquire_lock — prevents concurrent monthly runs
# detect_hosts() — MY_ID in notifications and logs
# Uptime gate — MONTHLY_UPTIME_THRESHOLD_DAYS must be met
# Interval gate — MONTHLY_RUN_INTERVAL_DAYS since last run must be met
# --force flag — bypasses both gates for manual override
# Root Enforcement
# ZFS scrub and SMART tests require root.
#
# Lock Acquisition
# acquire_lock prevents concurrent monthly runs. These are long jobs — a scrub can run
# for hours — and two at once would double the I/O cost for no benefit.
#
# Host Detection
# detect_hosts() sets MY_ID for notifications and logs.
#
# Empty Job List Guard
# Exits with an error and a notification if MONTHLY_MAINTENANCE_SCRIPTS is empty. A
# monthly job that silently does nothing is the hardest kind to notice missing.
#
# Uptime Gate
# MONTHLY_UPTIME_THRESHOLD_DAYS must be met before the run proceeds. Heavy full-disk
# work immediately after a boot competes with everything else still starting up.
#
# Interval Gate
# MONTHLY_RUN_INTERVAL_DAYS since the last successful run must have elapsed. The
# schedule fires more often than the work should actually happen, so the gate — not
# the cron entry — is what defines the real cadence.
#
# Force Override
# --force bypasses both gates for a deliberate manual run.
#
# Non-Fatal Steps
# A failing job is recorded and the remaining jobs still run.
#
# ==============================================================================================
# CONFIGURATION
@@ -113,6 +135,16 @@ acquire_lock
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
if [[ ${#MONTHLY_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
error "MONTHLY_MAINTENANCE_SCRIPTS is empty — no monthly maintenance scripts will run"
error "Check MONTHLY_MAINTENANCE_SCRIPTS in master.conf"
notify "monthly maintenance scripts skipped on $(hostname) ($MY_ID) — MONTHLY_MAINTENANCE_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no scripts will be executed"
[[ "$FORCE_RUN" == true ]] && warn "FORCE — uptime and interval gates bypassed"
@@ -234,8 +266,8 @@ echo "$ICON_GEAR Scripts: ${#MONTHLY_MAINTENANCE_SCRIPTS[@]}"
echo ""
START=$(date +%s)
PASSED=()
FAILED=()
JOB_PASS=()
JOB_FAIL=()
STEP=0
# ==============================================================================================
@@ -246,47 +278,11 @@ for entry in "${MONTHLY_MAINTENANCE_SCRIPTS[@]}"; do
(( STEP++ ))
read -r -a parts <<< "$entry"
script_path="$ECOSYSTEM_ROOT/${parts[0]}"
script_name=$(basename "${parts[0]}")
extra_args=("${parts[@]:1}")
echo "━━━ $ICON_GEAR Step $STEP: $script_name${extra_args:+ ${extra_args[*]}} ━━━"
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
FAILED+=("$script_name")
echo ""
continue
fi
if [[ ! -x "$script_path" ]]; then
warn "$script_name — not executable, fixing..."
chmod +x "$script_path" || {
error "$script_name — chmod +x failed"
FAILED+=("$script_name")
echo ""
continue
}
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would run: $script_name${extra_args:+ ${extra_args[*]}}"
PASSED+=("$script_name")
echo ""
continue
fi
local_args=()
[[ "$VERBOSE" == true ]] && local_args+=("--log")
if bash "$script_path" "${extra_args[@]}" "${local_args[@]}"; then
echo "$script_name — done ✅"
PASSED+=("$script_name")
else
warn "$script_name — failed (exit $?) — continuing to next step"
FAILED+=("$script_name")
fi
run_orch_child "$entry"
echo ""
done
@@ -307,22 +303,12 @@ fi
echo "━━━━━ $ICON_SUMMARY MONTHLY MAINTENANCE SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
[[ ${#PASSED[@]} -gt 0 ]] && echo "$ICON_DONE Passed: ${PASSED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
[[ ${#JOB_PASS[@]} -gt 0 ]] && echo "$ICON_DONE Passed: ${JOB_PASS[*]}"
[[ ${#JOB_FAIL[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${JOB_FAIL[*]}"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: all $STEP step(s) complete ✅"
notify "Monthly maintenance complete on $(hostname) ($MY_ID) — $STEP step(s) done" \
"Monthly Maintenance" "normal"
else
warn "Status: ${#FAILED[@]} step(s) failed — ${FAILED[*]}"
notify "Monthly maintenance on $(hostname) ($MY_ID) — ${#FAILED[@]} step(s) failed: ${FAILED[*]}" \
"Monthly Maintenance" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#FAILED[@]} -gt 0 ]] && exit 1
exit 0
# STEP is what this orchestrator expected to run, so it is the denominator that makes a skipped
# step visible rather than absent.
JOB_COUNT="$STEP"
orchestrator_summary "MONTHLY MAINTENANCE" "$START" "Monthly Maintenance"
exit $?
+72 -37
View File
@@ -39,9 +39,33 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# detect_hosts() — MY_ID in banner and summary
# Non-fatal steps — a failed script is logged; remaining scripts still run
# Flag pass-through — --dry-run and --log forwarded to all child scripts
# Root Enforcement
# Required for the docker and system reads the report is built from.
#
# Lock Acquisition
# acquire_lock prevents overlapping weekly runs.
#
# Host Detection
# detect_hosts() sets MY_ID for the banner and summary.
#
# Empty Job List Guard
# Exits with an error and a notification if COFFEE_REPORT_SCRIPTS is empty. A report
# that silently contains nothing still arrives looking like a report.
#
# Read-Only by Composition
# Every child here is a reporting script. This orchestrator changes nothing itself —
# it only sequences reads and assembles their output.
#
# Non-Fatal Steps
# A failing script is logged and the remaining ones still run, so one unavailable
# subsystem costs a section of the report rather than the whole thing.
#
# Flag Pass-Through
# --dry-run and --log are forwarded to every child script.
#
# Notification Contract
# notify() fires on failure only outside --dry-run, matching the runtime-mode contract
# below — a dry run never sends anything outward.
#
# ==============================================================================================
# CONFIGURATION
@@ -70,49 +94,43 @@
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$ECOSYSTEM_ROOT/load_config.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
# Timed from here so the standard summary can report a real duration; this report had none.
REPORT_START=$(date +%s)
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
if [[ ${#COFFEE_REPORT_SCRIPTS[@]} -eq 0 ]]; then
error "COFFEE_REPORT_SCRIPTS is empty — no coffee report scripts will run"
error "Check COFFEE_REPORT_SCRIPTS in master.conf"
notify "coffee report scripts skipped on $(hostname) ($MY_ID) — COFFEE_REPORT_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
# ==============================================================================================
# ━━━ Helpers ━━━
# ==============================================================================================
JOB_PASS=()
JOB_FAIL=()
run_job() {
local script_entry="$1"
local extra_dry=""
local extra_log=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
[[ "$ENABLE_LOGGING" == true ]] && extra_log="--log"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
if bash "$script_path" "${extra_args[@]}" $extra_dry $extra_log; then
echo "$script_name — done ✅"
JOB_PASS+=("$script_name")
else
error "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name")
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
@@ -158,7 +176,7 @@ fi
for script_entry in "${COFFEE_REPORT_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
echo ""
run_job "$script_entry"
run_orch_child "$script_entry"
done
# ==============================================================================================
@@ -169,6 +187,19 @@ echo "━━━━━ ☕ COFFEE REPORT SUMMARY ━━━━━"
echo "🖥️ Host: $MY_ID"
echo "⏱️ Done: $(date '+%Y-%m-%d %H:%M:%S')"
# Anything the Scheduler troubleshooter concluded was a Varaverk defect rather than a setting.
# Counted here because a finding filed on a Tuesday and read on a Tuesday is a finding nobody
# acts on — this report is the weekly moment the operator is actually looking. Count only: the
# detail lives on the AI tab, and a report that reprints every open bug stops being skimmable.
# Silent when there are none, and silent when AI is off, so a host without it reads the same as
# it always has.
if [[ "${AI_ENABLED:-false}" == true && -d "$DATA_DIR/ai_bugs" ]]; then
_ai_bugs_open=$(grep -l '"open": true' "$DATA_DIR"/ai_bugs/*.json 2>/dev/null | wc -l)
if [[ "${_ai_bugs_open:-0}" -gt 0 ]]; then
echo "🐞 AI bugs: $_ai_bugs_open open — see the AI tab"
fi
fi
if [[ ${#JOB_PASS[@]} -gt 0 ]]; then
echo "✅ Passed: ${JOB_PASS[*]}"
fi
@@ -176,5 +207,9 @@ if [[ ${#JOB_FAIL[@]} -gt 0 ]]; then
echo "❌ Failed: ${JOB_FAIL[*]}"
fi
[[ ${#JOB_FAIL[@]} -gt 0 ]] && exit 1
exit 0
# Standard ending. The configured section list is the denominator, so a report that quietly
# stopped producing one of its sections reads as skipped rather than simply not appearing.
JOB_COUNT="${#SUNDAY_REPORT_SCRIPTS[@]:-0}"
[[ "$JOB_COUNT" -eq 0 ]] && JOB_COUNT=$(( ${#JOB_PASS[@]} + ${#JOB_FAIL[@]} ))
orchestrator_summary "SUNDAY MORNING COFFEE REPORT" "$REPORT_START" "Sunday Morning Coffee Report"
exit $?
+81 -50
View File
@@ -5,14 +5,17 @@
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Runs transcode_cleanup.sh then transcode_manager.sh in the correct order.
# Replaces individual cron entries for each — this is the single cron entry.
# Schedule: */7 * * * * (every 7 minutes via User Scripts plugin)
# Runs TRANSCODE_MANAGEMENT_SCRIPTS in order each cron cycle — this is the
# single cron entry replacing individual entries for each child script.
# Schedule: */7 * * * * (every 7 minutes)
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Order driven by TRANSCODE_MANAGEMENT_SCRIPTS in master.conf.
# Default: transcode_cleanup.sh → transcode_manager.sh
#
# transcode_cleanup.sh
# Removes aged segment files not open by any process. Uses lsof for O(1)
# per-file active check. Triggers flip-back to ramdisk after cleanup if
@@ -36,6 +39,8 @@
# Stale segment files from ended sessions inflate the ramdisk usage reading
# and trigger unnecessary SSD flips even when active sessions would fit on
# the ramdisk. Cleanup runs first so the manager measures real current usage.
# Order is config-driven (TRANSCODE_MANAGEMENT_SCRIPTS) but this dependency
# is real — reordering the array changes what the manager measures.
#
# Delegated Logging
# This orchestrator does not write its own log — transcode_manager.sh owns
@@ -45,11 +50,37 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — mount and docker operations require root
# acquire_lock — prevents concurrent 7-minute cycles overlapping
# detect_hosts() — aliases RAMDISK_PATH, TRANSCODE_SSD, RAMDISK_WARN_GB per host
# --dry-run — passed through to both child scripts
# Exit code — worst exit code of both scripts returned to cron
# Root Enforcement
# Mount and docker operations require root.
#
# Lock Acquisition
# acquire_lock prevents concurrent 7-minute cycles overlapping. Cleanup and the manager
# both touch the same ramdisk, and two cycles at once could have one deleting files
# while the other is measuring usage to decide whether to flip.
#
# Host Detection
# detect_hosts() aliases RAMDISK_PATH, TRANSCODE_SSD and RAMDISK_WARN_GB per host.
#
# Empty Job List Guard
# Exits with an error and a notification if TRANSCODE_MANAGEMENT_SCRIPTS is empty —
# without it the ramdisk would silently stop being cleaned or flipped, and the first
# symptom would be a full ramdisk stalling playback.
#
# Ordering Is Load-Bearing
# Cleanup runs before the manager so the manager measures real active-session usage
# rather than usage inflated by stale files. Reversing them would trigger flips that
# a cleanup two seconds later would have made unnecessary.
#
# Dry Run Propagation
# --dry-run is passed through to every script in TRANSCODE_MANAGEMENT_SCRIPTS.
#
# Any-Failure Exit Code
# Exits 1 if any child failed, 0 otherwise — the individual exit codes are not
# propagated, only whether anything failed. A failure in an early child is therefore
# never masked by a later success.
#
# Notification Contract
# notify() fires on failure and is skipped in --dry-run.
#
# ==============================================================================================
# CONFIGURATION
@@ -57,6 +88,7 @@
#
# master.conf
#
# TRANSCODE_MANAGEMENT_SCRIPTS — scripts to run, in order
# TRANSCODE_DAILY_LOG — daily stats log (written by transcode_manager.sh)
# TRANSCODE_LOG_RETENTION — days to keep (trimmed by manager)
# TRANSCODE_STATE_FILE — current state (ramdisk_used, flip_count, etc.)
@@ -70,25 +102,23 @@
# Normal run (every 7 minutes via cron).
#
# transcode_management.sh --dry-run
# Preview without changes (passed to both child scripts).
# Preview without changes (passed to every script in TRANSCODE_MANAGEMENT_SCRIPTS).
#
# transcode_management.sh --status
# Show configuration and current state.
#
# transcode_management.sh --log
# Verbose output from both child scripts.
# Verbose output from every child script.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$ECOSYSTEM_ROOT/load_config.sh"
parse_args "$@"
CLEANUP_SCRIPT="$SCRIPT_DIR/../Transcodes/transcode_cleanup.sh"
MANAGER_SCRIPT="$SCRIPT_DIR/../Transcodes/transcode_manager.sh"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
@@ -107,6 +137,20 @@ fi
# detect_hosts() sets MY_ID and aliases all HOST*_TRANSCODE_* vars
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
# This orchestrator never timed itself, so its summary could not report a duration. Set before
# any work so the figure means the cycle, not the tail of it.
CYCLE_START=$(date +%s)
if [[ ${#TRANSCODE_MANAGEMENT_SCRIPTS[@]} -eq 0 ]]; then
error "TRANSCODE_MANAGEMENT_SCRIPTS is empty — no transcode management scripts will run"
error "Check TRANSCODE_MANAGEMENT_SCRIPTS in master.conf"
notify "transcode management scripts skipped on $(hostname) ($MY_ID) — TRANSCODE_MANAGEMENT_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — passing through to child scripts"
# ==============================================================================================
@@ -122,12 +166,15 @@ if [[ "$SHOW_STATUS" == true ]]; then
echo "$ICON_TIME Schedule: every 7 minutes"
echo ""
echo "━━━ Child Scripts ━━━"
[[ -f "$CLEANUP_SCRIPT" ]] && \
echo " $ICON_SUCCESS transcode_cleanup.sh — found" || \
echo " $ICON_ERROR transcode_cleanup.sh — NOT FOUND at $CLEANUP_SCRIPT"
[[ -f "$MANAGER_SCRIPT" ]] && \
echo " $ICON_SUCCESS transcode_manager.sh — found" || \
echo " $ICON_ERROR transcode_manager.sh — NOT FOUND at $MANAGER_SCRIPT"
for _entry in "${TRANSCODE_MANAGEMENT_SCRIPTS[@]}"; do
_script_path="$SCRIPT_DIR/../$_entry"
_script_name=$(basename "$_entry")
if [[ -f "$_script_path" ]]; then
echo " $ICON_SUCCESS $_script_name — found"
else
echo " $ICON_ERROR $_script_name — NOT FOUND at $_script_path"
fi
done
echo ""
echo "━━━ Daily Log ━━━"
if [[ -f "${TRANSCODE_DAILY_LOG:-}" ]] && [[ -s "$TRANSCODE_DAILY_LOG" ]]; then
@@ -151,19 +198,6 @@ if [[ "$SHOW_STATUS" == true ]]; then
exit 0
fi
# ==============================================================================================
# ━━━ Validate Child Scripts ━━━
# ==============================================================================================
if [[ ! -f "$CLEANUP_SCRIPT" ]]; then
error "transcode_cleanup.sh not found: $CLEANUP_SCRIPT"
exit 1
fi
if [[ ! -f "$MANAGER_SCRIPT" ]]; then
error "transcode_manager.sh not found: $MANAGER_SCRIPT"
exit 1
fi
# ==============================================================================================
# ━━━ Pre-run State Snapshot ━━━
# ==============================================================================================
@@ -179,29 +213,26 @@ else
fi
# ==============================================================================================
# ━━━ Run Cleanup ━━━
# ━━━ Run Scripts ━━━
# ==============================================================================================
DRY_FLAG=""
[[ "$DRY_RUN" == true ]] && DRY_FLAG="--dry-run"
_cleanup_start=$(date +%s)
bash "$CLEANUP_SCRIPT" $DRY_FLAG
CLEANUP_EXIT=$?
log "cleanup: $(format_duration $(( $(date +%s) - _cleanup_start ))) (exit $CLEANUP_EXIT)"
# transcode_manager.sh writes to TRANSCODE_DAILY_LOG after each run — no flag here
# suppresses that; it owns the log write for this cycle regardless of position ✅
JOB_PASS=()
JOB_FAIL=()
for _entry in "${TRANSCODE_MANAGEMENT_SCRIPTS[@]}"; do
[[ -z "$_entry" ]] && continue
run_orch_child "$_entry"
done
# ==============================================================================================
# ━━━ Run Manager ━━━
# ━━━ Summary — minimal one-liner by default (7-min cadence — keep it quiet when healthy) ━━━
# ==============================================================================================
# transcode_manager.sh writes to TRANSCODE_DAILY_LOG after each run
# No --no-log flag here — manager owns the log write for this cycle ✅
_manager_start=$(date +%s)
bash "$MANAGER_SCRIPT" $DRY_FLAG
MANAGER_EXIT=$?
log "manager: $(format_duration $(( $(date +%s) - _manager_start ))) (exit $MANAGER_EXIT)"
# Quiet by default — 7-min cadence. Anything failed or skipped expands on its own.
JOB_COUNT="${#TRANSCODE_MANAGEMENT_SCRIPTS[@]}"
orchestrator_summary "TRANSCODE CYCLE" "${CYCLE_START:-$(date +%s)}" "Transcode Management" quiet
# ==============================================================================================
# ━━━ Exit ━━━
# ==============================================================================================
# Return worst exit code — caller knows if either script failed
[[ "$CLEANUP_EXIT" -ne 0 || "$MANAGER_EXIT" -ne 0 ]] && exit 1
[[ "${#JOB_FAIL[@]}" -gt 0 ]] && exit 1
exit 0
+55 -51
View File
@@ -49,11 +49,34 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — watchdog operations require root
# acquire_lock — strict; no pile-up if prior cycle still active
# detect_hosts() — MY_ID in logs and notifications
# Array check — exits early if /mnt/user is not shfs-mounted
# Startup grace — WATCHDOG_STARTUP_GRACE respected before any checks
# Root Enforcement
# Every watchdog launched from here requires root. Failing once at the top gives one
# clear error instead of the same permission failure repeated per child.
#
# Lock Acquisition
# acquire_lock in strict mode — if the previous cycle is still running, this one exits
# rather than queuing. At a 15-minute cadence a waiting lock would pile up cycles
# behind a slow watchdog and eventually run them all at once.
#
# Host Detection
# detect_hosts() sets MY_ID for logs and notifications.
#
# Empty Job List Guard
# Exits with an error and a notification if WATCHDOG_ORCHESTRATOR_SCRIPTS is empty.
# Without it the cycle reports "0/0 passed" and exits 0 every cycle — indistinguishable
# from a healthy run, while nothing at all is being monitored.
#
# Array Check
# Exits early if /mnt/user is not shfs-mounted. Watchdogs that inspect shares would
# otherwise read an unmounted array as missing data and act on it.
#
# Startup Grace
# WATCHDOG_STARTUP_GRACE is respected before any checks run, so containers still
# initialising after boot are not judged as unhealthy.
#
# Non-Fatal Steps
# run_orch_child() records a failing or missing watchdog and continues. One broken
# watchdog never suppresses the rest of the chain.
#
# ==============================================================================================
# CONFIGURATION
@@ -104,6 +127,16 @@ acquire_lock
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
if [[ ${#WATCHDOG_ORCHESTRATOR_SCRIPTS[@]} -eq 0 ]]; then
error "WATCHDOG_ORCHESTRATOR_SCRIPTS is empty — no watchdogs will run"
error "Check WATCHDOG_ORCHESTRATOR_SCRIPTS in master.conf"
notify "watchdogs skipped on $(hostname) ($MY_ID) — WATCHDOG_ORCHESTRATOR_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
log "$ICON_GEAR Config: grace=${WATCHDOG_STARTUP_GRACE}s heartbeat=${WATCHDOG_ORCHESTRATOR_HEARTBEAT:-true}/${WATCHDOG_ORCHESTRATOR_HEARTBEAT_HOURS:-1}hr scripts=${#WATCHDOG_ORCHESTRATOR_SCRIPTS[@]}"
log "$ICON_WATCHDOG Order: $(for s in "${WATCHDOG_ORCHESTRATOR_SCRIPTS[@]}"; do printf '%s ' "${s##*/}"; done)"
@@ -177,40 +210,12 @@ log "Startup grace: past — uptime $(format_duration $UPTIME_SECONDS)"
# ━━━ Run Watchdog Cycle ━━━
# ==============================================================================================
CYCLE_START=$(date +%s)
PASS=()
FAIL=()
run_watchdog() {
local name="$1" script="$2"
if [[ ! -f "$script" ]]; then
error "$name — not found: $script"
FAIL+=("$name:missing")
return 1
fi
[[ ! -x "$script" ]] && chmod +x "$script"
local extra_args=()
[[ "$DRY_RUN" == true ]] && extra_args+=("--dry-run")
[[ "$VERBOSE" == true ]] && extra_args+=("--log")
local _ws
_ws=$(date +%s)
log "$ICON_START $name"
if bash "$script" "${extra_args[@]}"; then
log "$ICON_DONE $name — done in $(format_duration $(( $(date +%s) - _ws )))"
PASS+=("$name")
return 0
else
error "$name — non-zero exit ($(format_duration $(( $(date +%s) - _ws ))))"
FAIL+=("$name")
return 1
fi
}
JOB_PASS=()
JOB_FAIL=()
for _entry in "${WATCHDOG_ORCHESTRATOR_SCRIPTS[@]}"; do
run_watchdog "$(_watchdog_display_name "$_entry")" "$ECOSYSTEM_ROOT/$_entry"
[[ -z "$_entry" ]] && continue
run_orch_child "$_entry"
done
CYCLE_END=$(date +%s)
@@ -229,23 +234,22 @@ if [[ "${WATCHDOG_ORCHESTRATOR_HEARTBEAT:-true}" == true ]]; then
HB_ELAPSED=$(( HB_COUNT * 60 ))
if [[ "$HB_SECONDS" -gt 0 ]] && (( HB_ELAPSED % HB_SECONDS < 60 )) && [[ "$HB_COUNT" -gt 1 ]]; then
HB_HR=$(( HB_ELAPSED / 3600 ))
warn "♥ watchdog_orchestrator alive — $MY_ID — ~${HB_HR}hr ($(date '+%H:%M:%S'))"
log "♥ watchdog_orchestrator alive — $MY_ID — ~${HB_HR}hr ($(date '+%H:%M:%S'))"
fi
fi
# ==============================================================================================
# ━━━ Summary — only shown on failures or --log ━━━
# ━━━ Summary — minimal one-liner by default, full breakdown on failure or --log ━━━
# ==============================================================================================
if [[ "${#FAIL[@]}" -gt 0 || "$VERBOSE" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY WATCHDOG CYCLE — $MY_ID$(date '+%H:%M:%S') ━━━━━"
for p in "${PASS[@]}"; do log " $ICON_DONE $p"; done
for f in "${FAIL[@]}"; do error " $ICON_ERROR $f"; done
echo "$ICON_TIME Duration: $(format_duration $DURATION)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ "${#FAIL[@]}" -gt 0 ]]; then
notify "Watchdog cycle failure on $(hostname) ($MY_ID) — ${FAIL[*]}" \
"Watchdog Orchestrator" "warning"
fi
# Per-script detail only when there is something to read; the standard block carries the rest.
if [[ "${#JOB_FAIL[@]}" -gt 0 || "$ENABLE_LOGGING" == true ]]; then
for p in "${JOB_PASS[@]}"; do log " $ICON_DONE $p"; done
for f in "${JOB_FAIL[@]}"; do error " $ICON_ERROR $f"; done
fi
# Quiet by default at a 15-min cadence. The configured script list is the denominator, so a
# watchdog that silently stopped running one of its checks shows up as skipped.
JOB_COUNT="${#WATCHDOG_ORCHESTRATOR_SCRIPTS[@]}"
_mode=quiet; [[ "$ENABLE_LOGGING" == true ]] && _mode=full
orchestrator_summary "WATCHDOG CYCLE" "$CYCLE_START" "Watchdog Orchestrator" "$_mode"
exit $?
+50 -63
View File
@@ -40,15 +40,35 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — stop/start containers and rsync require root
# acquire_lock — prevents concurrent weekly windows
# detect_hosts() — MY_ID in banner, summary, and notifications
# check_connectivity — verifies remote before any remote operations
# check_remote_rootfs — aborts rsync if remote rootfs nearly full
# DOCKER_TIMEOUT — all docker calls protected
# SSH_TIMEOUT — all SSH calls protected
# platform_require_cmd — notify validated before use
# Silent on success — runs weekly; only failures warrant notification
# Root Enforcement
# Container stop/start and rsync both require root.
#
# Lock Acquisition
# acquire_lock prevents concurrent weekly windows. This window stops Emby and the auth
# stack — two overlapping runs would fight over the same critical containers.
#
# Host Detection
# detect_hosts() sets MY_ID for the banner, summary and notifications.
#
# Empty Job List Guard
# Exits with an error and a notification if WEEKLY_MAINTENANCE_SCRIPTS is empty, rather
# than taking the weekly outage window and doing nothing with it.
#
# Connectivity Check
# check_connectivity verifies the remote before any remote operation is attempted.
#
# Remote Rootfs Check
# check_remote_rootfs aborts rsync if the remote rootfs is nearly full.
#
# Timeout Protection
# DOCKER_TIMEOUT bounds every docker call and SSH_TIMEOUT every SSH call, so neither a
# hung daemon nor an unresponsive partner can hold the weekly window open indefinitely.
#
# Non-Fatal Steps
# A failing job is recorded and the remaining jobs still run.
#
# Silent on Success
# Runs weekly; only failures warrant a notification.
#
# ==============================================================================================
# CONFIGURATION
@@ -84,8 +104,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RSYNC_SCRIPT="$ECOSYSTEM_ROOT/Rsync/rsync.sh"
parse_args "$@"
@@ -109,6 +129,16 @@ if ! command -v docker &>/dev/null; then
fi
detect_hosts
# An unconfigured job list would run nothing and still report "0/0 passed" — indistinguishable
# from a healthy run. Fail loudly instead of silently doing no work.
if [[ ${#WEEKLY_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
error "WEEKLY_MAINTENANCE_SCRIPTS is empty — no weekly maintenance scripts will run"
error "Check WEEKLY_MAINTENANCE_SCRIPTS in master.conf"
notify "weekly maintenance scripts skipped on $(hostname) ($MY_ID) — WEEKLY_MAINTENANCE_SCRIPTS is empty" \
"$(basename "$0" .sh)" "warning"
exit 1
fi
resolve_remote_ip
WINDOW_START=$(date +%s)
@@ -123,35 +153,6 @@ read -r -a MAINTENANCE_CONTAINERS <<< \
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be stopped, no sync, no updates"
# ── Helper — run a post-sync maintenance script ────────────────────────────────────────────────
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
echo "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
error "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
@@ -214,6 +215,8 @@ else
# Load container lists for stop functions
read -r -a CRITICAL_CONTAINER_NAMES <<< \
"${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-}"
read -r -a LOCAL_CRITICAL_CONTAINER_NAMES <<< \
"${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-}"
read -r -a DELAYED_CONTAINERS <<< "${PROFILE_DELAYED_CONTAINERS[critical-data]:-}"
CONTAINER_DELAY="${PROFILE_CONTAINER_DELAY[critical-data]:-15}"
@@ -390,7 +393,7 @@ if [[ ${#WEEKLY_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
for script_entry in "${WEEKLY_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
echo ""
run_job "$script_entry"
run_orch_child "$script_entry"
done
fi
@@ -399,18 +402,14 @@ WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
# Per-unit detail first — the standard block that follows carries the verdict and the counts, not
# the names, and knowing WHICH share failed is the whole point of reading a log.
echo ""
echo "━━━━━ $ICON_SUMMARY WEEKLY SYNC MAINTENANCE SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S')$(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo "$ICON_GEAR Updates: local=${WEEKLY_SYNC_UPDATES:-false} remote=${WEEKLY_SYNC_UPDATES_REMOTE:-false}"
echo ""
echo "$ICON_SYNC Sync jobs ($SHARE_COUNT):"
for job in "${PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${FAIL[@]}"; do echo " $ICON_ERROR $job"; done
echo " Passed: ${#PASS[@]} Failed: ${#FAIL[@]}"
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo ""
@@ -419,19 +418,7 @@ if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
echo "$ICON_DONE Status: all complete ✅ — ${#PASS[@]} share(s) synced, ${#JOB_PASS[@]} job(s) run"
else
warn "Status: $TOTAL_FAIL failure(s)"
notify "Weekly maintenance failed on $(hostname) ($MY_ID) — sync: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Weekly Maintenance" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
exit 0
# Standard ending. Derives skipped from SHARE_COUNT vs what actually ran, so a gated-off section
# can no longer read as success — this is the run that printed "all complete — 0 shares synced".
orchestrator_summary "WEEKLY SYNC MAINTENANCE" "$WINDOW_START" "Weekly Maintenance"
exit $?
+2
View File
@@ -144,6 +144,8 @@ independently at 4-hour cadence.
| `partnership_transfer.sh` | Transfer ownership from current owner to current mirror | Via `partnership_manager.sh --transfer`; owner only |
| `ssh_setup.sh` | SSH key generation, remote install, auth validation | Called by onboard; manually for re-keying or validation |
| `onboard_cancel.sh` | Remove SSH keys in one or both directions, clear setup flags | During cancelled or failed onboard; manual cleanup |
| `gitea_ssh_setup.sh` | Generate a Gitea keypair and register it via the Gitea API | Called during onboard; manually when re-keying a server |
| `share_setup.sh` | Create missing Unraid shares on the mirror from the owner's sync lists | Called during onboard; safe to re-run — existing shares are never modified |
---
+25
View File
@@ -41,6 +41,31 @@
# Skipped if entry already exists — use --force to overwrite
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Separate Key From the Rsync Key
# Gitea authentication uses its own keypair rather than reusing the rsync automation key.
# The two have different lifecycles and different blast radii — re-keying Gitea should
# never break cross-host rsync, and revoking a partner's rsync access should not lock this
# host out of its own repository.
#
# Idempotent Registration
# Both generation and registration are skipped when already satisfied, and registration is
# matched on the public key itself rather than on a label. A key registered under a
# different title is still the same key, and re-registering it would leave duplicate
# entries accumulating in Gitea on every re-run.
#
# Locate, Do Not Assume
# The API endpoint is resolved at runtime — container-local first, then GITEA_DOMAIN. Same
# reasoning as git_pull_execute.sh: Gitea's address changes with container restarts and
# migrations, so hardcoding it guarantees an eventual break.
#
# Force Is Explicit
# Regeneration invalidates the key already registered in Gitea, so it requires --force. A
# bare re-run can never cost this host its repository access.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
+126 -6
View File
@@ -8,6 +8,102 @@
# Removes SSH keys between HOST1 and HOST2 in the specified direction.
# Safe to run at any phase. Clears related setup.db flags.
#
# The escape hatch for a half-finished onboard: partnership setup is multi-phase, and an
# attempt abandoned midway leaves keys installed and phase flags set. This unwinds that so
# onboarding can be started cleanly rather than resumed from an unknown state.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Per direction requested:
#
# h1 (HOST1 → HOST2)
# 1. SSH to HOST2 and delete HOST1's public key line from its authorized_keys
# 2. Clear HOST2's phase / key-ready flags from the setup db
# 3. Delete the local HOST1 key pair
#
# h2 (HOST2 → HOST1)
# Remove HOST2's public key from HOST1's authorized_keys, identifying the key by
# HOST2's hostname in the key comment.
#
# both — run each direction in turn.
#
# Key removal is matched on the key blob or hostname comment, never on line number.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Safe at Any Phase
# Onboarding is multi-phase and can fail anywhere in it. This runs against whatever state
# exists rather than requiring a known starting point — a missing key or an absent flag is
# a no-op, not an error. Cancelling twice is harmless.
#
# Direction Is Explicit
# Removing a key is not symmetric: it breaks authentication for whichever side loses it.
# The direction must be stated, and defaults to h1 (this host's own outbound key) rather
# than to both, so an unqualified run cannot sever the partner's access to you.
#
# Unwind, Do Not Repair
# The job is to return to a clean pre-onboard state so onboarding can be re-run from the
# top. It deliberately does not attempt to salvage or resume a partial setup — a known
# empty state is worth more than a guessed-at partial one.
#
# Keys and Flags Together
# Removing the key without clearing the setup-db flags would leave onboarding believing a
# phase had completed. Both are cleared in the same pass for that reason.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Deletes key pairs from /root/.ssh and edits authorized_keys on both ends as root.
#
# Lock Acquisition
# acquire_lock prevents this racing an in-progress onboard, which would otherwise be
# installing the very keys this is removing.
#
# Host Detection
# detect_hosts() resolves MY_ID / REMOTE_ID so the direction flags map to real hosts.
#
# Direction Default
# Defaults to h1 — never removes the partner's inbound key unless explicitly asked.
#
# Targeted Key Removal
# authorized_keys lines are matched by key blob or hostname comment. Nothing is removed
# positionally, so an unrelated key can never be deleted because it sat on a given line.
#
# SSH Timeout
# The remote edit is wrapped in SSH_TIMEOUT — an unreachable partner fails fast rather
# than hanging a cancel that still has local cleanup to do.
#
# Idempotent
# Absent keys and absent flags are skipped silently. Re-running is safe.
#
# Dry Run Support
# --dry-run reports every key and flag it would remove, and removes none.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# SSH_TIMEOUT
# Bounds the remote authorized_keys edit.
#
# host*.conf
#
# SSH_KEY
# Key used to reach the partner, and the local pair deleted in the h1 direction.
#
# HOST* — hostnames, used to identify which key comment belongs to which side.
#
# Setup state lives in the platform setup db (platform_setup_db_path) — the phase and
# key-ready flags cleared here are the same ones partnership_onboard.sh sets.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
@@ -85,14 +181,36 @@ if [[ "$DIRECTION" == "h1" || "$DIRECTION" == "both" ]]; then
warn "DRY RUN — would remove HOST1 key from $MIRROR:/root/.ssh/authorized_keys"
H1_DONE=true
else
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
# `\|…|d`, not `|…|d`. sed only accepts a custom address delimiter when it is
# introduced by a backslash; the bare form is a syntax error — "unknown command: `|'".
# A delimiter other than / is still required, because the key blob is base64 and
# routinely contains /.
#
# The error went to 2>/dev/null and `echo ok` ran anyway, so this reported
# "key removed ✅" on every run while removing nothing, and a cancelled onboard left
# HOST1's key live on the mirror. Report on what the remote actually did instead.
_cancel_out=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"sed -i \"|${KEY_BLOB}|d\" /root/.ssh/authorized_keys 2>/dev/null
sed -i \"/^${MIRROR_ID}_PHASE\|^${MIRROR_ID}_KEY_READY/d\" $(platform_setup_db_path) 2>/dev/null
echo ok" 2>/dev/null | grep -q ok && {
"sed -i \"\\|${KEY_BLOB}|d\" /root/.ssh/authorized_keys || { echo sed-failed; exit 1; }
chmod 600 /root/.ssh/authorized_keys 2>/dev/null
sed -i \"/^${MIRROR_ID}_PHASE/d; /^${MIRROR_ID}_KEY_READY/d\" $(platform_setup_db_path) 2>/dev/null
grep -qF '${KEY_BLOB}' /root/.ssh/authorized_keys 2>/dev/null && echo still-present || echo ok" 2>/dev/null)
case "$_cancel_out" in
*ok*)
echo "HOST1 key removed from $MIRROR authorized_keys ✅"
H1_DONE=true
} || warn "Could not SSH to $MIRROR — remove HOST1 key there manually"
;;
*still-present*)
warn "HOST1 key still present in $MIRROR authorized_keys — remove it there manually"
;;
*sed-failed*)
warn "Could not edit authorized_keys on $MIRROR — remove HOST1 key there manually"
;;
*)
warn "Could not SSH to $MIRROR — remove HOST1 key there manually"
;;
esac
unset _cancel_out
fi
fi
@@ -132,7 +250,9 @@ if [[ "$DIRECTION" == "h2" || "$DIRECTION" == "both" ]]; then
warn "DRY RUN — would remove $MIRROR_SHORT key from $AUTH_KEYS"
H2_DONE=true
else
sed -i "/${MIRROR_SHORT}/Id" "$AUTH_KEYS" && {
# chmod after: sed -i rewrites via a temp file, which lands under the umask and can
# leave the file 0666. sshd StrictModes then silently refuses every key in it.
sed -i "/${MIRROR_SHORT}/Id" "$AUTH_KEYS" && chmod 600 "$AUTH_KEYS" && {
echo "$MIRROR key removed from HOST1 authorized_keys ✅"
H2_DONE=true
} || warn "Failed to remove $MIRROR key from HOST1 authorized_keys"
+209 -88
View File
@@ -79,8 +79,25 @@
# SSH_TIMEOUT on all remote calls
# Every ssh/scp call is timeout-protected. No operation hangs on an unreachable peer.
#
# No acquire_lock — Deliberate
# This file is dual-role: an executable dispatcher AND a library that
# partnership_offboard.sh and partnership_transfer.sh source with
# PARTNERSHIP_LIB_MODE=1. A top-level acquire_lock would fire for every sourcing
# script, and --check can re-invoke this file as itself (bash "$0" --offboard),
# which a strict lock would deadlock. Concurrency is handled per-write with flock
# instead. Do not "fix" this to match the single-role scripts.
#
# flock on state writes
# Prevents concurrent state file corruption from overlapping --check cycles.
# write_state_file() serialises full state-file rewrites, and the --check counter
# updates are flocked separately on their own lock file. The offline counter is a
# read-modify-write: without the lock two overlapping --check cycles both read N and
# both write N+1, silently losing an increment and pushing the auto-offboard threshold
# past its configured window. The last_seen_remote sed is inside the same lock because
# it edits a file write_state_file() rewrites wholesale from other paths.
#
# Note the redirection must sit INSIDE a command substitution — "$( ... ) 201>file"
# attaches the descriptor to the assignment rather than the subshell, and flock then
# fails with "Bad file descriptor" while the unlocked write proceeds anyway.
#
# SIGTERM trap on grace period sleep
# Offboard grace period is interruptible — Ctrl-C aborts cleanly.
@@ -249,22 +266,7 @@ fi
detect_hosts
# ── Derive owner and mirror from PARTNERSHIP_OWNER_HOST ───────────────────────────────────────
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}" # e.g. "HOST1"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
OWNER="${!OWNER_ID}" # hostname string
MIRROR="${!MIRROR_ID}"
# SSH_KEY (set by detect_hosts) is this server's own private key.
# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.
# With sparse checkout, each server only has its own host{N}.conf — the other server's
# key path is never available here. Use SSH_KEY for all outbound SSH regardless of mode.
MIRROR_SSH_KEY="$SSH_KEY"
OWNER_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
partnership_resolve_roles
# State files
LOCAL_STATE_FILE="${STATE_DIR}/partnership_${LOCAL_SERVER_NAME}.db"
@@ -349,22 +351,59 @@ EOF
) 200>"${file}.lock"
}
# Deliver the partnership state to the partner, at the path the partner actually reads, under
# both names its UI looks for.
#
# This used to be one scp to "root@ip:$local_file" — the LOCAL absolute path reused verbatim on
# the remote. That only works while both hosts install to the same place. HOST1 is on flash at
# /boot/config/plugins/varaverk and HOST2 is in appdata mode at /mnt/user/appdata/Varaverk, so
# the copy went to a directory HOST2 does not read and, more often, does not have — and the
# failure surfaced as "will propagate on next sync", which nothing does.
#
# Both names, because the page resolves one file per node: partnership_<that node's hostname>.db.
# The mirror needs partnership_<mirror>.db for its own card and partnership_<owner>.db for the
# owner's. Nothing on the mirror writes the first one during an owner-driven onboard, which is
# why a mirror that was fully onboarded still rendered as having no partnership at all.
# The content is symmetric — state/owner/mirror/onboarded — so one file serves as both.
push_state_to_remote() {
local local_file="$1" remote_ip="$2" ssh_key="$3"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push state file to remote"
return 0
fi
timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
"$local_file" "root@${remote_ip}:${local_file}" 2>/dev/null && \
echo "State file pushed to remote ✅" || \
warn "Could not push state file to remote — will propagate on next sync"
# Resolve where the partner keeps its state, from the partner. varaverk.cfg names its
# SCRIPTS_DIR; absent it, the flash default is the right guess for a stock install.
local remote_sd remote_state_dir
remote_sd=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
-o BatchMode=yes -o StrictHostKeyChecking=no root@"$remote_ip" \
'grep -oP "(?<=SCRIPTS_DIR=\")[^\"]+" /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null' \
2>/dev/null | tr -d '\r')
[[ -z "$remote_sd" ]] && remote_sd="/boot/config/plugins/varaverk"
remote_state_dir="${remote_sd}/data/state"
local rc=0
local name
for name in "partnership_${REMOTE_SERVER_NAME}.db" "partnership_${LOCAL_SERVER_NAME}.db"; do
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
-o BatchMode=yes -o StrictHostKeyChecking=no root@"$remote_ip" \
"mkdir -p '$remote_state_dir' && cat > '${remote_state_dir}/${name}'" \
< "$local_file" 2>/dev/null || rc=1
done
if [[ $rc -eq 0 ]]; then
echo "State file pushed to $REMOTE_SERVER_NAME:${remote_state_dir}"
return 0
fi
warn "Could not push state to $REMOTE_SERVER_NAME:${remote_state_dir} — it will keep showing"
warn " no partnership until this succeeds. Nothing retries this on a schedule."
return 1
}
read_remote_state() {
local remote_ip="$1" ssh_key="$2" remote_file="$3"
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
"cat '$remote_file' 2>/dev/null" 2>/dev/null
}
@@ -465,6 +504,7 @@ do_ssh_key_revocation() {
"grep -v '${our_comment}' /root/.ssh/authorized_keys \
> /root/.ssh/authorized_keys.tmp 2>/dev/null \
&& mv /root/.ssh/authorized_keys.tmp /root/.ssh/authorized_keys \
&& chmod 600 /root/.ssh/authorized_keys \
&& echo removed" 2>/dev/null | grep -q removed; then
echo "Our pubkey revoked from $REMOTE_SERVER_NAME"
SSH_REVOKE_REMOTE_OK=true
@@ -488,9 +528,16 @@ do_ssh_key_revocation() {
SSH_REVOKE_LOCAL_OK=true
elif [[ -f /root/.ssh/authorized_keys ]]; then
if grep -q "@${REMOTE_SERVER_NAME}" /root/.ssh/authorized_keys 2>/dev/null; then
# chmod after the mv, every time. `>` creates the temp file under the shell's umask
# and `mv` keeps the NEW file's mode, so this rewrite left authorized_keys 0666 on a
# filesystem that permits it. sshd's StrictModes then refuses every key in it without
# saying so to the client — the key is present, byte-correct, and inert, and the next
# onboard's SSH step fails with nothing in any Varaverk log to explain it. Only
# /var/log/syslog knows: "Authentication refused: bad ownership or modes".
if grep -v "@${REMOTE_SERVER_NAME}" /root/.ssh/authorized_keys \
> /root/.ssh/authorized_keys.tmp 2>/dev/null && \
mv /root/.ssh/authorized_keys.tmp /root/.ssh/authorized_keys; then
mv /root/.ssh/authorized_keys.tmp /root/.ssh/authorized_keys && \
chmod 600 /root/.ssh/authorized_keys; then
echo "$REMOTE_SERVER_NAME pubkey revoked locally ✅"
SSH_REVOKE_LOCAL_OK=true
else
@@ -524,37 +571,16 @@ gather_partner_fallback_containers() {
done
}
# Read a scalar var from the mirror's own config via SSH.
# Sources load_config.sh + detect_hosts() on the remote so HOST* aliasing works.
read_remote_conf_var() {
local mirror_ip="$1" var_name="$2"
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s' \"\${${var_name}:-}\"" 2>/dev/null
}
# read_remote_conf_var() / read_remote_conf_array() — provided by common.sh
# Read an array var from the mirror's own config via SSH — one element per line.
read_remote_conf_array() {
local mirror_ip="$1" var_name="$2"
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${${var_name}[@]:-}\"" 2>/dev/null
}
# Returns just the short name portion: "unRAID-Gmer4Lfe" → "Gmer4Lfe"
derive_short_name() {
local hostname="$1"
local short="${hostname,,}"
[[ "$short" == unraid-* ]] && short="${short:7}"
echo "${short^}"
}
# derive_short_name() — provided by common.sh
# Start this server's own parked containers after partnership ends.
start_own_stack() {
# Returns non-zero if any container failed. It used to return whatever the loop's last
# docker start happened to produce, so a caller checking it learned nothing — and the
# offboard summary just printed "Step 6 — Own stack: started" either way.
local _rc=0
echo ""
echo "━━━ $ICON_START Restart Own Stack ━━━"
if [[ ${#PARTNERSHIP_OWN_CONTAINERS[@]} -eq 0 ]]; then
@@ -571,14 +597,20 @@ start_own_stack() {
echo "$container started ✅"
else
warn "$container failed to start — check manually"
_rc=1
fi
done
return "$_rc"
}
# Remove partnership containers on this server + their appdata bind-mount paths.
# Appdata paths collected via docker inspect BEFORE removal — inspect fails on removed containers.
# Safety gate: only paths matching /mnt/*/appdata* are deleted.
cleanup_partner_containers() {
# Returns non-zero if any container or appdata path could not be removed. Previously the
# exit status was whatever the trailing while-loop produced, so "Step 5 — Local cleanup: ✅"
# was printed over a container that failed to remove.
local _rc=0
declare -a containers=()
gather_partner_fallback_containers containers
@@ -609,8 +641,12 @@ cleanup_partner_containers() {
if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$container" >/dev/null 2>&1; then
timeout "${DOCKER_TIMEOUT:-30}" docker stop "$container" >/dev/null 2>&1 || true
_PM_TRAP_STOPPED+=("$container")
timeout "${DOCKER_TIMEOUT:-30}" docker rm "$container" >/dev/null 2>&1 && \
echo "$container removed ✅" || warn "$container rm failed"
if timeout "${DOCKER_TIMEOUT:-30}" docker rm "$container" >/dev/null 2>&1; then
echo "$container removed ✅"
else
warn "$container rm failed"
_rc=1
fi
else
log "$container not found — skipping"
fi
@@ -623,8 +659,14 @@ cleanup_partner_containers() {
warn " DRY RUN — would rm -rf $path"
continue
fi
rm -rf "$path" && echo " Appdata removed: $path" || warn " Failed to remove: $path"
if rm -rf "$path"; then
echo " Appdata removed: $path"
else
warn " Failed to remove: $path"
_rc=1
fi
done <<< "$all_appdata_paths"
return "$_rc"
}
# SSH to mirror — remove all containers named *-${OWNER_SHORT} (owner's deployed containers)
@@ -632,6 +674,10 @@ cleanup_partner_containers() {
# Appdata paths collected via SSH docker inspect before removal, then deleted via SSH.
# Safety gate: only paths matching /mnt/*/appdata* are deleted on the remote.
cleanup_owner_containers_on_mirror() {
# Returns non-zero if any remote removal failed, so the caller can report Step 7 honestly
# rather than from MIRROR_REACHABLE — which only says the mirror answered, not that the
# containers on it are gone.
local _rc=0
local mirror_ip="$1"
local owner_short
owner_short=$(derive_short_name "$OWNER")
@@ -644,7 +690,7 @@ cleanup_owner_containers_on_mirror() {
local container_list
container_list=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$mirror_ip" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"docker ps -a --format '{{.Names}}' 2>/dev/null | grep -i -- '-${owner_short}$'" 2>/dev/null)
if [[ -z "$container_list" ]]; then
@@ -658,28 +704,35 @@ cleanup_owner_containers_on_mirror() {
# Collect appdata paths before removal
local appdata_paths
appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$mirror_ip" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$container' 2>/dev/null \
| awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$mirror_ip" \
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"docker stop '$container' >/dev/null 2>&1
docker rm '$container' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
grep -q removed && \
echo "$container removed from $MIRROR" || \
grep -q removed; then
echo "$container removed from $MIRROR"
else
warn "Failed to remove $container from $MIRROR"
_rc=1
fi
# Delete appdata on remote after container removal
while IFS= read -r path; do
[[ -z "$path" ]] && continue
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$mirror_ip" \
"rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
echo " Appdata removed on $MIRROR: $path" || \
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed; then
echo " Appdata removed on $MIRROR: $path"
else
warn " Failed to remove appdata on $MIRROR: $path"
_rc=1
fi
done <<< "$appdata_paths"
done <<< "$container_list"
return "$_rc"
}
# SSH to mirror — start mirror's own parked containers.
@@ -701,18 +754,23 @@ start_mirror_own_stack() {
fi
log "Restarting own stack on $MIRROR: ${mirror_own[*]}"
local _rc=0
for container in "${mirror_own[@]}"; do
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would start $container on $MIRROR"
continue
fi
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$mirror_ip" \
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"docker start '$container' >/dev/null 2>&1 && echo started" 2>/dev/null | \
grep -q started && \
echo "$container started on $MIRROR" || \
grep -q started; then
echo "$container started on $MIRROR"
else
warn "$container failed to start on $MIRROR — check manually"
_rc=1
fi
done
return "$_rc"
}
# Create the mirror's Emby admin account on the owner's deployed Emby.
@@ -764,7 +822,7 @@ provision_emby_admin() {
error "Emby username '${username}' is already taken on the shared instance"
error "Options:"
error " 1. Sign in with that account — it may already be yours"
error " 2. Set a different name in ${user_var} and re-run --onboard"
error " 2. Set a different name in HOST${MIRROR_ID: -1}_PARTNERSHIP_EMBY_ADMIN_USER and re-run --onboard"
return 1
fi
@@ -881,7 +939,7 @@ check_both_healthy() {
[[ -z "$mirror_ip" ]] && { error "Cannot resolve $MIRROR Tailscale IP"; return 1; }
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$mirror_ip" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"mountpoint -q '$REMOTE_STORAGE_PATH' && timeout 10 docker ps" >/dev/null 2>&1 || {
error "Mirror $MIRROR not healthy"
return 1
@@ -889,38 +947,82 @@ check_both_healthy() {
return 0
}
# Returns non-zero when the mirror did NOT leave with current state, so the caller can say so.
# The whole point of this step is the guarantee in the summary — "mirror leaves with current
# Critical-Data" — and it used to print that unconditionally at the end of the function. An
# offboard with RSYNC_ENABLED=false logged "rsync globally disabled, skipping all syncs"
# immediately followed by "Final sync complete — mirror has current state ✅", and the summary
# scored Step 2 as a pass. The mirror left with whatever it happened to have.
do_final_sync() {
log "Running final critical sync..."
local _synced=0 _failed=0
if [[ "$DRY_RUN" == false ]]; then
if [[ "${#CRITICAL_SYNC_SHARES[@]}" -gt 0 ]]; then
# Tier 1 only, deliberately. rsync.sh honours RSYNC_ENABLED and nothing else — the Tier 2
# gates belong to the orchestrators, so a direct call like this one runs whether or not
# CRITICAL_RSYNC_ENABLED is set. Checking Tier 2 here would refuse a final sync that works
# perfectly well, and the offboard's whole reason for existing is to get current auth data
# onto the mirror before the keys go.
#
# What was wrong was the accounting below it: a share was counted synced whenever Tier 1
# happened to be on, with rsync.sh's exit status ignored entirely. A transfer that failed
# on a full disk, an offline share or a refused connection still reported "Final sync ✅
# — mirror has current state", which is the one claim in this script somebody acts on.
local _gate_ok=true
if [[ "${RSYNC_ENABLED:-true}" != true ]]; then
warn "RSYNC_ENABLED=false — Tier 1 stops every rsync, no share can be sent"
_gate_ok=false
fi
if [[ "$_gate_ok" != true ]]; then
_failed=1
elif [[ "${#CRITICAL_SYNC_SHARES[@]}" -gt 0 ]]; then
for _share in "${CRITICAL_SYNC_SHARES[@]}"; do
[[ -z "$_share" ]] && continue
local _path="${_share%%|*}"
local _profile="${_share##*|}"
# Count on what rsync.sh returned, now that a closed gate can no longer reach here.
if [[ "$_path" == "$_profile" ]]; then
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$_path" --log
else
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$_path" \
--profile="$_profile" --log
fi
if [[ $? -eq 0 ]]; then (( _synced++ )) || true; else (( _failed++ )) || true; fi
done
else
warn "CRITICAL_SYNC_SHARES is empty — skipping final sync (configure in host*.conf)"
_failed=1
fi
else
warn "DRY RUN — would run final critical sync (${#CRITICAL_SYNC_SHARES[@]:-hardcoded} shares)"
# ${#ARR[@]} and :- cannot be combined — bash rejects the whole word as "bad substitution",
# so this line aborted Step 2 of every offboard --dry-run with a shell error instead of
# printing. A count of an unset array is already 0, which is the only default needed.
warn "DRY RUN — would run final critical sync (${#CRITICAL_SYNC_SHARES[@]} shares)"
return 0
fi
warn "Final sync complete — mirror has current state ✅"
if [[ "$_failed" -gt 0 ]]; then
warn "Final sync did NOT complete — $MIRROR leaves with whatever state it already had"
[[ "${RSYNC_ENABLED:-true}" != true ]] && \
warn " RSYNC_ENABLED=false — the Tier 1 gate stopped it before any share was sent"
[[ "${CRITICAL_RSYNC_ENABLED:-true}" != true ]] && \
warn " CRITICAL_RSYNC_ENABLED=false — the Tier 2 gate stopped it"
return 1
fi
echo "Final sync complete — $_synced share(s) sent, mirror has current state ✅"
return 0
}
# Safe master.conf modification with error handling
# Safe master.conf modification with error handling — appends the key if not already present,
# since sed -i returns 0 whether or not it matched anything.
update_master_conf() {
local key="$1" value="$2"
local conf="$SCRIPT_DIR/../master.conf"
local conf="$CONF_DIR/master.conf"
if [[ ! -f "$conf" ]]; then
error "master.conf not found at $conf"
return 1
fi
if grep -q "^[[:space:]]*${key}=" "$conf" 2>/dev/null; then
if sed -i "s|^[[:space:]]*${key}=.*| ${key}=${value}|" "$conf" 2>/dev/null; then
echo "master.conf updated: ${key}=${value}"
return 0
@@ -928,6 +1030,15 @@ update_master_conf() {
error "Failed to update master.conf: ${key}=${value}"
return 1
fi
else
if echo " ${key}=${value}" >> "$conf" 2>/dev/null; then
echo "master.conf updated: ${key}=${value} (appended)"
return 0
else
error "Failed to append to master.conf: ${key}=${value}"
return 1
fi
fi
}
# ── Library-mode guard — source only, skip all mode dispatch ─────────────────────────────────
@@ -1057,17 +1168,33 @@ fi
# ==============================================================================================
if [[ "$MODE" == "check" ]]; then
# Update last_seen_remote and offline counter based on rsync outcome
# Update last_seen_remote and offline counter based on rsync outcome.
#
# Both branches are flocked. The counter is a read-modify-write, so two overlapping
# --check cycles would otherwise both read N and both write N+1 — silently losing an
# increment and pushing the auto-offboard threshold further out than configured. The
# sed on the state file is included because write_state_file() flocks the same file
# from other code paths, and an unsynchronised sed -i can land mid-rewrite.
if [[ "$REMOTE_SEEN" == true ]]; then
(
flock -x 201
echo "0" > "$OFFLINE_COUNTER"
if [[ -f "$LOCAL_STATE_FILE" ]]; then
sed -i "s|^last_seen_remote=.*|last_seen_remote=$(date '+%Y-%m-%d %H:%M:%S')|" \
"$LOCAL_STATE_FILE" 2>/dev/null
fi
) 201>"${OFFLINE_COUNTER}.lock"
elif [[ "$REMOTE_UNSEEN" == true ]]; then
OFFLINE_COUNT=$(cat "$OFFLINE_COUNTER" 2>/dev/null || echo 0)
OFFLINE_COUNT=$(( OFFLINE_COUNT + 1 ))
echo "$OFFLINE_COUNT" > "$OFFLINE_COUNTER"
# Redirection must live INSIDE the substitution — "$( ... ) 201>file" attaches the fd
# to the assignment, not to the subshell doing the work, and flock then fails with
# "Bad file descriptor" while the increment silently proceeds unlocked.
OFFLINE_COUNT=$( {
flock -x 201
_c=$(cat "$OFFLINE_COUNTER" 2>/dev/null || echo 0)
_c=$(( _c + 1 ))
echo "$_c" > "$OFFLINE_COUNTER"
echo "$_c"
} 201>"${OFFLINE_COUNTER}.lock" )
# Auto-offboard threshold: threshold_days × 48 intervals/day (every 30min)
THRESHOLD_INTERVALS=$(( ${PARTNERSHIP_OFFLINE_THRESHOLD:-30} * 48 ))
@@ -1162,13 +1289,7 @@ if [[ "$MODE" == "onboard" ]]; then
echo ""
echo "Enabling partnership in master.conf..."
if [[ "$DRY_RUN" == false ]]; then
local _master_conf="${SCRIPTS_ROOT:-$(dirname "$SCRIPT_DIR")}/master.conf"
if grep -q "^[[:space:]]*PARTNERSHIP_ENABLED=" "$_master_conf" 2>/dev/null; then
sed -i "s|^[[:space:]]*PARTNERSHIP_ENABLED=.*|PARTNERSHIP_ENABLED=true|" "$_master_conf"
else
echo "PARTNERSHIP_ENABLED=true" >> "$_master_conf"
fi
echo "PARTNERSHIP_ENABLED=true in master.conf ✅"
update_master_conf "PARTNERSHIP_ENABLED" "true"
platform_push_conf | while IFS= read -r line; do log "$line"; done
else
warn "DRY RUN — would set PARTNERSHIP_ENABLED=true in master.conf and push"
@@ -1281,7 +1402,7 @@ if [[ "$MODE" == "onboard" ]]; then
warn "DRY RUN — would write ACTIVE state and push to remote"
fi
# Emby admin provisioning — runs after container deployment (deploy step not yet built)
# Emby admin provisioning — runs after container deployment
provision_emby_admin "$MIRROR_IP"
# Summary
@@ -1435,9 +1556,9 @@ if false; then
# Update remote master.conf
timeout "$SSH_TIMEOUT" ssh -i "$NEW_MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$NEW_MIRROR_IP" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$NEW_MIRROR_IP" \
"sed -i 's|^[[:space:]]*PARTNERSHIP_OWNER_HOST=.*| PARTNERSHIP_OWNER_HOST=\"$NEW_OWNER_ID\"|' \
'$SCRIPT_DIR/../master.conf'" 2>/dev/null && \
'$SCRIPT_DIR/../Configurations/master.conf'" 2>/dev/null && \
echo "Remote master.conf updated ✅" || \
error "Failed to update remote master.conf — update manually"
else
+362 -74
View File
@@ -13,28 +13,108 @@
# OPERATIONAL MODEL
# ==============================================================================================
#
# OWNER PATH (10 steps)
# OWNER PATH (11 steps)
# Step 1: Stop rsync — halt any running sync before state changes
# Step 2: Final sync — mirror leaves with current Critical-Data state
# Step 3: Reconfigure WebUIs — mirror's auth WebUIs → localhost
# Step 4: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf
# Step 4: Disarm sync gates — RSYNC/CRITICAL_RSYNC/CONF_SYNC/ARR_SYNC=false in master.conf,
# the exact inverse of onboard Step 9c
# Step 5: Local cleanup — remove fallback coverage containers + appdata
# Step 6: Restart own stack — bring up owner's own parked containers
# Step 7: Remote cleanup — remove auth/arr stack + fallback containers from mirror
# Step 8: Restart mirror — bring up mirror's own parked containers
# Step 9: Revocation — Emby admin, SSH keys
# Step 9: Emby revocation — remove the mirror's Emby admin while Emby is still reachable
# Step 10: Write state — INACTIVE locally + pushed to mirror, mirror blocklisted
# Tailscale — grace window then device removal (after state written)
# Tailscale — grace deadline recorded, device removed after it expires
# Step 11: SSH revocation — keys, both directions. Genuinely last: it is the step that
# removes the access every step above depends on
#
# MIRROR PATH (8 steps)
# Step 1: Stop rsync — halt any running sync
# Step 2: Reconfigure WebUIs — local auth WebUIs → localhost
# Step 3: Remote stack clean — remove owner-deployed containers locally (auth/arr stack)
# Step 4: Fallback cleanup — remove fallback coverage containers
# Step 5: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf
# Step 5: Disarm sync gates — same four gates as the owner path
# Step 6: Revoke Emby admin — remove own admin account from local Emby instance
# Step 7: Restart own stack — bring up own parked containers
# Step 8: SSH revocation — revoke keys both directions, write state, signal owner
# Step 8: Finalise — write INACTIVE, clear phase flags, signal owner, THEN revoke
# keys. Revocation is last because the signal needs the key
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Sync Before Severing
# The final sync (owner step 2) runs before any state change, so the mirror leaves with
# current Critical-Data rather than a snapshot from whenever the last scheduled sync
# happened. Once keys are revoked there is no second chance to move data.
#
# Stop the Sync First
# Step 1 on both paths halts rsync before anything else. A sync running through a
# partnership teardown would be writing to a partner that is having its access removed
# underneath it.
#
# Revoke Last, Not First
# SSH keys and Emby admin are revoked at the end. Every earlier step needs working remote
# access — revoking up front would strand the remaining cleanup on the far side and leave
# the mirror holding containers nobody can remove.
#
# Both Sides Land Somewhere Valid
# Each path restarts the host's own parked containers before finishing. Offboarding must
# leave two working standalone servers, not one working server and one stripped of the
# coverage it was relying on.
#
# Role Detected, Not Declared
# Owner and mirror run different sequences, and the role is derived rather than passed in.
# A human choosing the wrong path would run the owner's remote-cleanup steps against a
# server that never deployed anything.
#
# Blocklist Is the Enforcement
# Writing INACTIVE state is not enough on its own — a stale cron or a script mid-flight
# could still attempt a sync. The mirror is blocklisted so rsync.sh refuses it outright,
# independently of whatever any config still says.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Container removal, conf edits and SSH key revocation all require root.
#
# Lock Acquisition
# acquire_lock "strict" — an offboard is not resumable partway, so a second instance is
# refused rather than queued behind the first.
#
# Host Detection
# detect_hosts() resolves MY_ID / REMOTE_ID, which the role detection builds on.
#
# Docker Presence Check
# Verified before any container removal is attempted.
#
# Ordered Teardown
# The step sequence is the safeguard — sync, then reconfigure, then remove, then restart,
# then revoke, then record. Reordering breaks the guarantees above.
#
# Own Stack Restored
# Parked containers are brought back up on both sides before the run completes.
#
# State Written Both Ends
# INACTIVE is written locally and pushed to the mirror, so neither side is left believing
# a partnership is still active.
#
# Partner Blocklisted
# The mirror is added to the partnership blocklist, which rsync.sh checks and refuses on —
# stale access cannot survive the offboard.
#
# Tailscale Grace Window Is a Deadline, Not a Sleep
# Device removal happens after state is written, not before, so the final state push
# cannot be cut off by removing its own transport. The grace period itself is recorded to
# STATE_DIR/tailscale_removal_due.db and the offboard returns. It used to sleep
# PARTNERSHIP_GRACE_HOURS inline — six hours by default — holding the lock and its job record
# open the whole time, reporting "running", and blocking any re-onboard behind it.
#
# Dry Run Support
# --dry-run walks the full sequence reporting each step without executing any.
#
# ==============================================================================================
# CONFIGURATION
@@ -81,6 +161,19 @@ source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
REASON="manual"
STEP_DISABLE_RSYNC_OK=true # both paths report it; only the mirror path re-initialised it
TAILSCALE_REMOVED=false # set only when remove_tailscale_device actually succeeds
# Owner-path step outcomes. Every one of these was a hardcoded ✅ in the summary, or derived from
# MIRROR_REACHABLE — which says the mirror answered a ping, not that the work on it succeeded.
# An offboard that failed to remove a single container still reported a clean teardown.
STEP_LOCAL_CLEANUP_OK=true
STEP_OWN_STACK_OK=true
STEP_REMOTE_CLEANUP_OK=true # or "skipped" when the mirror is unreachable
STEP_MIRROR_STACK_OK=true # or "skipped"
STEP_STATE_WRITE_OK=true
STEP_STATE_PUSH_OK=false # INACTIVE actually delivered to the mirror, or "skipped"
STEP_SETUP_PUSH_OK=false # cleared phase flags delivered to the mirror, or "skipped"
FILTERED_ARGS=()
for arg in "$@"; do
@@ -108,17 +201,7 @@ fi
detect_hosts
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
OWNER="${!OWNER_ID}"
MIRROR="${!MIRROR_ID}"
MIRROR_SSH_KEY="$SSH_KEY"
OWNER_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
partnership_resolve_roles
LOCAL_STATE_FILE="${STATE_DIR}/partnership_${LOCAL_SERVER_NAME}.db"
REMOTE_STATE_FILE="${STATE_DIR}/partnership_${REMOTE_SERVER_NAME}.db"
@@ -151,6 +234,57 @@ echo " Reason: $REASON"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
# ==============================================================================================
# ── HELPER: disarm every sync gate a completed onboard armed ─────────────────────────────────
#
# The exact inverse of partnership_onboard.sh Step 9c, which sets RSYNC_ENABLED,
# CONF_SYNC_ENABLED and ARR_SYNC_ENABLED to true on a completed onboard. Offboard used to turn
# off CRITICAL_RSYNC_ENABLED and nothing else, which left the far more consequential Tier 1
# RSYNC_ENABLED switched on with no partner to sync to.
#
# That asymmetry read as safe and was not. Turning off one Tier 2 gate looks like "sync is off"
# in the summary, while INTERMEDIATE, DAILY, WEEKLY and FALLBACK rsync all stayed live behind an
# open Tier 1 — and CONF_SYNC_ENABLED kept a 4-hourly job reaching for a partner that had just
# been removed, failing and notifying each time.
#
# CRITICAL_RSYNC_ENABLED stays in the list. It is a Tier 2 gate and closing Tier 1 already stops
# it, but leaving it true would misreport the state to anyone reading the conf rather than the
# tier logic.
#
# FALLBACK_ENABLED is deliberately NOT here. Onboard does not arm it, so offboard has no business
# disarming it — it is the operator's switch, and the summary says so rather than moving it.
# ==============================================================================================
_VV_SYNC_GATES=(RSYNC_ENABLED CRITICAL_RSYNC_ENABLED CONF_SYNC_ENABLED ARR_SYNC_ENABLED)
# ── Stop any running rsync, and say honestly whether it worked ────────────────────────────────
#
# Both offboard paths called "$SCRIPTS_ROOT/Rsync/rsync_stop.sh". That file has never existed —
# rsync_stop.sh lives in System_Essentials/. With stderr sent to /dev/null the "No such file"
# went unseen, and the line below it printed "Rsync stopped ✅" unconditionally, so every
# offboard ever run reported stopping an rsync it had not touched. On the owner path the failed
# exit also set STEP_STOP_OK=false, which is why the summary said ❌ two lines under a ✅.
#
# One helper, one path, and the outcome is the return value.
stop_rsync_now() {
local script="$SCRIPTS_ROOT/System_Essentials/rsync_stop.sh"
if [[ ! -f "$script" ]]; then
warn "rsync_stop.sh not found at $script"
return 1
fi
bash "$script" --rsync-only
}
_disarm_sync_gates() {
local gate rc=0 conf="$SCRIPTS_ROOT/Configurations/master.conf"
for gate in "${_VV_SYNC_GATES[@]}"; do
# set_conf_bool, not update_master_conf: the latter rewrites the whole line and would
# strip the trailing comment that explains what each tier gates. Same helper onboard
# arms with, so arming and disarming are one operation in two directions.
set_conf_bool "$gate" "false" "$conf" || rc=1
done
return "$rc"
}
# ==============================================================================================
# ── HELPER: revoke own admin account from local Emby instance ────────────────────────────────
#
@@ -242,8 +376,12 @@ if [[ "$AM_MIRROR" == true ]]; then
echo "━━━ $ICON_STOP Step 1/8 — Stop Rsync ━━━"
if [[ "$DRY_RUN" == false ]]; then
bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || true
if stop_rsync_now; then
echo "Rsync stopped ✅"
else
warn "Could not stop rsync — a transfer may still be running into $OWNER"
STEP_STOP_RSYNC_OK=false
fi
else
warn "DRY RUN — would stop rsync"
fi
@@ -272,16 +410,14 @@ if [[ "$AM_MIRROR" == true ]]; then
cleanup_partner_containers || STEP_FALLBACK_CLEANUP_OK=false
# ── Step 5: Disable critical sync ─────────────────────────────────────────────────────────
# ── Step 5: Disarm the sync gates ─────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_GEAR Step 5/8 — Disable Critical Sync ━━━"
echo "━━━ $ICON_GEAR Step 5/8 — Disarm Sync Gates ━━━"
if [[ "$DRY_RUN" == false ]]; then
update_master_conf "CRITICAL_RSYNC_ENABLED" "false" && \
warn "CRITICAL_RSYNC_ENABLED=false ✅" || \
{ warn "Failed to update CRITICAL_RSYNC_ENABLED"; STEP_DISABLE_RSYNC_OK=false; }
_disarm_sync_gates || STEP_DISABLE_RSYNC_OK=false
else
warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
warn "DRY RUN — would disarm ${_VV_SYNC_GATES[*]}"
fi
# ── Step 6: Revoke Emby admin locally ─────────────────────────────────────────────────────
@@ -294,19 +430,31 @@ if [[ "$AM_MIRROR" == true ]]; then
echo ""
echo "━━━ $ICON_SHIELD Step 8/8 — SSH Revocation + State ━━━"
do_ssh_key_revocation "${OWNER_IP:-}"
# State first, keys last — same ordering the owner path needed. Revocation used to run here,
# before the push below, so the mirror destroyed the key and then tried to tell the owner it
# had left using that key. The owner never heard, and the notify promised it would "finalise
# on next check" — a check that now had no way in.
NOW=$(date '+%Y-%m-%d %H:%M:%S')
if [[ "$DRY_RUN" == false ]]; then
write_state_file "$LOCAL_STATE_FILE" \
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
echo "Local state: INACTIVE ✅"
add_to_blocklist "$OWNER" "$REASON"
# Inverse of onboard's write_onboard_phase. MIRROR_ID is this host on this path, and the
# flags are named for the mirror on both sides, so the same names clear here.
_setup_db="$(platform_setup_db_path)"
for _flag in "${MIRROR_ID}_PHASE1_DONE" "${MIRROR_ID}_PHASE2_DONE" "${MIRROR_ID}_KEY_READY"; do
clear_state_var "$_setup_db" "$_flag"
done
echo "Onboard phase flags cleared ✅"
unset _setup_db _flag
else
warn "DRY RUN — would write INACTIVE state and blocklist $OWNER"
warn "DRY RUN — would write INACTIVE state, clear phase flags and blocklist $OWNER"
fi
if [[ "$OWNER_REACHABLE" == true ]]; then
platform_push_setup_state 2>/dev/null || warn "Could not push cleared setup state to $OWNER"
push_state_to_remote "$LOCAL_STATE_FILE" "$OWNER_IP" "$OWNER_SSH_KEY"
notify "Partnership offboard requested by $MIRROR$OWNER will finalise on next check" \
"Partnership" "normal"
@@ -314,6 +462,9 @@ if [[ "$AM_MIRROR" == true ]]; then
warn "$OWNER unreachable — state written locally, owner will see it when reachable"
fi
# Last, for the reason above: everything before it needs the key.
do_ssh_key_revocation "${OWNER_IP:-}"
# ── Summary ───────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
@@ -339,7 +490,7 @@ if [[ "$AM_MIRROR" == true ]]; then
echo " Step 2 — WebUIs: $(_ok "$STEP_WEBUI_OK")"
echo " Step 3 — Stack cleanup: $(_ok "$STEP_STACK_CLEANUP_OK")"
echo " Step 4 — Fallback cleanup: $(_ok "$STEP_FALLBACK_CLEANUP_OK")"
echo " Step 5 — Disable sync: $(_ok "$STEP_DISABLE_RSYNC_OK")"
echo " Step 5 — Sync gates: $(_ok "$STEP_DISABLE_RSYNC_OK") (${_VV_SYNC_GATES[*]} → false)"
echo " Step 6 — Emby revoke: $(_ok "$STEP_EMBY_OK")"
echo " Step 7 — Own stack: started"
echo " Step 8 — Keys revoked: $(_revoke_status)"
@@ -378,8 +529,14 @@ echo ""
echo "━━━ $ICON_STOP Step 1/10 — Stop Rsync ━━━"
if [[ "$DRY_RUN" == false ]]; then
bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || STEP_STOP_OK=false
if stop_rsync_now; then
echo "Rsync stopped ✅"
else
warn "Could not stop rsync — a transfer may still be running into $MIRROR"
warn " Everything below changes state while data is still moving, which is the one"
warn " ordering this step exists to prevent. Check: ps -ef | grep rsync"
STEP_STOP_OK=false
fi
else
warn "DRY RUN — would stop rsync"
fi
@@ -412,25 +569,24 @@ else
(( WEBUI_FAILURES++ ))
fi
# ── Step 4: Disable critical sync ─────────────────────────────────────────────────────────────
# ── Step 4: Disarm the sync gates ─────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_GEAR Step 4/10 — Disable Critical Sync ━━━"
echo "━━━ $ICON_GEAR Step 4/10 — Disarm Sync Gates ━━━"
if [[ "$DRY_RUN" == false ]]; then
update_master_conf "CRITICAL_RSYNC_ENABLED" "false"
warn "CRITICAL_RSYNC_ENABLED=false ✅"
_disarm_sync_gates || STEP_DISABLE_RSYNC_OK=false
else
warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
warn "DRY RUN — would disarm ${_VV_SYNC_GATES[*]}"
fi
# ── Step 5: Local container cleanup ───────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 5/10 — Local Container Cleanup ━━━"
cleanup_partner_containers
cleanup_partner_containers || STEP_LOCAL_CLEANUP_OK=false
# ── Step 6: Restart own stack ─────────────────────────────────────────────────────────────────
start_own_stack
start_own_stack || STEP_OWN_STACK_OK=false
# ── Step 7: Remote container cleanup ──────────────────────────────────────────────────────────
echo ""
@@ -438,64 +594,160 @@ echo "━━━ $ICON_CONTAINERS Step 7/10 — Remote Container Cleanup ━━
if [[ "$MIRROR_REACHABLE" == true ]]; then
# Remove auth/arr stack containers deployed during onboard (by config array)
cleanup_deployed_stack_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY"
cleanup_deployed_stack_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_REMOTE_CLEANUP_OK=false
# Remove fallback coverage containers (by *-owner_short naming pattern)
cleanup_owner_containers_on_mirror "$MIRROR_IP"
cleanup_owner_containers_on_mirror "$MIRROR_IP" || STEP_REMOTE_CLEANUP_OK=false
else
warn "$MIRROR unreachable — remote container cleanup skipped"
warn "Run 'partnership_offboard.sh' on $MIRROR to clean up manually"
STEP_REMOTE_CLEANUP_OK=skipped
fi
# ── Step 8: Restart mirror's own stack ────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_START Step 8/10 — Restart Mirror Stack ━━━"
[[ "$MIRROR_REACHABLE" == true ]] && start_mirror_own_stack "$MIRROR_IP"
if [[ "$MIRROR_REACHABLE" == true ]]; then
start_mirror_own_stack "$MIRROR_IP" || STEP_MIRROR_STACK_OK=false
else
STEP_MIRROR_STACK_OK=skipped
fi
# ── Step 9: Revocation (Emby + SSH) ──────────────────────────────────────────────────────────
# ── Step 9: Emby revocation ───────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SHIELD Step 9/10 — Revocation ━━━"
echo "━━━ $ICON_SHIELD Step 9/11 Emby Revocation ━━━"
# Emby admin — before SSH key revocation while Emby still reachable
# Before SSH key revocation, while Emby is still reachable
[[ "$MIRROR_REACHABLE" == true ]] && revoke_emby_admin "$MIRROR_IP"
# SSH key revocation — mutual, both directions; must run while Tailscale still active
do_ssh_key_revocation "${MIRROR_IP:-}"
# ── Step 10: Write state, push to mirror, blocklist ───────────────────────────────────────────
# State is written after container cleanup and SSH revocation so that:
# • Re-running after a crash between steps 59 restarts from scratch (no early-exit on INACTIVE)
# • --check sees INACTIVE during the Tailscale grace sleep and does not re-trigger offboard
# Ahead of SSH revocation, which is now Step 11.
#
# "State Written Both Ends" is the guarantee this step exists for — neither side left believing
# the partnership is active. It could not deliver it: revocation used to run here in Step 9 and
# then this push authenticated with the key it had just destroyed, so every offboard ended
# "Could not push state file to remote — will propagate on next sync". There is no next sync;
# the gates are closed and the keys are gone. The mirror was left reading ACTIVE for ever.
#
# The original ordering note said state must follow revocation so a crash between steps 59
# re-runs from scratch rather than early-exiting on INACTIVE. Writing it one step later than the
# cleanup preserves that — the cleanup is still done before any state is recorded — while
# putting the push back inside the window where it can actually reach the mirror.
echo ""
echo "━━━ $ICON_GEAR Step 10/10 — Write State ━━━"
echo "━━━ $ICON_GEAR Step 10/11 — Write State ━━━"
NOW=$(date '+%Y-%m-%d %H:%M:%S')
if [[ "$DRY_RUN" == false ]]; then
write_state_file "$LOCAL_STATE_FILE" \
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
# Checked, because this is the record every other host and every later --check reads. A
# failed write here leaves both sides believing the partnership is still active while the
# summary says INACTIVE — the one line in the teardown that must not be assumed.
if write_state_file "$LOCAL_STATE_FILE" \
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"; then
echo "Local state: INACTIVE ✅"
else
error "Failed to write local state file — $MIRROR may still look ACTIVE here"
STEP_STATE_WRITE_OK=false
fi
add_to_blocklist "$MIRROR" "$REASON"
[[ "$MIRROR_REACHABLE" == true ]] && \
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
# The inverse of onboard's write_onboard_phase, which had none. Without this a completed
# offboard leaves the state file reading INACTIVE beside HOST*_PHASE1_DONE / _PHASE2_DONE
# still set — and the setup checklist and partnership card read the flags, not the state
# file, so a torn-down partnership went on presenting itself as fully onboarded.
#
# KEY_READY goes too: it means "a key is generated and waiting to be installed", which stops
# being true the moment Step 11 revokes both sides.
_setup_db="$(platform_setup_db_path)"
for _flag in "${MIRROR_ID}_PHASE1_DONE" "${MIRROR_ID}_PHASE2_DONE" "${MIRROR_ID}_KEY_READY"; do
clear_state_var "$_setup_db" "$_flag"
done
echo "Onboard phase flags cleared ✅"
unset _setup_db _flag
if [[ "$MIRROR_REACHABLE" == true ]]; then
# Two separate pushes, and the summary used to report only the second. A run where the
# setup.db push failed and the state-file push succeeded printed "Could not push state
# file to remote" in the body and "Pushed to mirror: ✅" in the summary — describing
# different files with the same words. They are tracked apart now, because they fail
# apart: the mirror can be told the partnership is INACTIVE while keeping the phase
# flags that make its own UI still claim a finished onboard.
#
# Pushed after the flags are cleared, so the mirror receives the cleared file rather than
# the version that still claimed a finished onboard.
if platform_push_setup_state 2>/dev/null; then
STEP_SETUP_PUSH_OK=true
else
warn "Could not push cleared phase flags to $MIRROR — its wizard and partnership"
warn " card will keep showing a completed onboard until it is reinstalled or pulled"
fi
if push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
STEP_STATE_PUSH_OK=true
else
warn "Could not push INACTIVE to $MIRROR — it will keep reading ACTIVE until told otherwise"
fi
else
STEP_STATE_PUSH_OK=skipped
STEP_SETUP_PUSH_OK=skipped
fi
else
warn "DRY RUN — would write INACTIVE state, blocklist $MIRROR, push to remote"
STEP_STATE_PUSH_OK=true
STEP_SETUP_PUSH_OK=true
fi
# ── Step 11: SSH key revocation ───────────────────────────────────────────────────────────────
# Genuinely last. Every step above needs working remote access — this is the one that takes it
# away, so it goes after the final thing that has to reach the mirror.
echo ""
echo "━━━ $ICON_SHIELD Step 11/11 — SSH Key Revocation ━━━"
do_ssh_key_revocation "${MIRROR_IP:-}"
# Tailscale removal — after state written so --check does not re-trigger offboard during grace sleep
if [[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]]; then
if [[ "${PARTNERSHIP_REMOVE_TAILSCALE:-false}" == true ]]; then
echo ""
echo "━━━ $ICON_NET Tailscale Separation ━━━"
if [[ "$MIRROR_REACHABLE" == true ]]; then
grace_seconds=$(( ${PARTNERSHIP_GRACE_HOURS:-6} * 3600 ))
warn "Waiting ${PARTNERSHIP_GRACE_HOURS:-6}hr grace — mirror can collect backups..."
# The grace period is recorded as a deadline, not slept through.
#
# This used to `sleep $((PARTNERSHIP_GRACE_HOURS * 3600))` inline — six hours by default —
# holding the offboard's lock and its job record open the whole time, showing "running" to
# every status reader, and blocking any re-onboard behind the lock. Worse, the sleep ran
# even when removal was going to be a no-op: TAILSCALE_API_KEY and TAILSCALE_TAILNET are
# both empty here, so the six hours bought nothing at all.
#
# The offboard's own work is finished by this point. Writing the deadline lets the teardown
# complete now and leaves the removal to whoever reads the file — and makes the wait
# visible and cancellable instead of buried in a sleeping process.
_grace_h="${PARTNERSHIP_GRACE_HOURS:-6}"
if [[ "$_grace_h" -gt 0 ]] && [[ "$MIRROR_REACHABLE" == true ]]; then
_due=$(( $(date +%s) + _grace_h * 3600 ))
if [[ "$DRY_RUN" == false ]]; then
trap 'warn "Offboard interrupted during grace sleep"; exit 0' SIGTERM SIGINT
sleep "$grace_seconds"
trap - SIGTERM SIGINT
printf 'host=%s\ndue=%s\ndue_human=%s\nreason=%s\n' \
"$MIRROR" "$_due" "$(date -d "@$_due" '+%Y-%m-%d %H:%M:%S')" "$REASON" \
> "${STATE_DIR}/tailscale_removal_due.db"
fi
warn "Grace period: $MIRROR stays on the tailnet until $(date -d "@$_due" '+%Y-%m-%d %H:%M') — recorded, not slept"
# No CLI entry point removes it yet, and there is deliberately no invented one here:
# remove_tailscale_device() is a partnership_manager.sh function with no --mode of its
# own, and it no-ops without credentials regardless. Say what is true.
if [[ -z "${TAILSCALE_API_KEY:-}" || -z "${TAILSCALE_TAILNET:-}" ]]; then
warn "Automatic removal is not possible — TAILSCALE_API_KEY/TAILSCALE_TAILNET are unset; remove it in the Tailscale admin console"
else
warn "Removal after that is not yet automated — remove it in the Tailscale admin console"
fi
TAILSCALE_REMOVED=deferred
unset _grace_h _due
else
# No grace configured, or the mirror is already unreachable — remove now.
#
# Outcome recorded, not assumed. remove_tailscale_device returns 1 when TAILSCALE_API_KEY
# or TAILSCALE_TAILNET is unset — it warns "skipping Tailscale removal" and the summary
# went on to report "removed ✅" anyway, so an offboard that left the device on the
# tailnet said it had taken it off. Neither key is configured here, so that was every run.
if remove_tailscale_device "$MIRROR"; then
TAILSCALE_REMOVED=true
fi
fi
remove_tailscale_device "$MIRROR"
fi
# Backup handover notification
@@ -534,24 +786,60 @@ _revoke_status() {
echo " Step 1 — Stop rsync: $(_ok "$STEP_STOP_OK")"
echo " Step 2 — Final sync: $(_ok "$STEP_SYNC_OK")"
echo " Step 3 — WebUI failures: $WEBUI_FAILURES"
echo " Step 4 — Disable sync: ✅"
echo " Step 5 — Local cleanup: "
echo " Step 6 — Own stack: started"
echo " Step 7 — Remote cleanup: $( [[ "$MIRROR_REACHABLE" == true ]] && echo "✅" || echo "skipped (unreachable)" )"
echo " Step 8 — Mirror stack: $( [[ "$MIRROR_REACHABLE" == true ]] && echo "started" || echo "skipped (unreachable)" )"
echo " Step 9 — Keys revoked: $(_revoke_status)"
echo " Step 10 — State: INACTIVE ✅"
echo " Step 4 — Sync gates: $(_ok "$STEP_DISABLE_RSYNC_OK") (${_VV_SYNC_GATES[*]} → false)"
echo " Step 5 — Local cleanup: $(_ok "$STEP_LOCAL_CLEANUP_OK")"
echo " Step 6 — Own stack: $( [[ "$STEP_OWN_STACK_OK" == true ]] && echo "started ✅" || echo "⚠️ check warnings above" )"
echo " Step 7 — Remote cleanup: $( [[ "$STEP_REMOTE_CLEANUP_OK" == skipped ]] && echo "skipped (unreachable)" || _ok "$STEP_REMOTE_CLEANUP_OK" )"
echo " Step 8 — Mirror stack: $( [[ "$STEP_MIRROR_STACK_OK" == skipped ]] && echo "skipped (unreachable)" || { [[ "$STEP_MIRROR_STACK_OK" == true ]] && echo "started" || echo "⚠️ check warnings above"; } )"
echo " Step 9 — Emby revoked: $(_ok "${STEP_EMBY_OK:-true}")"
echo " Step 10 — State: $( [[ "$STEP_STATE_WRITE_OK" == true ]] && echo "INACTIVE ✅" || echo "⚠️ WRITE FAILED — still looks ACTIVE here" )"
echo " Step 10 — INACTIVE pushed: $( [[ "$STEP_STATE_PUSH_OK" == skipped ]] && echo "skipped (unreachable)" || { [[ "$STEP_STATE_PUSH_OK" == true ]] && echo "✅" || echo "⚠️ $MIRROR still reads ACTIVE"; } )"
echo " Step 10 — Phase flags pushed: $( [[ "$STEP_SETUP_PUSH_OK" == skipped ]] && echo "skipped (unreachable)" || { [[ "$STEP_SETUP_PUSH_OK" == true ]] && echo "✅" || echo "⚠️ $MIRROR still shows a finished onboard"; } )"
echo " Step 11 — Keys revoked: $(_revoke_status)"
echo ""
echo " Blocklist: $MIRROR blocked — re-onboard to permit access again ✅"
[[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \
echo " Tailscale: $MIRROR removed ✅"
if [[ "${PARTNERSHIP_REMOVE_TAILSCALE:-false}" == true ]]; then
case "$TAILSCALE_REMOVED" in
true) echo " Tailscale: $MIRROR removed ✅" ;;
deferred) echo " Tailscale: $MIRROR kept until the grace period expires — see ${STATE_DIR}/tailscale_removal_due.db" ;;
*) echo " Tailscale: $MIRROR NOT removed ⚠ — still on the tailnet (needs TAILSCALE_API_KEY + TAILSCALE_TAILNET)" ;;
esac
fi
# Named because it is the one partnership switch neither onboard nor offboard moves, so it
# survives an offboard still true and there is nothing else that would ever mention it.
[[ "${FALLBACK_ENABLED:-false}" == true ]] && \
echo " FALLBACK_ENABLED is still true — nothing left to fail over to, turn it off yourself"
echo ""
echo " $MIRROR leaves with:"
echo " ✓ Current auth config (final sync)"
echo " ✓ Auth WebUIs → localhost"
# Each line reads the step that produced it. "✓ Current auth config (final sync)" was a
# literal, so an offboard whose Step 2 had just warned "Final sync did NOT complete" still
# closed by telling the operator the mirror held current auth data. That is the one claim here
# somebody might act on — it is the difference between a partner that can stand alone and one
# carrying a stale copy of the auth stack.
if [[ "$STEP_SYNC_OK" == true ]]; then
echo " ✓ Current auth config (final sync)"
else
echo " ✗ Auth config NOT synced — it keeps whatever it already had (see Step 2)"
fi
if [[ "${WEBUI_FAILURES:-0}" -eq 0 ]]; then
echo " ✓ Auth WebUIs → localhost"
else
echo "${WEBUI_FAILURES} auth WebUI(s) still point at $LOCAL_SERVER_NAME — fix by hand there"
fi
echo "${PARTNERSHIP_GRACE_HOURS:-6}hr to collect backups"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
warn "$ICON_DONE DONE — clean separation complete ✅"
# The verdict is derived, never asserted. "DONE — clean separation complete ✅" printed
# unconditionally, under a summary that had already shown two failed steps.
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$STEP_STOP_OK" == true && "$STEP_SYNC_OK" == true \
&& "$STEP_STATE_PUSH_OK" != false && "$STEP_SETUP_PUSH_OK" != false \
&& "${WEBUI_FAILURES:-0}" -eq 0 ]]; then
echo "$ICON_DONE DONE — clean separation complete ✅"
else
warn "$ICON_DONE Offboard finished with unresolved steps — the partnership is ended, but"
warn " the ❌ lines above did not happen. Re-read them before re-onboarding."
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
+665 -114
View File
@@ -22,6 +22,12 @@
# Step 1: SSH key setup — generate keypair, install on mirror, update conf
# Step 1b: Docker network — ensure varaverk docker network exists on mirror
# Step 1c: Share setup — create missing Unraid shares on mirror (pool-aware, idempotent)
# Step 1c2:Permit mirror — drop the mirror from the partnership blocklist a previous
# offboard wrote, or Step 1e's rsync is refused
# Step 1d: Sync gates — Tier 1 + CONF_SYNC + ARR_SYNC on, every Tier 2 rsync gate
# off. Here, not at the end, because Step 1e is an rsync
# Step 1e: Auth appdata — rsync PARTNERSHIP_PROVISION_SHARES to the mirror BEFORE the
# containers that read it exist. The only rsync an onboard does
# Step 2: Stop mirror auth — stop mirror's existing auth containers before replacing
# Step 3: Deploy auth stack — push XMLs, pull images, create + start on mirror
# Mariadb/Redis health-checked before Authelia deploys
@@ -32,10 +38,19 @@
# Step 8: Partnership onboard — configure WebUIs → owner IP, write state, Emby
# Step 9: Arr bootstrap — bidirectional library sync (arr_sync.sh)
# Step 9b: Webhook setup — register download webhook in arrs on both servers
# Step 9c: Media seed — rsync all DAILY_SYNC_SHARES to mirror (--seed)
# prevents arrs treating every file as missing after bootstrap
# Step 9d: Webhook listener — start listener on mirror (runs continuously, no reboot needed)
# Step 9e: Webhook listener — start listener on mirror (runs continuously, no reboot needed)
# Step 10: Conf push — push master.conf + setup state to all listed hosts
# Step 11: Service discovery — conf_populate.sh on the mirror, last, once the stacks it
# would discover are actually deployed there
# Step 12: Container grouping — reproduce this host's folder layout on the mirror (Arrs
# Stack, Networking, Databases…) for the containers deployed
# there; only unfiled or PARTNERSHIP_FALLBACK_ONLY entries go
# to "<OwnerShort>-Fallback". Icon resolved here and passed
# over: the mirror has no Emby key
#
# Phase 3 (media seed) is NOT part of the above. Onboard ends at Phase 2 — connected, running,
# and saying so. Seeding is a separate operator-triggered phase; see --phase3-only. With
# MEDIA_SEED_ENABLED=false there is no Phase 3 and the model is two phases.
#
# ==============================================================================================
# DESIGN PRINCIPLES
@@ -150,16 +165,27 @@
# Partnership/partnership_onboard.sh --skip-webhook-setup
# Skip webhook registration in arrs (Step 9b)
#
# Partnership/partnership_onboard.sh --skip-media-seed
# Skip initial media share rsync to mirror (Step 9c)
# Use when mirror already has files or you want to seed manually
# Partnership/partnership_onboard.sh --phase3-only
# OWNER: media seed only. Refuses unless Phase 2 is done and MEDIA_SEED_ENABLED is true.
# Sets the seeding gate posture (Tier 1 open, every Tier 2 gate closed) and dispatches
# Rsync/media_seed.sh detached. Returns in seconds; the seed runs for weeks.
#
# Partnership/partnership_onboard.sh --skip-appdata-provision
# Skip the pre-container auth appdata rsync (Step 1e)
# The mirror's auth containers then start against whatever is already there
#
# Partnership/partnership_onboard.sh --skip-webhook-listener
# Skip starting webhook listener on mirror (Step 9d)
# Skip starting webhook listener on mirror (Step 9e)
# Listener will start automatically on next array restart
#
# Partnership/partnership_onboard.sh --no-arm
# Leave every sync gate as it is (Step 1d). Step 1e then has no Tier 1 and cannot provision.
# Use when onboarding a node you want to keep inert — a rebuild test, or a mirror whose
# shares are not populated yet.
#
# Partnership/partnership_onboard.sh --phase1-only
# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.
# OWNER only: SSH key exchange, conf push, docker network, partner conf cache.
# Safe to run before HOST2 has Varaverk — all of it needs docker and SSH, not the plugin.
# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.
#
# Partnership/partnership_onboard.sh --phase2-only
@@ -184,10 +210,13 @@ SKIP_ARR_STACK=false
SKIP_SERVICES_STACK=false
SKIP_ARR_SYNC=false
SKIP_WEBHOOK_SETUP=false
SKIP_MEDIA_SEED=false
SKIP_WEBHOOK_LISTENER=false
PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)
PHASE1_ONLY=false # OWNER: SSH + conf push only (mirror not yet installed)
# MIRROR: SSH key install only, no owner notification
PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)
PHASE3_ONLY=false # OWNER: media seed only — a separate decision, see the PHASE 3 block
SKIP_ARM=false # leave the sync gates as they are — see Step 1d
SKIP_APPDATA_PROVISION=false # skip the pre-container auth appdata sync — see Step 1e
FILTERED_ARGS=()
for arg in "$@"; do
@@ -199,10 +228,12 @@ for arg in "$@"; do
--skip-services-stack) SKIP_SERVICES_STACK=true ;;
--skip-arr-sync) SKIP_ARR_SYNC=true ;;
--skip-webhook-setup) SKIP_WEBHOOK_SETUP=true ;;
--skip-media-seed) SKIP_MEDIA_SEED=true ;;
--skip-appdata-provision) SKIP_APPDATA_PROVISION=true ;;
--skip-webhook-listener) SKIP_WEBHOOK_LISTENER=true ;;
--phase1-only) PHASE1_ONLY=true ;;
--phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;
--phase3-only) PHASE3_ONLY=true; SKIP_SSH=true ;;
--no-arm) SKIP_ARM=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
@@ -223,24 +254,11 @@ fi
detect_hosts
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
OWNER="${!OWNER_ID}"
MIRROR="${!MIRROR_ID}"
# SSH_KEY (set by detect_hosts) is this server's own private key.
# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.
# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's
# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.
MIRROR_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
partnership_resolve_roles
EXTRA_FLAGS=()
[[ "$DRY_RUN" == true ]] && EXTRA_FLAGS+=("--dry-run")
[[ "$LOG_MODE" == true ]] && EXTRA_FLAGS+=("--log")
[[ "$ENABLE_LOGGING" == true ]] && EXTRA_FLAGS+=("--log")
START=$(date +%s)
@@ -250,14 +268,16 @@ write_onboard_phase() {
local key="${target_id}_PHASE${phase}_DONE"
local state_file="$(platform_setup_db_path)"
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
if grep -q "^${key}=" "$state_file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=true|" "$state_file"
else
echo "${key}=true" >> "$state_file"
fi
platform_push_setup_state
set_state_var "$state_file" "$key" "true"
# The push now reports failure, so it is warned about rather than returned. This function's
# contract is "the phase is recorded here" — the flag is on local disk either way, and an
# unreachable partner must not make a completed phase look like it did not happen.
platform_push_setup_state \
|| warn "Phase flag ${key} written locally but not delivered to the partner"
return 0
}
echo ""
echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
@@ -282,13 +302,7 @@ stop_mirror_stack() {
local config_var="$1" label="$2"
local -a to_stop=()
mapfile -t to_stop < <(
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${${config_var}[@]:-}\"" 2>/dev/null | grep -v '^$'
)
mapfile -t to_stop < <(read_remote_conf_array "$MIRROR_IP" "$config_var" | grep -v '^$')
if [[ ${#to_stop[@]} -eq 0 ]]; then
log "No $label containers to stop on $MIRROR — skipping"
@@ -302,7 +316,7 @@ stop_mirror_stack() {
continue
fi
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"docker stop '$container' 2>/dev/null
docker rm '$container' 2>/dev/null && echo removed" 2>/dev/null | \
grep -q removed && \
@@ -320,14 +334,47 @@ if [[ "$AM_MIRROR" == true ]]; then
echo " Mirror sets up SSH keys, then notifies Owner to run Phase 2."
echo ""
# Tested before attempted, the same guard the owner's phase 1 has had all along.
#
# The key step is a TERMINAL step by design — ssh_setup.sh runs ssh-copy-id, which prompts for
# the owner's root password on a first install. Once the operator has done that in a terminal,
# pressing ▶ Onboard ran the whole thing again: another ssh-copy-id, this time from the WebGUI
# with no TTY to answer the prompt, which fails and aborts the run at Step 1 — so the button
# whose entire job is Step 2 could never reach it.
#
# Working SSH is the actual precondition, not "have we run the setup script". If it already
# works there is nothing to install, whichever route installed it.
OWNER_IP_PRE=$(resolve_tailscale_ip "$OWNER" 2>/dev/null || true)
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping SSH setup (--skip-ssh)"
elif [[ -n "$OWNER_IP_PRE" ]] && timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
root@"$OWNER_IP_PRE" exit 0 2>/dev/null; then
echo "SSH to $OWNER already works ✅ — key already installed, skipping setup"
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
echo "SSH key ready ✅"
else
error "SSH key setup failed"
error "Install the key from a terminal on this host — ssh-copy-id needs $OWNER's password,"
error "and a WebGUI button has no way to answer that prompt:"
error " bash $SCRIPTS_ROOT/Partnership/partnership_onboard.sh --phase1-only"
exit 1
fi
unset OWNER_IP_PRE
# Stop after the key when asked. ssh_setup.sh runs ssh-copy-id, which prompts for the
# owner's root password on a first install — answerable in a terminal, never from the
# WebGUI button, which is why the mirror's panel sends the operator to a terminal for
# exactly this step and nothing more. The flag was parsed but only ever honoured on the
# owner path, so a mirror asked for phase 1 silently ran the whole thing.
if [[ "$PHASE1_ONLY" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY MIRROR PHASE 1 COMPLETE ━━━━━"
echo " SSH key: ready"
echo " Next: press ▶ Onboard on the Partnership tab to notify $OWNER"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━"
@@ -338,24 +385,80 @@ if [[ "$AM_MIRROR" == true ]]; then
if [[ -n "$OWNER_IP" ]]; then
# Read OWNER's SCRIPTS_DIR via platform probe command — don't assume same path as mirror
_probe_cmd=$(platform_scripts_dir_probe_cmd)
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"$_probe_cmd" 2>/dev/null | tr -d '[:space:]')
OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-$SCRIPTS_DIR}"
OWNER_SCRIPTS_DIR=$(resolve_remote_scripts_dir "$OWNER_IP")
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
PHASE2_TRIGGERED=true
elif timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered" \
2>/dev/null | grep -q triggered; then
echo "Phase 2 triggered on $OWNER"
log "Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log"
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2 via run_job.sh"
PHASE2_TRIGGERED=true
else
# Launched through run_job.sh, the same path cron and api/run.php use, so Phase 2
# gets a stat file, a job log and a Scheduler entry on the owner. It used to be a
# bare `nohup … > /tmp/vv_phase2_onboard.log`, which ran fine and left the owner's
# entire half of onboarding invisible to its own WebGUI — no job record, nothing
# under /var/log/varaverk, nothing for the operator to look at when asking why
# pressing Onboard here appeared to do nothing.
#
# setsid, not bare nohup: the job must lead its own process group so api/stop.php
# can signal the whole tree. api/run.php carries the same note for the same reason.
#
# Reported triggered only after the stat file proves run_job.sh actually started.
# The old `& echo triggered` printed unconditionally — it would have claimed success
# for a path that does not exist on the owner, which is exactly the failure mode a
# mirror in a different storage mode hits.
# Its own timeout, not SSH_TIMEOUT: the remote waits for the runner to prove itself,
# and 15s would cut that short and report a healthy launch as a failure.
_phase2_out=$(timeout 40 ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"bash -s -- $(printf '%q' "$OWNER_SCRIPTS_DIR")" 2>/dev/null <<'PHASE2_TRIGGER'
sd="$1"
runner="$sd/Plugin/unraid/run_job.sh"
script="$sd/Partnership/partnership_onboard.sh"
stat_file="/var/log/varaverk/Partnership/partnership_onboard.json"
[ -f "$runner" ] || { echo "missing-runner:$runner"; exit 1; }
[ -f "$script" ] || { echo "missing-script:$script"; exit 1; }
# Absolute, not "newer than the file we saw a moment ago". A previous run's stat file rewritten
# inside the same second would compare equal and read as a failed launch.
t0=$(date +%s)
setsid nohup bash "$runner" "Partnership/partnership_onboard.sh" "$script" \
--manual --phase2-only >/dev/null 2>&1 </dev/null &
# run_job.sh writes the stat file before it execs the script, so a stat file dated at or after
# the moment we launched is proof the runner is alive — rather than proof the ssh call returned.
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
sleep 1
[ -f "$stat_file" ] || continue
now=$(date -r "$stat_file" +%s 2>/dev/null || echo 0)
[ "$now" -ge "$t0" ] && { echo triggered; exit 0; }
done
echo start-failed
exit 1
PHASE2_TRIGGER
)
# Failure patterns are matched first, and they echo a path back. An owner whose
# SCRIPTS_DIR happened to contain the word "triggered" would otherwise satisfy a
# leading *triggered* glob and report success for a launch that never happened.
case "$_phase2_out" in
missing-runner:*)
error "Phase 2 not started — no run_job.sh at ${_phase2_out#missing-runner:} on $OWNER"
;;
missing-script:*)
error "Phase 2 not started — no partnership_onboard.sh at ${_phase2_out#missing-script:} on $OWNER"
;;
start-failed)
error "Phase 2 launch on $OWNER did not produce a job record — check run_job.sh there"
;;
triggered)
echo "Phase 2 triggered on $OWNER"
log "Watch on $OWNER: Scheduler tab, or tail -f /var/log/varaverk/Partnership/partnership_onboard.log"
PHASE2_TRIGGERED=true
;;
*)
warn "Could not auto-trigger Phase 2 on $OWNER"
;;
esac
fi
else
warn "Cannot resolve $OWNER Tailscale IP"
@@ -383,10 +486,129 @@ MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
log "Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)"
[[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)"
[[ "$PHASE3_ONLY" == true ]] && log "Mode: Phase 3 only (media seed)"
echo ""
# ==============================================================================================
# ── PHASE 3: MEDIA SEED ───────────────────────────────────────────────────────────────────────
#
# The partnership is finished at the end of Phase 2 — connected, running, and saying so. Phase 3
# is not part of it. It is the separate decision to fill the partner's disks by rsync, and it is
# operator-triggered because a first seed of a full library is weeks of transfer and tens of
# terabytes of somebody else's free space.
#
# The seed used to be a step inside Phase 2. Inline it held the onboard open for the entire
# transfer, which meant the phase-2 flag, the conf push, discovery and container grouping all
# waited on it, and both hosts described an unfinished onboard over a partnership that was
# already live. Detaching it fixed the blocking but left the seed starting on its own, which is
# still the wrong default: the machine decided to move 28 TB because an onboard succeeded.
#
# With MEDIA_SEED_ENABLED=false there is no Phase 3 at all and the model is two phases. That is
# the point of the toggle — not a seed that runs and does nothing, an absent phase.
# ==============================================================================================
if [[ "$PHASE3_ONLY" == true ]]; then
echo ""
echo "━━━━━ $ICON_SYNC PHASE 3 — MEDIA SEED — $MY_ID$MIRROR ━━━━━"
echo ""
_master_conf="$SCRIPTS_ROOT/Configurations/master.conf"
_seed_gate=$(grep -m1 -E '^[[:space:]]*MEDIA_SEED_ENABLED=' "$_master_conf" 2>/dev/null \
| cut -d= -f2- | cut -d'#' -f1 | tr -d '"'"'" | tr -d '[:space:]')
if [[ -n "$_seed_gate" && "$_seed_gate" != "true" ]]; then
error "MEDIA_SEED_ENABLED is '$_seed_gate' — there is no Phase 3 on this host"
error " Onboarding is two phases while seeding is off. Arm the toggle in master.conf"
error " if you want the partner's library filled by rsync."
exit 1
fi
_setup_db="$(platform_setup_db_path)"
if ! grep -q "^${MIRROR_ID}_PHASE2_DONE=true" "$_setup_db" 2>/dev/null; then
error "$MIRROR has not completed Phase 2 — seeding a partner that is not onboarded"
error " would push media at a host with no containers to serve it. Finish Phase 2 first."
exit 1
fi
unset _setup_db
# Seeding posture, set before the transfer rather than after it. Tier 1 has to be open or
# rsync.sh refuses every share; every Tier 2 gate is closed so the scheduled orchestrators
# are not competing for the same link and the same disks for the next several weeks. This is
# also the posture the seed leaves behind — global on, per-orchestrator off — so finishing
# Phase 3 does not quietly hand the schedule back.
echo "━━━ Gate posture ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would set RSYNC_ENABLED true and every Tier 2 rsync gate false"
else
cp -a "$_master_conf" "${_master_conf}.bak-phase3-$(date +%Y%m%d-%H%M%S)"
set_conf_bool RSYNC_ENABLED "true" "$_master_conf" \
|| { error "Could not open Tier 1 — the seed would move nothing"; exit 1; }
for _gate in CRITICAL_RSYNC_ENABLED INTERMEDIATE_RSYNC_ENABLED DAILY_RSYNC_ENABLED \
WEEKLY_RSYNC_ENABLED MONTHLY_RSYNC_ENABLED FALLBACK_RSYNC_ENABLED; do
set_conf_bool "$_gate" "false" "$_master_conf" \
|| warn "Could not close $_gate — a scheduled sync may run beside the seed"
done
unset _gate
echo " Tier 1 open, every Tier 2 gate closed ✅"
fi
unset _master_conf _seed_gate
echo ""
echo "━━━ Dispatch ━━━"
_seed_script="$SCRIPTS_ROOT/Rsync/media_seed.sh"
_runner="$SCRIPTS_ROOT/Plugin/$PLATFORM/run_job.sh"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would dispatch Rsync/media_seed.sh"
exit 0
fi
if [[ ! -f "$_seed_script" || ! -f "$_runner" ]]; then
error "Seed runner not found at $_seed_script"
exit 1
fi
# setsid, not nohup + &: the seed must outlive this script and must not share its process
# group, or stopping Phase 3 takes the transfer down with it.
_seed_flags=()
[[ "$ENABLE_LOGGING" == true ]] && _seed_flags+=(--log)
setsid bash "$_runner" "Rsync/media_seed.sh" "$_seed_script" "${_seed_flags[@]}" \
>/dev/null 2>&1 < /dev/null &
disown 2>/dev/null || true
# Report the record, not the launch. run_job.sh writes its stat file before running the
# script, so a fresh "running" record is the difference between dispatched and attempted.
_seed_stat="/var/log/varaverk/Rsync/media_seed.json"
_dispatched=false
for _i in $(seq 1 10); do
if [[ -f "$_seed_stat" ]] \
&& grep -q '"status":"running"' "$_seed_stat" 2>/dev/null \
&& (( $(date +%s) - $(stat -c %Y "$_seed_stat") < 60 )); then
_dispatched=true
break
fi
sleep 1
done
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 3 SUMMARY ━━━━━"
if [[ "$_dispatched" == true ]]; then
echo " Seed: running as job Rsync/media_seed.sh ✅"
echo " Shares: ${#DAILY_SYNC_SHARES[@]}"
echo " Gates: Tier 1 open · every Tier 2 closed"
echo ""
echo " A first full seed runs for days. Watch it on the Partnership tab; the"
echo " partnership itself has been live since Phase 2 and does not depend on this."
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
error " Seed did not start — check /var/log/varaverk/Rsync/media_seed.log"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 1
fi
STEP_SSH_OK=false
STEP_NETWORK_OK=false
LOCAL_SETUP_OK=true # partnership_manager --local-only; the summary claimed done ✅ regardless
PHASE1_NET_OK=false # Phase 1 only — network created on the mirror before any deploy
PHASE1_CACHE_OK=false # Phase 1 only — our conf pushed into the mirror's RAM cache
STEP_STOP_AUTH_OK=true
STEP_AUTH_OK=true
AUTH_DEPLOYED=0
@@ -402,8 +624,6 @@ SERVICES_FAILED=0
ONBOARD_OK=false
ARR_SYNC_OK=false
WEBHOOK_SETUP_OK=false
MEDIA_SEED_OK=false
MEDIA_SEED_COUNT=0
WEBHOOK_LISTENER_OK=false
MASTER_PUSH_OK=false
@@ -444,11 +664,9 @@ elif [[ "$PHASE1_ONLY" == true ]]; then
echo " Then click 'Push Conf' in the Partnership tab."
# Write key-ready flag so UI can show the manual-install state
[[ "$DRY_RUN" == false ]] && {
local kflag="${MIRROR_ID}_KEY_READY"
local _setup_f="$(platform_setup_db_path)"
grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
&& sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|| echo "${kflag}=true" >> "$_setup_f"
kflag="${MIRROR_ID}_KEY_READY"
_setup_f="$(platform_setup_db_path)"
set_state_var "$_setup_f" "$kflag" "true"
}
fi
STEP_SSH_OK=false
@@ -472,15 +690,17 @@ if [[ "$PHASE1_ONLY" == true ]]; then
# UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
if ! bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}"; then
LOCAL_SETUP_OK=false
warn "Local setup had issues — check partnership_manager.sh output above"
fi
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━"
echo " SSH keys: key generated ✅ — NOT yet installed on $MIRROR"
echo " Conf push: skipped (needs SSH access to $MIRROR)"
echo " HOST1 setup: done ✅"
echo " HOST1 setup: $( [[ "$LOCAL_SETUP_OK" == true ]] && echo "done ✅" || echo "⚠️ had issues — see above" )"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " ACTION NEEDED: install the key on $MIRROR:"
@@ -511,11 +731,72 @@ if [[ "$PHASE1_ONLY" == true ]]; then
fi
fi
# ── Phase 1 — Docker network on the mirror ────────────────────────────────────────────────
# Here, not only in Step 1b, because a --phase1-only run exits above and never reaches it.
# The mirror needs docker, not Varaverk, so this works before the plugin is installed — and
# creating the network now means it is in place long before the first container is deployed
# against it. Deploying against a missing network is what left twelve containers stuck in
# `Created`, so the earliest safe moment is the right one.
echo ""
echo "━━━ Phase 1 — Docker Network ($MIRROR) ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would create any networks the stack templates reference on $MIRROR"
PHASE1_NET_OK=true
elif ensure_stack_networks_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
PHASE1_NET_OK=true
else
warn "Network prep incomplete on $MIRROR — Step 1b retries this during Phase 2"
fi
# ── Phase 1 — Partner conf cache ──────────────────────────────────────────────────────────
# CONF_SYNC_ENABLED is armed here rather than waiting for Step 1d. It is the safe one of the
# three gates — it moves no data, it copies each side's host*.conf into the other's tmpfs so
# partner vars resolve — and the moment SSH works is the moment that should start. Leaving it
# until Phase 2 meant conf_sync.sh, which sources the conf fresh in its own process, exited
# on a closed gate every time it was called before then.
#
# Push always; pull only if the mirror actually has a conf yet. Before HOST2 installs Varaverk
# there is nothing to pull, and an unconditional pull would count a failure and notify about
# a condition that is simply "the mirror is not installed". The pull lands on the re-run after the
# install — the same --phase1-only --skip-ssh the operator uses to push conf again.
echo ""
echo "━━━ Phase 1 — Partner Conf Cache ━━━"
_conf_sync_script="$SCRIPTS_ROOT/System_Essentials/conf_sync.sh"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would arm CONF_SYNC_ENABLED and cache confs with $MIRROR"
PHASE1_CACHE_OK=true
elif [[ ! -f "$_conf_sync_script" ]]; then
warn "conf_sync.sh not found — skipping partner conf cache"
else
set_conf_bool CONF_SYNC_ENABLED "true" "$SCRIPTS_ROOT/Configurations/master.conf" \
|| warn "Could not arm CONF_SYNC_ENABLED — cache step may no-op"
if bash "$_conf_sync_script" --push-only; then
PHASE1_CACHE_OK=true
else
warn "Could not push our conf to $MIRROR"
fi
_mirror_sd=$(resolve_remote_scripts_dir "$MIRROR_IP" "$MIRROR_SSH_KEY" "no")
if timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
root@"$MIRROR_IP" \
"[ -f '${_mirror_sd}/Configurations/${MIRROR_ID,,}.conf' ]" 2>/dev/null; then
bash "$_conf_sync_script" --pull-only || warn "Could not pull ${MIRROR_ID,,}.conf from $MIRROR"
else
echo " $MIRROR has no ${MIRROR_ID,,}.conf yet — it will cache once Varaverk is installed there"
fi
unset _mirror_sd
fi
unset _conf_sync_script
# HOST1 local setup — runs immediately without needing HOST2
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
if ! bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}"; then
LOCAL_SETUP_OK=false
warn "Local setup had issues — check partnership_manager.sh output above"
fi
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
@@ -524,11 +805,29 @@ if [[ "$PHASE1_ONLY" == true ]]; then
echo "━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━"
echo " SSH keys: $( [[ "$STEP_SSH_OK" == true ]] && echo "ready ✅" || echo "skipped" )"
echo " Conf push: $( [[ "$CONF_PUSH_OK" == true ]] && echo "done ✅" || echo "⚠ manual needed" )"
echo " HOST1 setup: done ✅"
echo " Network: $( [[ "$PHASE1_NET_OK" == true ]] && echo "ready on $MIRROR" || echo "⚠ Step 1b will retry" )"
echo " Conf cache: $( [[ "$PHASE1_CACHE_OK" == true ]] && echo "pushed to $MIRROR" || echo "⚠ not cached" )"
echo " HOST1 setup: $( [[ "$LOCAL_SETUP_OK" == true ]] && echo "done ✅" || echo "⚠️ had issues — see above" )"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
echo " The wizard will detect the pushed conf and take the correct path."
# The push no longer waits for a Varaverk install on the far side. It resolves the partner's
# conf directory across both layouts and creates the internal one if neither exists, so the
# conf arrives BEFORE the plugin — which is the order that makes it useful. The .plg only
# seeds master.conf from the template when none is present, so what lands here survives the
# install and the wizard reads its identity straight out of it.
if [[ "$CONF_PUSH_OK" == true ]]; then
echo " master.conf is on $MIRROR — the wizard will find it and take the partner path,"
echo " already knowing $MY_ID and $MIRROR_ID. If the operator picks flash storage there,"
echo " storage_migrate.sh moves the conf to appdata with the rest of the install."
else
echo " master.conf was NOT delivered. Phase 1 seeds it into a bare host, so this is a"
echo " real failure, not the pre-install state — check SSH and that /boot is writable"
echo " on $MIRROR. Retry with:"
echo " • bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh"
echo " • or push from Scheduler → master.conf → Save Conf"
echo " • or, once the plugin is installed, 'Pull from HOST1' on HOST2's Setup tab"
fi
echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here."
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
@@ -539,18 +838,45 @@ fi
echo ""
echo "━━━ Step 1b — Docker Network (Mirror) ━━━"
_net_script="${SCRIPTS_ROOT}/Docker_Essentials/docker_network_connect.sh"
# Two halves, and the first is the one that matters for a fresh mirror.
#
# ensure_stack_networks_on_remote reads the networks out of the XMLs this onboard is about to
# push and creates any that are missing on the mirror. It does not consult the mirror's conf,
# because on a fresh node that array is the template default — a single commented-out entry —
# and an empty list is indistinguishable from "no networks needed". The result was every
# container in both stacks created against a network that did not exist.
#
# docker_network_connect.sh still runs afterwards: it is what *connects* the mirror's own
# listed containers to its own listed networks, which is a different job and remains the
# mirror's to declare.
if ! ensure_stack_networks_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
warn "One or more stack networks could not be prepared on $MIRROR — deploys below may fail"
fi
# Resolve the path on the mirror, not here. This used SCRIPTS_ROOT — the OWNER's install path —
# to name a script it then runs over SSH on the MIRROR. That only holds while both hosts install
# to the same place, and an appdata-mode mirror is at /mnt/user/appdata/Varaverk, so bash was
# handed a path that does not exist there and Step 1b failed on every appdata install. The
# warning even printed the owner's path and told the operator to go check it on the mirror.
#
# Same fix as Steps 11 and 12, which already resolve the remote layout this way.
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would run docker_network_connect.sh on $MIRROR"
STEP_NETWORK_OK=true
elif timeout 60 ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"bash '$_net_script'" 2>/dev/null; then
else
_mirror_sd=$(resolve_remote_scripts_dir "$MIRROR_IP" "$MIRROR_SSH_KEY" "no")
_net_script="${_mirror_sd}/Docker_Essentials/docker_network_connect.sh"
if timeout 60 ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
root@"$MIRROR_IP" \
"[ -f '$_net_script' ] || { echo missing; exit 127; }; bash '$_net_script'" 2>/dev/null; then
echo "Docker network ready on $MIRROR"
STEP_NETWORK_OK=true
else
else
warn "docker_network_connect.sh failed on $MIRROR — containers may fail if network is missing"
warn "Check ${_net_script} on $MIRROR and re-run with --skip-ssh if needed"
fi
unset _mirror_sd
fi
# ── Step 1c: Share setup ─────────────────────────────────────────────────────────────────────
@@ -565,6 +891,135 @@ else
bash "$SCRIPT_DIR/share_setup.sh"
fi
# ── Step 1c2: Permit the mirror ───────────────────────────────────────────────────────────────
# A previous offboard blocklists the mirror, and rsync.sh refuses any host on that list outright
# — deliberately, so a stale cron cannot keep syncing to a partner that has been removed.
#
# The blocklist was cleared inside Step 8, which was fine while every rsync in the onboard came
# after it. Step 1e does not: it runs before the containers are deployed, which is the whole
# point of it. On a re-onboard that ordering means the provisioning sync is refused, the auth
# stack comes up against empty directories, and the step built to prevent exactly that reports
# a failure nobody can explain from its own output.
#
# Un-blocking here is not a loosening. The offboard's own summary says "re-onboard to permit
# access again" — an onboard is the event that permits it, and this is where the onboard starts
# needing it. Step 8 still runs its own clear; removing an absent entry is a no-op.
echo ""
echo "━━━ Step 1c2 — Permit $MIRROR ━━━"
UNBLOCK_OK=true
_blocklist="${PARTNERSHIP_BLOCKLIST_FILE:-${STATE_DIR}/partnership_blocklist.db}"
if [[ ! -f "$_blocklist" ]] || ! grep -q "^${MIRROR}|" "$_blocklist" 2>/dev/null; then
echo " $MIRROR is not blocklisted ✅"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove $MIRROR from the partnership blocklist"
else
sed -i "/^${MIRROR}|/d" "$_blocklist" 2>/dev/null
if grep -q "^${MIRROR}|" "$_blocklist" 2>/dev/null; then
error "Could not remove $MIRROR from $_blocklist — rsync.sh will refuse it"
UNBLOCK_OK=false
else
echo " Removed $MIRROR from the blocklist ✅"
fi
fi
unset _blocklist
# ── Step 1d: Sync gates ───────────────────────────────────────────────────────────────────────
# This was Step 9c, after the containers were already deployed. It has to run here instead,
# because Step 1e below is itself an rsync and Tier 1 stops every rsync — arming afterwards
# meant the provisioning sync exited cleanly having moved nothing.
#
# master.conf.template ships a fresh node inert. A successful Phase 1 is what makes Tier 1,
# CONF_SYNC and ARR_SYNC true; without this the defaults were a one-way door and somebody had
# to remember to hand-edit three toggles on the machine where forgetting looks exactly like
# everything working.
#
# Tier 2 is now set false rather than "left as configured". Tier 1 opening is what makes the
# scheduled jobs live, and a node that was onboarded ten seconds ago is not a node anyone has
# checked yet — free space, share layout, what the partner actually holds. Arriving connected
# and idle is the useful state; the operator arms each orchestrator when they mean to.
# MEDIA_SEED_ENABLED is not touched here at all — see Step 13.
#
# Owner only — the mirror receives these values in the Step 10 push rather than deciding.
ARM_OK=true
_VV_ARM_ON=(RSYNC_ENABLED CONF_SYNC_ENABLED ARR_SYNC_ENABLED)
_VV_ARM_OFF=(CRITICAL_RSYNC_ENABLED INTERMEDIATE_RSYNC_ENABLED DAILY_RSYNC_ENABLED
WEEKLY_RSYNC_ENABLED MONTHLY_RSYNC_ENABLED FALLBACK_RSYNC_ENABLED)
echo ""
echo "━━━ $ICON_GEAR Step 1d — Sync Gates ━━━"
if [[ "$SKIP_ARM" == true ]]; then
echo " --no-arm — leaving the sync gates as they are"
warn " Step 1e needs Tier 1 open; with it closed the auth appdata will not be provisioned"
elif [[ "$AM_OWNER" != true ]]; then
echo " mirror — the owner's push decides these"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would set ${_VV_ARM_ON[*]} true and ${_VV_ARM_OFF[*]} false"
else
_master_conf="$SCRIPTS_ROOT/Configurations/master.conf"
if [[ ! -f "$_master_conf" ]]; then
warn "master.conf not found at $_master_conf — gates left closed"
ARM_OK=false
else
cp -a "$_master_conf" "${_master_conf}.bak-arm-$(date +%Y%m%d-%H%M%S)"
for _gate in "${_VV_ARM_ON[@]}"; do set_conf_bool "$_gate" "true" "$_master_conf" || ARM_OK=false; done
for _gate in "${_VV_ARM_OFF[@]}"; do set_conf_bool "$_gate" "false" "$_master_conf" || ARM_OK=false; done
unset _gate
echo " Tier 1 open: ${_VV_ARM_ON[*]}"
echo " Tier 2 off: every scheduled rsync — arm them yourself when you are ready"
fi
unset _master_conf
fi
# ── Step 1e: Provision the auth stack's appdata ───────────────────────────────────────────────
# The one rsync an onboard actually needs, and it has to land before Step 3 creates the
# containers that read it. Deploying first meant Authelia, Lldap, NPM and both databases came up
# on the mirror against empty directories and initialised themselves from nothing — so the
# mirror's auth stack was a fresh install wearing the owner's container names.
#
# The critical-data profile, not critical-fallback. critical-data stops the auth containers on
# both sides for the duration, which costs the owner its auth for about a minute at 588 MB, and
# buys a consistent copy. critical-fallback is the dirty variant that keeps auth running, and a
# dirty copy is exactly what broke Redis and MariaDB on the mirror before: the manifest and the
# binlog index were copied without the files they name, and MariaDB hides that — the container
# reads Up while mysqld_safe restarts the dead database every few seconds.
#
# Media is not seeded here or anywhere in Phase 2. This share is 588 MB and the containers do
# not start correctly without it; a media library is terabytes and nothing waits on it.
echo ""
echo "━━━ $ICON_SYNC Step 1e — Auth Appdata Provision ━━━"
APPDATA_PROVISION_OK=false
if [[ "$SKIP_APPDATA_PROVISION" == true ]]; then
warn "Skipping (--skip-appdata-provision) — the mirror's auth stack will start empty"
elif [[ "${#PARTNERSHIP_PROVISION_SHARES[@]}" -eq 0 ]]; then
warn "PARTNERSHIP_PROVISION_SHARES is empty — nothing to provision"
warn " Set it in host${MY_ID: -1}.conf, or the mirror's auth stack starts from nothing"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would sync ${PARTNERSHIP_PROVISION_SHARES[*]} to $MIRROR"
APPDATA_PROVISION_OK=true
else
_prov_rc=0
for _share in "${PARTNERSHIP_PROVISION_SHARES[@]}"; do
echo " Provisioning: $_share"
_prov_flags=()
[[ "$ENABLE_LOGGING" == true ]] && _prov_flags+=(--log)
if ! bash "$SCRIPTS_ROOT/Rsync/rsync.sh" "$_share" "${_prov_flags[@]}"; then
warn " Failed: $_share"
_prov_rc=1
fi
done
unset _share _prov_flags
if [[ "$_prov_rc" -eq 0 ]]; then
echo "Auth appdata provisioned to $MIRROR"
APPDATA_PROVISION_OK=true
else
warn "Auth appdata NOT fully provisioned — the containers below will start against"
warn " whatever is already on $MIRROR, which on a fresh node is nothing"
fi
unset _prov_rc
fi
# ── Step 2: Stop mirror's existing auth stack ─────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Stop Mirror Auth Stack ━━━"
@@ -667,14 +1122,14 @@ if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$SKIP_ARR_SYNC" == true ]]; then
warn "Skipping (--skip-arr-sync)"
elif [[ ! -f "$SCRIPTS_ROOT/Media/arr_sync.sh" ]]; then
warn "arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live"
elif bash "$SCRIPTS_ROOT/Media/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
elif [[ ! -f "$SCRIPTS_ROOT/Arrs_Stack/arr_sync.sh" ]]; then
warn "arr_sync.sh not found — run Arrs_Stack/arr_sync.sh manually once arrs are live"
elif bash "$SCRIPTS_ROOT/Arrs_Stack/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
echo "Arr bootstrap complete ✅"
ARR_SYNC_OK=true
else
warn "Arr sync had errors — partnership still valid"
warn "Re-run Media/arr_sync.sh once all arr containers are live"
warn "Re-run Arrs_Stack/arr_sync.sh once all arr containers are live"
fi
# ── Step 9b: Webhook setup ────────────────────────────────────────────────────────────────────
@@ -701,48 +1156,13 @@ else
fi
unset _webhook_script
# ── Step 9c: Seed media shares on mirror ─────────────────────────────────────────────────────
# arr_sync.sh bootstrapped the databases — mirror's arrs now know about all content but
# have no files on disk. Without this rsync, every imported item looks missing and arrs
# will immediately queue searches. --seed skips the empty-remote guard and does a clean push.
echo ""
echo "━━━ Step 9c — Media Share Seed ━━━"
if [[ "$SKIP_MEDIA_SEED" == true ]]; then
warn "Skipping (--skip-media-seed)"
elif [[ "${#DAILY_SYNC_SHARES[@]}" -eq 0 ]]; then
warn "DAILY_SYNC_SHARES empty for $MY_ID — skipping media seed"
warn "Configure HOST${MY_ID: -1}_DAILY_SYNC_SHARES in host${MY_ID: -1}.conf and run Rsync/rsync.sh --seed manually"
else
echo " Seeding ${#DAILY_SYNC_SHARES[@]} share(s) to $MIRROR — this may take a while"
_rsync_script="$SCRIPTS_ROOT/Rsync/rsync.sh"
_seed_flags=(--seed)
[[ "$DRY_RUN" == true ]] && _seed_flags+=(--dry-run)
[[ "$LOG_MODE" == true ]] && _seed_flags+=(--log)
for _share in "${DAILY_SYNC_SHARES[@]}"; do
echo " Seeding: $_share"
if bash "$_rsync_script" "$_share" "${_seed_flags[@]}"; then
(( MEDIA_SEED_COUNT++ )) || true
else
warn " Seed failed for $_share — re-run: Rsync/rsync.sh $_share --seed"
fi
done
if [[ "$MEDIA_SEED_COUNT" -gt 0 ]]; then
echo "Media seed complete — ${MEDIA_SEED_COUNT}/${#DAILY_SYNC_SHARES[@]} share(s) ✅"
MEDIA_SEED_OK=true
else
warn "Media seed: no shares completed — check errors above"
fi
unset _rsync_script _seed_flags _share
fi
# ── Step 9d: Start webhook listener on mirror ─────────────────────────────────────────────────
# ── Step 9e: Start webhook listener on mirror ─────────────────────────────────────────────────
# Listener is in ARRAY_START_SCRIPTS so it starts on next boot, but the mirror's array is
# already running — kick it now so events are captured immediately after onboard.
echo ""
echo "━━━ Step 9d — Webhook Listener (Mirror) ━━━"
echo "━━━ Step 9e — Webhook Listener (Mirror) ━━━"
_listener_script="$SCRIPTS_ROOT/Media/start_webhook_listener.sh"
_listener_script="$SCRIPTS_ROOT/Arrs_Stack/start_webhook_listener.sh"
if [[ "$SKIP_WEBHOOK_LISTENER" == true ]]; then
warn "Skipping (--skip-webhook-listener)"
elif [[ "${WEBHOOK_PORT:-0}" -eq 0 ]]; then
@@ -785,6 +1205,133 @@ else
fi
fi
# ── Step 11: Service discovery on the mirror ──────────────────────────────────────────────────
# Deliberately last. conf_populate.sh fills host*.conf from what it can actually find running —
# arr API keys, container names, URLs — and until Step 3 and Step 5 deployed the auth and arr
# stacks there was nothing on the mirror to find. The wizard runs it during first-run setup,
# which on a fresh mirror is precisely the moment the machine is still empty, so everything it
# could have discovered was discovered as absent.
#
# No --overwrite: it only fills blanks, so anything the operator set by hand survives. --no-push
# because Step 10 above has just pushed conf; letting discovery push again would race it.
echo ""
echo "━━━ $ICON_GEAR Step 11 — Service Discovery ($MIRROR) ━━━"
POPULATE_OK=false
# MIRROR_IP, not MIRROR_REACHABLE — the latter is partnership_offboard.sh's variable and does not
# exist in this script, so the test was always true against an empty string and Step 11 reported
# "skipped (unreachable)" on a mirror it had just deployed twelve containers to.
if [[ -z "${MIRROR_IP:-}" ]]; then
warn "$MIRROR has no resolved IP — skipping discovery, run Deployment/conf_populate.sh there later"
POPULATE_OK=skipped
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would run conf_populate.sh --no-push on $MIRROR"
POPULATE_OK=true
else
_mirror_sd=$(resolve_remote_scripts_dir "$MIRROR_IP" "$MIRROR_SSH_KEY" "no")
_pop_script="${_mirror_sd}/Deployment/conf_populate.sh"
if timeout 180 ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
root@"$MIRROR_IP" \
"[ -f '$_pop_script' ] || { echo missing; exit 127; }; bash '$_pop_script' --no-push" 2>/dev/null; then
echo "Discovery complete on $MIRROR"
POPULATE_OK=true
else
warn "Discovery failed on $MIRROR — run $_pop_script there by hand"
fi
unset _mirror_sd _pop_script
fi
# ── Step 12: Group our containers on the mirror ───────────────────────────────────────────────
# The mirror now runs a dozen containers that are ours, scattered among its own. This files them
# under one folder named after us — "<OwnerShort>-Fallback" — matching the convention the owner
# already keeps for the mirror's containers.
#
# The icon is resolved HERE and passed over, not looked up there. It comes from the closest Emby
# user to our own name, and the mirror has neither our Emby key nor necessarily an Emby at all —
# so a lookup on that side would find nothing and the folder would come up blank.
#
# Not fatal in any direction: folder.view3 absent on the mirror is a clean skip, and a folder
# without a picture is still a folder.
echo ""
echo "━━━ $ICON_GEAR Step 12 — Container Grouping ($MIRROR) ━━━"
FOLDER_OK=false
if [[ -z "${MIRROR_IP:-}" ]]; then
warn "$MIRROR has no resolved IP — skipping container grouping"
FOLDER_OK=skipped
else
mapfile -t _deployed < <(deployed_stack_container_names)
_deployed_csv=$(IFS=,; echo "${_deployed[*]}")
if [[ -z "$_deployed_csv" ]]; then
log "No stack templates resolved to container names — nothing to group"
FOLDER_OK=skipped
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would create ${MY_ID}-named fallback folder on $MIRROR with: $_deployed_csv"
FOLDER_OK=true
else
_ff_local="$SCRIPTS_ROOT/Plugin/$PLATFORM/Tools/fallback_folder.php"
_mf_local="$SCRIPTS_ROOT/Plugin/$PLATFORM/Tools/mirror_folders.php"
_icon=$(php "$_ff_local" --host="$MY_ID" --icon-only 2>/dev/null || true)
[[ -z "$_icon" ]] && log "No icon resolved for $MY_ID — folder will be created without one"
_mirror_sd=$(resolve_remote_scripts_dir "$MIRROR_IP" "$MIRROR_SSH_KEY" "no")
_ff_remote="${_mirror_sd}/Plugin/${PLATFORM}/Tools/fallback_folder.php"
_mf_remote="${_mirror_sd}/Plugin/${PLATFORM}/Tools/mirror_folders.php"
# ── 12a: reproduce our own folder layout on the mirror ────────────────────────────────
# The deployed stacks run on the mirror continuously — they are not failover coverage —
# so they belong on the same shelves they occupy here: Sonarr in "Arrs Stack", NPM and
# Lldap in "Networking", the databases in "Databases". Filing all of them under
# "<Owner>-Fallback", which is what this step used to do, records whose they are and
# nothing about what they do.
#
# The plan is computed here, where the owner's folder.view3 layout lives, and applied
# there. Whatever the layout does not account for comes back as "unfiled" and is what
# the fallback folder is actually for.
_unfiled="$_deployed_csv"
if [[ -f "$_mf_local" ]]; then
# Comma-joined: the flag takes a CSV, and "${arr[*]}" would join on spaces — which
# also happen to appear inside folder names, so the plan must never be reflowed.
_fbonly=$(IFS=,; printf '%s' "${PARTNERSHIP_FALLBACK_ONLY[*]:-}")
_plan=$(php "$_mf_local" --export --containers="$_deployed_csv" \
--fallback-only="$_fbonly" 2>/dev/null)
if [[ -n "$_plan" ]]; then
if printf '%s' "$_plan" | timeout 60 ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
root@"$MIRROR_IP" \
"[ -f '$_mf_remote' ] || exit 127; php '$_mf_remote' --import" 2>/dev/null; then
_unfiled=$(printf '%s' "$_plan" | php -r \
'echo implode(",", json_decode(stream_get_contents(STDIN),true)["unfiled"] ?? []);' 2>/dev/null)
else
warn "Could not mirror the folder layout to $MIRROR — falling back to one folder"
fi
fi
unset _plan _fbonly
fi
# ── 12b: the fallback folder gets only what is genuinely fallback ─────────────────────
# Unfiled containers plus anything named in PARTNERSHIP_FALLBACK_ONLY. Empty is the
# normal, correct outcome when every deployed container has a home in the layout.
if [[ -z "$_unfiled" ]]; then
echo " Folder layout mirrored — nothing left for ${MY_ID}-Fallback ✅"
FOLDER_OK=true
elif timeout 60 ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
root@"$MIRROR_IP" \
"[ -f '$_ff_remote' ] || { echo missing; exit 127; }
php '$_ff_remote' --host=$(printf '%q' "$MY_ID") \
--containers=$(printf '%q' "$_unfiled") \
--icon=$(printf '%q' "$_icon")" 2>/dev/null; then
FOLDER_OK=true
else
warn "Could not group containers on $MIRROR — run $_ff_remote there by hand"
fi
unset _ff_local _ff_remote _mf_local _mf_remote _mirror_sd _icon _unfiled
fi
unset _deployed _deployed_csv
fi
# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────
[[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2
@@ -802,6 +1349,9 @@ _skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
echo " Step 1 — SSH keys: $(_skip "$SKIP_SSH" "$STEP_SSH_OK")"
echo " Step 1b — Docker network: $(_ok "$STEP_NETWORK_OK")"
echo " Step 1c2— Permit mirror: $(_ok "$UNBLOCK_OK")"
echo " Step 1d — Sync gates: $( [[ "$SKIP_ARM" == true ]] && echo "skipped (--no-arm)" || { [[ "$AM_OWNER" != true ]] && echo "mirror — set by owner" || _ok "$ARM_OK"; } )"
echo " Step 1e — Auth appdata: $( [[ "$SKIP_APPDATA_PROVISION" == true ]] && echo "skipped" || _ok "$APPDATA_PROVISION_OK" )"
echo " Step 2 — Stop auth: $(_skip "$SKIP_AUTH_STACK" "$STEP_STOP_AUTH_OK")"
echo " Step 3 — Auth stack: $( [[ "$SKIP_AUTH_STACK" == true ]] && echo "skipped" || echo "${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed" )"
echo " Step 4 — Stop arr: $(_skip "$SKIP_ARR_STACK" "$STEP_STOP_ARR_OK")"
@@ -811,9 +1361,10 @@ echo " Step 7 — Services stack: $( [[ "$SKIP_SERVICES_STACK" == true ]] &&
echo " Step 8 — Onboard: $(_ok "$ONBOARD_OK")"
echo " Step 9 — Arr bootstrap: $( [[ "$SKIP_ARR_SYNC" == true || "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$ARR_SYNC_OK")" )"
echo " Step 9b — Webhook setup: $(_skip "$SKIP_WEBHOOK_SETUP" "$WEBHOOK_SETUP_OK")"
echo " Step 9cMedia seed: $( [[ "$SKIP_MEDIA_SEED" == true ]] && echo "skipped" || echo "${MEDIA_SEED_COUNT}/${#DAILY_SYNC_SHARES[@]} shares $(_ok "$MEDIA_SEED_OK")" )"
echo " Step 9d — Webhook listener: $(_skip "$SKIP_WEBHOOK_LISTENER" "$WEBHOOK_LISTENER_OK")"
echo " Step 9eWebhook listener: $(_skip "$SKIP_WEBHOOK_LISTENER" "$WEBHOOK_LISTENER_OK")"
echo " Step 10 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )"
echo " Step 11 — Discovery: $( [[ "$POPULATE_OK" == skipped ]] && echo "skipped (unreachable)" || _ok "$POPULATE_OK" )"
echo " Step 12 — Grouping: $( [[ "$FOLDER_OK" == skipped ]] && echo "skipped" || _ok "$FOLDER_OK" )"
echo ""
if [[ "$ONBOARD_OK" == true ]]; then
+28 -16
View File
@@ -28,6 +28,31 @@
# Step 5: Write State — ACTIVE written locally and pushed to new mirror
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Config Changes Hands, Data Does Not
# No containers move and no appdata is copied. A transfer rewrites who is authoritative
# and where WebUIs point; the sync direction reverses naturally on the next fallback or
# critical-sync cycle. Keeping data out of the transfer is what makes it cheap enough to
# be reversible.
#
# Owner Initiates, Always
# Only the current owner can run this. The owner holds the authoritative config, so a
# mirror-initiated transfer would be writing ownership state it does not own — and if both
# sides ran it, neither would be owner.
#
# Prove Health Before Swapping
# Both servers must pass consecutive health checks first. Handing ownership to a partner
# that is unhealthy converts a recoverable situation into an outage with the authoritative
# side on the weaker host.
#
# Roles Swap Atomically
# Owner and mirror are two ends of one relationship, not independent flags. Any window
# where both believe they are owner — or neither does — is worse than the transfer simply
# failing, so the swap is written as one transition rather than two updates.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
@@ -128,17 +153,7 @@ fi
detect_hosts
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
OWNER="${!OWNER_ID}"
MIRROR="${!MIRROR_ID}"
MIRROR_SSH_KEY="$SSH_KEY"
OWNER_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
partnership_resolve_roles
LOCAL_STATE_FILE="${STATE_DIR}/partnership_${LOCAL_SERVER_NAME}.db"
REMOTE_STATE_FILE="${STATE_DIR}/partnership_${REMOTE_SERVER_NAME}.db"
@@ -286,14 +301,11 @@ if [[ "$DRY_RUN" == false ]]; then
# Push updated master.conf to new owner so both servers agree immediately.
# master.conf is shared — host-specific credentials live in host*.conf.
_probe_cmd=$(platform_scripts_dir_probe_cmd)
_REMOTE_SD=$(ssh -i "$SSH_KEY" -o ConnectTimeout=5 -o StrictHostKeyChecking=no \
"root@${MIRROR_IP}" "$_probe_cmd" \
2>/dev/null | tr -d '[:space:]')
_REMOTE_SD="${_REMOTE_SD:-$SCRIPTS_DIR}"
_REMOTE_SD=$(resolve_remote_scripts_dir "$MIRROR_IP" "$SSH_KEY" "no")
scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
"$SCRIPTS_ROOT/Configurations/master.conf" \
"root@${MIRROR_IP}:${_REMOTE_SD}/Configurations/master.conf" 2>/dev/null && \
echo "master.conf pushed to $NEW_OWNER" || \
+106 -3
View File
@@ -25,7 +25,96 @@
# Remote admin assigns disk sets from the Unraid UI after onboarding.
#
# ==============================================================================================
# USAGE
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Resolve the mirror and its Tailscale IP — unresolvable aborts before any remote call
# 2. Detect the remote's appdata cache pool from its own appdata.cfg (default: cache)
# 3. For each path across the owner's daily/weekly/critical/intermediate share lists:
# a. Extract the top-level Unraid share name
# b. Remote already has a .cfg for it? → skip, never modify
# c. Otherwise use the LOCAL .cfg as a template:
# - substitute the remote's detected pool
# - clear disk include/exclude (disk layouts differ per server)
# d. Write the .cfg to remote /boot/config/shares/ and mkdir -p the share directory
# e. Sub-paths (e.g. appdata-Fallback/Critical-Data) get their subdir created after
# the top-level share exists
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Create Only, Never Modify
# An existing remote .cfg is always left alone. The remote admin may have deliberately
# tuned a share's pool, allocation or disk set — this script has no way to tell an
# intentional setting from a stale one, so it never overwrites.
#
# Disk Layout Is Not Portable
# Include/exclude lists are cleared rather than copied, because the two servers have
# different disks. Copying the owner's disk set onto a mirror with a different array
# would produce a share pointing at disks that do not exist.
#
# Detect the Pool, Do Not Assume It
# The remote's cache pool name is read from its own appdata.cfg rather than hardcoded or
# copied from local. Pool names differ per server and a wrong one silently lands appdata
# on the array.
#
# Media Shares Land Array-Only
# Shares with shareUseCache=no are created without a pool assignment. Disk sets are the
# remote admin's call from the Unraid UI after onboarding.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Reads $SSH_KEY from /root/.ssh and the local /boot/config/shares/*.cfg, and writes share
# configs onto the mirror as root.
#
# Lock Acquisition
# acquire_lock prevents concurrent runs. Two instances could both observe a share as
# missing and race to create it.
#
# Host Detection
# detect_hosts() resolves REMOTE_SERVER_NAME — the mirror this script targets.
#
# Mirror Resolution Guard
# Aborts if REMOTE_SERVER_NAME is unset or its Tailscale IP cannot be resolved, before any
# remote command is attempted.
#
# Existing Share Protection
# A remote .cfg that already exists is never touched — see Create Only above.
#
# SSH Timeouts and BatchMode
# Every remote call uses ConnectTimeout and BatchMode=yes, so an unreachable or
# password-prompting mirror fails fast instead of hanging the onboarding run.
#
# Pool Fallback
# An undetectable remote pool defaults to "cache" rather than writing an empty pool name
# into the share config.
#
# Dry Run Support
# --dry-run reports every share it would create and writes nothing.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf (aliased by detect_hosts())
#
# HOST*_DAILY_SYNC_SHARES / _WEEKLY_ / _CRITICAL_ / _INTERMEDIATE_
# The share lists this script reads. Any path appearing in one of these on the owner
# is a share the mirror is expected to have.
#
# SSH_KEY
# Key used for every remote call. Written by ssh_setup.sh.
#
# master.conf
#
# HOST* — hostnames, used to resolve the mirror via detect_hosts()
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# share_setup.sh
@@ -40,9 +129,21 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
detect_hosts
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
# Reads $SSH_KEY from /root/.ssh, reads local /boot/config/shares/*.cfg, and writes share
# configs onto the mirror as root.
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
# Writes share .cfg files to the mirror. Two concurrent runs could both see a share as
# missing and race to create it.
acquire_lock
detect_hosts
# ── Resolve mirror ────────────────────────────────────────────────────────────────────────────
[[ -z "${REMOTE_SERVER_NAME:-}" ]] && { error "Cannot determine mirror hostname — check HOST* in master.conf"; exit 1; }
@@ -52,7 +153,7 @@ MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
_ssh() { ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=no root@"$MIRROR_IP" "$@"; }
_scp() { scp -i "$SSH_KEY" -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$@"; }
_scp() { scp -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=no "$@"; }
# ── Detect remote appdata pool ────────────────────────────────────────────────────────────────
@@ -175,3 +276,5 @@ done
echo ""
echo " Shares: $CREATED created, $SKIPPED already existed on $MIRROR"
[[ "$DRY_RUN" == true ]] && echo " (dry-run — nothing written)"
exit 0
+149 -29
View File
@@ -2,40 +2,143 @@
# ==============================================================================================
# ============================= SSH Setup ======================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Generates the SSH keypair for rsync automation and installs it on the remote server.
# Every cross-host operation in the ecosystem — rsync, conf sync, fallback container
# control, the upgrade webhook — authenticates with this key. If it is missing or broken,
# the mesh silently degrades to single-host.
#
# Key named after this server: hostname lowercased, unraid- prefix stripped.
# unRAID-Gmer4Lfe → gmer4lfe_rsync_automation
# unRAID-Jayred365 → jayred365_rsync_automation
# Idempotent — skips generation if key already exists (use --force to regenerate).
# Updates host*.conf with key path on success.
# unRAID-Jayred36 → jayred36_rsync_automation
#
# ── MODES ─────────────────────────────────────────────────────────────────────────────────────
# (default) — generate key if missing, copy to remote, update conf
# --force — regenerate key even if it exists, re-copy to remote
# --validate — test SSH auth to remote, track strikes, notify at limit
# --status — show key state, fingerprint, remote connectivity
# --dry-run — preview without creating, copying, or updating conf
# The name comes from the OS hostname, never from Tailscale. HOST2 answers to both
# `unRAID-Jayred36` (hostname -s — Unraid truncates Server Name to the 15-char NetBIOS limit)
# and `unraid-jayred365` (its Tailscale peer name), and only the first one decides this filename.
# api/setup.php derives the same path independently when it pulls master.conf, from the same
# source — so a key created under the Tailscale spelling is a key neither of them will find.
#
# Idempotent — skips generation if the key already exists (--force to regenerate).
# Updates host*.conf with the key path on success.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Setup (default):
# 1. Key exists? → skip generation unless --force
# 2. Generate keypair, named from this host
# 3. Copy the public key to the remote's authorized_keys
# 4. Verify authentication actually works before claiming success
# 5. Write the key path into host*.conf
#
# Validate (--validate), called during partnership --check cycles:
# Remote unreachable on Tailscale → network problem, NOT counted as a strike
# Remote reachable but SSH auth fails → key problem, strike incremented
# Clean connectivity for SSH_STRIKE_RESET_HRS → strikes reset automatically
# At SSH_MAX_STRIKES → notify and return exit 2 so the caller can escalate
#
# ── STRIKE SYSTEM (--validate) ────────────────────────────────────────────────────────────────
# Called during partnership --check cycles to detect broken SSH auth.
# Tracks consecutive SSH auth failures — not network unreachability.
# Remote Tailscale IP unreachable = network issue → not counted as SSH strike.
# Remote reachable but SSH auth fails = key issue → strike incremented.
# Strikes reset automatically after SSH_STRIKE_RESET_HRS of clean connectivity.
# At SSH_MAX_STRIKES: notify + return exit 2 (caller can escalate).
# State: DATA_DIR/ssh_strikes_{REMOTE_SERVER_NAME}.db
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# SSH_MAX_STRIKES — consecutive failures before notifying (default 5)
# SSH_STRIKE_RESET_HRS — hours since last failure before counter resets (default 24)
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Distinguish Unreachable From Unauthorised
# The strike system counts SSH auth failures only. A partner that is simply offline is a
# network condition, and counting it would fire a key-rotation alarm every time the remote
# reboots. Only "I can reach you but you will not let me in" is a key problem.
#
# Idempotent by Default, Destructive Only on Request
# A bare run never replaces an existing key. Regenerating invalidates every authorized_keys
# entry the old key was in — including on hosts this script is not talking to right now —
# so it requires --force explicitly.
#
# Verify Before Recording
# The key path is written into host*.conf only after authentication has been proven to
# work. Recording a key that does not authenticate would leave every downstream script
# pointed at a credential that silently fails.
#
# Strikes Reset on Recovery
# Counters clear themselves after a period of clean connectivity, so a transient outage
# does not accumulate toward an alarm across unrelated weeks.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Reads and writes /root/.ssh and installs keys on the remote as root.
#
# Lock Acquisition
# acquire_lock prevents concurrent runs. Two instances generating or copying keys at once
# could leave authorized_keys holding a key whose private half was already replaced.
#
# Host Detection
# detect_hosts() resolves MY_ID / REMOTE_ID for key naming and remote targeting.
#
# Existing Key Protection
# Generation is skipped when a key is present. Overwriting requires --force.
#
# Auth Verified Before Conf Write
# host*.conf is updated only after a successful authentication test.
#
# Network-vs-Auth Discrimination
# Unreachable remotes never increment the strike counter — see Design Principles.
#
# Strike Ceiling
# SSH_MAX_STRIKES bounds how long a genuinely broken key goes unreported, and exit 2 lets
# the caller decide whether that is escalation-worthy.
#
# Local-Only Escape Hatch
# --local-only generates the key without touching the remote, for onboarding a partner
# that is not reachable yet.
#
# Dry Run Support
# --dry-run previews generation, copy and conf update without performing any.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# SSH_MAX_STRIKES
# Consecutive SSH auth failures before notifying (default: 5)
#
# SSH_STRIKE_RESET_HRS
# Hours of clean connectivity before the strike counter resets (default: 24)
#
# host*.conf
#
# HOST*_SSH_KEY
# Written by this script on success. Read by rsync.sh, conf_sync.sh, fallback.sh and
# upgrade_webhook_handler.sh — every cross-host operation depends on it.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# ssh_setup.sh
# Initial setup — generate if missing, copy to remote, update conf. Idempotent.
#
# ssh_setup.sh --force
# Regenerate the key even if one exists, and re-copy to the remote.
#
# ssh_setup.sh --validate
# Health check with strike tracking. Exit 2 at the strike limit.
#
# ssh_setup.sh --status
# Show key state, fingerprint, and remote connectivity. Then exit.
#
# ssh_setup.sh --local-only
# Generate the key locally and skip the remote copy.
#
# ssh_setup.sh --dry-run / --log
# Supported by every mode above.
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# Partnership/ssh_setup.sh — initial setup (idempotent)
# Partnership/ssh_setup.sh --force — regenerate + re-copy
# Partnership/ssh_setup.sh --validate — health check + strike tracking
# Partnership/ssh_setup.sh --status — show key and connectivity state
# Partnership/ssh_setup.sh --local-only — generate key locally, skip remote copy
# Any mode supports --dry-run and --log
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -74,9 +177,10 @@ acquire_lock
detect_hosts
# ── Derive key name from hostname ─────────────────────────────────────────────────────────────
# Strip unraid- prefix (case-insensitive) if present → gmer4lfe_rsync_automation
SHORT_NAME="${LOCAL_SERVER_NAME,,}"
[[ "${SHORT_NAME}" == unraid-* ]] && SHORT_NAME="${SHORT_NAME:7}"
# derive_short_name() title-cases the result (for display elsewhere) — lowercase it here,
# same as before, since this feeds a filename → gmer4lfe_rsync_automation
SHORT_NAME="$(derive_short_name "$LOCAL_SERVER_NAME")"
SHORT_NAME="${SHORT_NAME,,}"
SSH_KEY_NAME="${SHORT_NAME}_rsync_automation"
SSH_KEY_PATH="/root/.ssh/${SSH_KEY_NAME}"
SSH_PUB_PATH="${SSH_KEY_PATH}.pub"
@@ -351,6 +455,22 @@ else
if ssh-copy-id -i "$SSH_PUB_PATH" -o ConnectTimeout="${SSH_TIMEOUT:-15}" \
root@"$REMOTE_SERVER" 2>/dev/null; then
echo "Public key installed on $REMOTE_SERVER_NAME"
# Tighten the file ssh-copy-id just wrote, or the key it installed is inert.
#
# On Unraid /root/.ssh is a symlink to /boot/config/ssh/root, and a file created there
# comes out 0666. sshd runs StrictModes by default and refuses to read a group- or
# world-writable authorized_keys — it logs "Authentication refused: bad ownership or
# modes" and falls through to password auth, which BatchMode then declines.
#
# The failure is silent from this end: ssh-copy-id succeeds and reports success, the key
# is present and byte-correct, and every later connection is still refused. Observed
# exactly that — key installed at 07:15:12, onboard refused at 07:15:43, blobs identical.
ssh -o ConnectTimeout="${SSH_TIMEOUT:-15}" -o BatchMode=yes -o StrictHostKeyChecking=no \
-i "$SSH_KEY_PATH" root@"$REMOTE_SERVER" \
'chmod 700 "$(dirname "$(readlink -f ~/.ssh/authorized_keys)")" 2>/dev/null
chmod 600 "$(readlink -f ~/.ssh/authorized_keys)" 2>/dev/null' 2>/dev/null \
|| warn "Could not tighten authorized_keys on $REMOTE_SERVER_NAME — if auth is refused, chmod 600 it there"
else
error "ssh-copy-id failed — check that:"
error " 1. Remote server is reachable: tailscale status"
+63 -42
View File
@@ -9,26 +9,20 @@ For folder overview see `README-Plugin.md`. For web app logic see the headers in
## ━━━ FIRST-TIME INSTALL ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### Prerequisites
Not yet published to Community Applications — this is a niche, self-hosted install for now.
- Repo cloned onto the target Unraid server
- `/boot/config/plugins/varaverk/varaverk.plg` exists on flash (see PLG SETUP below)
### Install via the Unraid UI (the actual install path)
### Steps
**Plugins → Install Plugin → Enter URL of remote plugin file**, paste the raw `.plg` URL:
```bash
cd Plugin/
./plugin_setup.sh
```
- Your own servers (Gitea): `https://git.gmer4lfe.com/FailedProxy/Varaverk/raw/branch/main/Plugin/varaverk.plg`
- Public/anyone else (GitHub): `https://raw.githubusercontent.com/FailedProxy/Varaverk/main/Plugin/varaverk.plg`
This creates:
```
/usr/local/emhttp/plugins/varaverk → Plugin/unraid/ (symlink)
```
Changes to any file under `Plugin/unraid/` take effect immediately in the browser —
no restart, no reinstall.
Unraid downloads the `.plg`, saves it to `/boot/config/plugins/varaverk.plg` itself
(nothing to copy by hand), then runs its `<FILE>` steps: clones the repo onto flash
(Gitea → Tailscale peer → `GITEA_DOMAIN` → GitHub fallback chain — same install script
either way, so both URLs above end up in the same place), then symlinks the web files.
See `varaverk.plg`'s inline comments for the exact clone/fallback logic.
### Verify
@@ -42,25 +36,13 @@ Go to **Settings → Other Settings** — a Varaverk tile should also appear the
The `.plg` file registers Varaverk with Unraid's plugin system. It enables the cron
mechanism (`update_cron`) and makes the plugin appear on the **Plugins** management page.
Create it once on flash — it persists across reboots:
`Plugin/varaverk.plg` is tracked in the repo — it's a real plugin descriptor (version,
GitHub links, changelog) maintained by `build.sh`, not hand-authored. Normally you never
touch flash directly — installing via the URL above (or the update button on the Plugins
page, once installed) handles placing/refreshing `/boot/config/plugins/varaverk.plg` for you.
```bash
mkdir -p /boot/config/plugins/varaverk
cat > /boot/config/plugins/varaverk.plg <<'EOF'
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN [
<!ENTITY name "varaverk">
<!ENTITY author "gmer4lfe">
<!ENTITY version "2026.05.28">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" launch="varaverk/monitor" icon="/plugins/varaverk/icons/varaverk.png">
</PLUGIN>
EOF
```
The `.plg` has no packages and no remote URLs — it is local-only and is not published
to Community Applications.
`plugin_setup.sh` is a separate, optional dev convenience — see below — not part of the
install path above.
**Cron flow on every boot:**
1. `event/disks_mounted/rebuild_cron` fires
@@ -71,10 +53,28 @@ to Community Applications.
---
## ━━━ DEV CONVENIENCE: plugin_setup.sh ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Not part of the install path above — a manual, optional tool for pointing the running
web server directly at an arbitrary repo clone (e.g. a dev checkout that isn't at
`/boot/config/plugins/varaverk`), bypassing the fixed-path symlink the `.plg` recreates
every boot.
```bash
cd Plugin/
./plugin_setup.sh
```
This creates `/usr/local/emhttp/plugins/varaverk → Plugin/unraid/` (or `Plugin/<os>/`
for other platforms) pointing at wherever this repo currently lives, so edits show up
in the browser instantly. Re-run it any time the repo moves — see REPO MOVE PROCEDURE below.
---
## ━━━ SCRIPTS DIRECTORY SETTING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`SCRIPTS_DIR` is the only plugin-level setting. It tells the plugin where to find the
Configurations directory and all scripts.
`SCRIPTS_DIR` tells the plugin where to find the Configurations directory and all
repo scripts.
**Set it via:** Settings → Other Settings → Varaverk → Scripts directory
@@ -88,29 +88,50 @@ SCRIPTS_DIR="/boot/config/plugins/varaverk"
All other configuration lives in `Configurations/master.conf` and `Configurations/host*.conf`.
`CUSTOM_SCRIPTS_DIR` is a separate, optional override for where the Scheduler tab's
Custom Scripts feature reads/writes user-authored scripts (see Manual.md → Custom
Scripts). It's intentionally **not** under `SCRIPTS_DIR` — Custom Scripts are personal,
non-repo tooling and must never end up inside the git-tracked plugin folder.
Default: `/boot/config/plugins/user.scripts/Varaverk/Scripts`
```bash
# in varaverk.cfg, alongside SCRIPTS_DIR
CUSTOM_SCRIPTS_DIR="/boot/config/plugins/user.scripts/Varaverk/Scripts"
```
---
## ━━━ REPO MOVE PROCEDURE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
If the repo is cloned to a new path:
If a dev checkout using `plugin_setup.sh` (see above) is moved to a new path:
1. Re-run `plugin_setup.sh` — removes the stale symlink and creates a new one pointing at the new path
2. Update `SCRIPTS_DIR` in Settings → Other Settings → Varaverk (or edit `varaverk.cfg` directly on flash)
The `.plg` on flash does not need to change — it has no path references.
This doesn't apply to a normal install (via the raw `.plg` URL) — that always lives at
the fixed path `/boot/config/plugins/varaverk`, there's no separate location to move.
---
## ━━━ UPDATING THE PLUGIN VERSION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
The version in `/boot/config/plugins/varaverk.plg` is cosmetic for a local plugin —
Unraid does not check it against anything remote. Update it when you want the Plugins
management page to reflect when the plugin was last changed:
`build.sh` rewrites the `version` and `sha256` entities in `Plugin/varaverk.plg` to match
each release build — don't hand-edit them. The `sha256`/packaged `.txz` aren't currently
consumed by anything (see the CHANGES entry in `varaverk.plg` — install is git-clone +
symlink, not package-based), so today this is mostly cosmetic version tracking:
```xml
<!ENTITY version "2026.05.28">
```bash
cd Plugin/
./build.sh # today's date as version
./build.sh 2026.09.01 # explicit version
```
Commit and push. The version shown on the Plugins page updates next time Unraid
re-fetches the `.plg` (the Plugins page's Update action, or a fresh install).
---
## ━━━ ADDING A NEW OS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+1 -1
View File
@@ -120,4 +120,4 @@ No other files need changing.
| `event/disks_mounted/rebuild_cron` | Fires on every boot — copies `.plg`, rebuilds cron |
| `event/disks_mounted/array_start_jobs` | Fires when array starts |
| `event/disks_unmounting/array_stop_jobs` | Fires when array stops |
| `/boot/config/plugins/varaverk.plg` | Registers the plugin with Unraid's plugin system (lives on flash, not in repo) |
| `Plugin/varaverk.plg` | Registers the plugin with Unraid's plugin system — tracked in-repo; installed via its raw URL through Plugins → Install Plugin (see Manual-Plugin.md), which Unraid saves to `/boot/config/plugins/varaverk.plg` itself |

Some files were not shown because too many files have changed in this diff Show More