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.
This commit is contained in:
Gmer4Lfe
2026-08-01 20:37:59 -04:00
parent cdce877601
commit e8b114094a
78 changed files with 3301 additions and 277 deletions
+23
View File
@@ -30,6 +30,29 @@
# 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
# ==============================================================================================
#
+93
View File
@@ -62,6 +62,94 @@
# 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
# ==============================================================================================
#
@@ -174,6 +262,11 @@ if [[ -z "$FFPROBE_CONTAINER" || -z "$FFPROBE_BIN" ]]; then
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"
+105 -17
View File
@@ -39,7 +39,8 @@
# 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 safeguard 6
# 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
#
@@ -47,27 +48,114 @@
# 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
# ==============================================================================================
#
# 1. DOWNLOAD_ORPHAN_CLEANER_ENABLED master toggle
# 2. Download dir must exist and live under /mnt/ — refuses to walk anything else
# 3. Queue fetch must succeed (see above)
# 4. Age gate — nothing under DOWNLOAD_ORPHAN_AGE days is touched
# 5. Deletion only for JUNK, parse-verified REDUNDANT, and UNMATCHED — never for
# IMPORTABLE or anything a guard has held
# 6. UNMATCHED deletes require the arr to report a non-empty library. An empty or
# restoring database answers every parse with "no match", which would condemn the
# entire download dir; the library is queried directly rather than inferred from the
# run's own matches, since a small batch that is legitimately all-unmatched is normal
# once daily runs have caught up and would otherwise read as a broken database
# 7. Run total over DOWNLOAD_ORPHAN_MAX_DELETE_GB aborts the delete pass and notifies —
# a queue fetch that returned partial data would classify live downloads as orphans,
# and an abnormally large delete total is the visible symptom of exactly that
# (--i-know-what-im-doing overrides, e.g. for a first run against a known backlog)
# 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.
#
# ==============================================================================================
# USAGE
# 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)
#
# DOWNLOAD_ORPHAN_MAX_DELETE_GB
# Abort the delete pass if the run total exceeds this (default: 100)
#
# 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
+90 -13
View File
@@ -20,22 +20,91 @@
# 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 required — required for consistency across Arrs_Stack/, no direct fs writes here
# acquire_lock "wait" — waits for a prior run to finish rather than skipping or colliding;
# a full-library rescan across three arrs can run long, worth queuing
# behind rather than silently no-op'ing
# Reachability check — check_api before touching an arr; unreachable → skip that arr only
# Active-rescan check — skips triggering a NEW rescan if one's already active on that arr
# (manual trigger, another script) — never stacks a duplicate scan,
# see Tools/arr_rescan_monitor.sh for catching up that arr's cache
# once the pre-existing scan finishes instead of waiting for next week
# Sequential only — never runs two arrs' rescans in parallel; each is a heavy full-disk
# walk and concurrent walks would just contend for the same disk I/O
# Per-arr isolation — one arr failing, timing out, or being skipped never blocks the others
# --dry-run mode — reports which arrs would be rescanned, triggers nothing
# 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
@@ -72,6 +141,14 @@ if [[ "$EUID" -ne 0 ]]; then
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
-1
View File
@@ -88,7 +88,6 @@
# 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)
# 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
#
# ==============================================================================================
@@ -52,6 +52,56 @@
# 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
# ==============================================================================================
#
+19 -4
View File
@@ -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)
@@ -58,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
+21 -5
View File
@@ -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)
@@ -56,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
+22 -5
View File
@@ -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
@@ -63,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
+99 -6
View File
@@ -15,8 +15,11 @@
# 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. No files are moved and no Radarr API writes happen — this is a detection
# tool. Every rule below was validated against this library's real data before being
# 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).
@@ -52,13 +55,38 @@
# 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, Don't Act
# This script never calls Radarr's write API and never touches a file. Every finding is
# a candidate for a human decision — moving media and re-pointing Radarr's tracking is a
# separate, deliberate follow-up action, not something this scan does automatically.
# 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
@@ -72,6 +100,71 @@
# 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
# ==============================================================================================
#
-1
View File
@@ -98,7 +98,6 @@
# 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
# platform_require_cmd — notify script validated before use
# Silent by default — orphans/junk warn(), clean library logs silently
#
# ==============================================================================================
+71 -1
View File
@@ -61,9 +61,15 @@
# no Sonarr equivalent for the same reason.
#
# ==============================================================================================
# MOVE MODE (--move)
# 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
@@ -93,6 +99,70 @@
# 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
# ==============================================================================================
#
-1
View File
@@ -92,7 +92,6 @@
# 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
# platform_require_cmd — notify script validated before use
# Silent by default — orphans/junk warn(), clean library logs silently
#
# ==============================================================================================
+65 -7
View File
@@ -67,13 +67,38 @@
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# WEBHOOK_PORT=0 gate — exits cleanly before any setup if the listener is disabled
# acquire_lock "continuous" — exits 0 cleanly if a healthy instance is already running,
# instead of relaunching into a port conflict
# 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
@@ -104,11 +129,25 @@ 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.
@@ -116,10 +155,29 @@ 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
+80 -7
View File
@@ -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