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:
@@ -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
|
||||
# ==============================================================================================
|
||||
#
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ==============================================================================================
|
||||
#
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# Goal: 0–5 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
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# Goal: 0–5 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
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# Goal: 0–3 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
|
||||
|
||||
@@ -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
|
||||
# ==============================================================================================
|
||||
#
|
||||
|
||||
@@ -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
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
@@ -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
|
||||
# ==============================================================================================
|
||||
#
|
||||
|
||||
@@ -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
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user