Move arr stack scripts from Media/ to Arrs_Stack/

Media/ now holds only media-level scripts (cleaner, permissions, play_state_sync).
All arr management scripts (cleanup, discovery, sync, webhooks, release fixer) live in Arrs_Stack/.
This commit is contained in:
Gmer4Lfe
2026-06-27 18:39:33 -04:00
parent 99b2d1879b
commit b4bc9267e9
26 changed files with 88 additions and 88 deletions
+3 -3
View File
@@ -316,9 +316,9 @@ MEDIA_MAINTENANCE_JOBS=(
"Media/media_shares_permissions.sh" # 1. permissions — always first
"Media/media_cleaner.sh anime" # 2. junk removal — before orphan scan
"Media/media_cleaner.sh media" # 3.
"Media/lidarr_cleanup.sh" # 4. arr cleanup — after permissions + clean
"Media/sonarr_cleanup.sh" # 5.
"Media/radarr_cleanup.sh" # 6.
"Arrs_Stack/lidarr_cleanup.sh" # 4. arr cleanup — after permissions + clean
"Arrs_Stack/sonarr_cleanup.sh" # 5.
"Arrs_Stack/radarr_cleanup.sh" # 6.
)
```
-1032
View File
File diff suppressed because it is too large Load Diff
-477
View File
@@ -1,477 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ========================= Arrs Failed / Stalled Recovery =====================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Detect and recover failed imports and stalled downloads across Sonarr, Radarr,
# and Lidarr. Blocklists the bad release and triggers a re-search — hands-free
# overnight recovery.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Four problem types detected from the arr queue API:
# importFailed — downloaded but arr couldn't import the file
# importPending — downloaded, stuck waiting to import (will not self-resolve)
# error status — serious failure not covered by the above two states
# stalled — download stuck with no connections or no progress
#
# Never touches items with state "downloading" or "imported" — safe to run anytime.
# Items newer than ARR_IMPORT_RECOVERY_AGE are skipped — gives arr time to retry first.
#
# Per problem item (3-step response):
# 1. Blocklist the release — prevents re-grabbing the same bad release
# 2. Remove from queue — cleans up the failed item
# 3. Trigger new search — finds a different release automatically
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Hands-Free Recovery
# The script completes the full recovery cycle autonomously — blocklist, remove,
# re-search. No operator decision required. A failed import at midnight resolves
# itself before morning without any intervention.
#
# Age Gate Before Action
# Items newer than ARR_IMPORT_RECOVERY_AGE are skipped. Arrs have their own
# retry logic — acting immediately would race against it. The age gate gives
# the arr time to self-resolve before this script escalates.
#
# Blocklist First
# The bad release is blocklisted before removal and re-search. Without this,
# the re-search can re-grab the same release that just failed.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# acquire_lock — prevents concurrent runs overlapping
# jq validation — exits if jq not installed (required for JSON parsing)
# API pre-flight — checks each arr is reachable before querying queue
# Version check — check_arr_version() verifies running arr matches master.conf major
# version; exits rather than silently misoperating after upgrade
# Age threshold — skips items newer than ARR_IMPORT_RECOVERY_AGE (default 6hr)
# Silent by default — only problems produce output, clean arrs stay silent
#
# API version mapping (endpoint paths differ from major version labels):
# Sonarr v4 → /api/v3/ (v3 endpoint retained in v4)
# Radarr v6 → /api/v3/ (v3 endpoint retained in v6)
# Lidarr v3 → /api/v1/ (different from Sonarr/Radarr)
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# ARR_RECOVERY_STATS — stats file written after each run (read by coffee report)
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_SONARR_URL / HOST*_SONARR_API_KEY / HOST*_SONARR_RECOVERY
# HOST*_RADARR_URL / HOST*_RADARR_API_KEY / HOST*_RADARR_RECOVERY
# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY / HOST1_LIDARR_RECOVERY
# All aliased by detect_hosts() — script uses unprefixed names
#
# master.conf
#
# ARR_IMPORT_RECOVERY_AGE — hours before item is eligible for recovery (default: 6)
# SONARR_VERSION_MAJOR — expected Sonarr major version (e.g. 4)
# RADARR_VERSION_MAJOR — expected Radarr major version (e.g. 6)
# LIDARR_VERSION_MAJOR — expected Lidarr major version (e.g. 3)
# ARR_RECOVERY_STATS — stats file path (read by coffee report)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# arrs_failed_stalled_recovery.sh — normal run
# arrs_failed_stalled_recovery.sh --dry-run — show what would be actioned, no changes
# arrs_failed_stalled_recovery.sh --log — verbose output
# arrs_failed_stalled_recovery.sh --status — show config and exit
#
# Recommended schedule: 0 5 * * * (5am daily)
# Or every 6hr: 0 */6 * * * (matches ARR_IMPORT_RECOVERY_AGE default)
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
# detect_hosts() sets MY_ID and aliases SONARR_*, RADARR_*, LIDARR_* vars
detect_hosts
# jq is required — not optional — for JSON parsing
if ! command -v jq >/dev/null 2>&1; then
error "jq is not installed — required for arr API JSON parsing"
error "Install: apt-get install jq or brew install jq"
notify "arrs_failed_stalled_recovery failed on $(hostname) — jq not installed" \
"Arr Recovery" "warning"
exit 1
fi
log "jq found"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no items will be blocklisted or searched"
# Age threshold in seconds
AGE_THRESHOLD_SECONDS=$(( ARR_IMPORT_RECOVERY_AGE * 3600 ))
log "$ICON_GEAR Config: age-threshold=${ARR_IMPORT_RECOVERY_AGE}hr sonarr-v${SONARR_VERSION_MAJOR} radarr-v${RADARR_VERSION_MAJOR} lidarr-v${LIDARR_VERSION_MAJOR:-?}"
TOTAL_ACTIONED=0
TOTAL_SKIPPED=0
ARR_SUMMARIES=()
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Sonarr: ${SONARR_URL:-not configured} (recovery: ${SONARR_RECOVERY:-true})"
echo "$ICON_SYNC Radarr: ${RADARR_URL:-not configured} (recovery: ${RADARR_RECOVERY:-true})"
echo "$ICON_SYNC Lidarr: ${LIDARR_URL:-not configured on this host} (recovery: ${LIDARR_RECOVERY:-false})"
echo "$ICON_TIME Age thresh: ${ARR_IMPORT_RECOVERY_AGE}hr"
echo "$ICON_GEAR Sonarr ver: v${SONARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Radarr ver: v${RADARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Lidarr ver: v${LIDARR_VERSION_MAJOR} expected"
echo "$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${MY_DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Check if a queue item is older than ARR_IMPORT_RECOVERY_AGE
# Returns 0 (old enough) or 1 (too new — skip)
item_is_old_enough() {
local added="$1"
[[ -z "$added" ]] && return 0 # no date = treat as old enough, safe to act
local added_epoch
added_epoch=$(date -d "$added" +%s 2>/dev/null) || return 0
local age_seconds=$(( $(date +%s) - added_epoch ))
[[ "$age_seconds" -ge "$AGE_THRESHOLD_SECONDS" ]]
}
# Query the arr queue API and return all records
# Args: url, api_key, api_version
get_queue_data() {
local url="$1" api_key="$2" api_version="$3"
curl -sf --max-time 15 \
-H "X-Api-Key: $api_key" \
"${url}/api/${api_version}/queue?page=1&pageSize=200&includeUnknownSeriesItems=true&includeUnknownArtistItems=true" \
2>/dev/null
}
# Blocklist and remove a queue item
# Args: url, api_key, api_version, queue_id
blocklist_item() {
local url="$1" api_key="$2" api_version="$3" queue_id="$4"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would blocklist queue item $queue_id"
return 0
fi
curl -sf --max-time 15 \
-X DELETE \
-H "X-Api-Key: $api_key" \
"${url}/api/${api_version}/queue/${queue_id}?removeFromClient=true&blocklist=true&skipRedownload=false" \
>/dev/null 2>&1
}
# Trigger a new search for the media item
# Args: url, api_key, api_version, arr_type, media_id
trigger_search() {
local url="$1" api_key="$2" api_version="$3" arr_type="$4" media_id="$5"
local command body
case "$arr_type" in
sonarr) command="EpisodeSearch"; body="{\"name\":\"EpisodeSearch\",\"episodeIds\":[$media_id]}" ;;
radarr) command="MoviesSearch"; body="{\"name\":\"MoviesSearch\",\"movieIds\":[$media_id]}" ;;
lidarr) command="AlbumSearch"; body="{\"name\":\"AlbumSearch\",\"albumIds\":[$media_id]}" ;;
esac
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would trigger $command for media ID $media_id"
return 0
fi
curl -sf --max-time 15 \
-X POST \
-H "X-Api-Key: $api_key" \
-H "Content-Type: application/json" \
-d "$body" \
"${url}/api/${api_version}/command" \
>/dev/null 2>&1
}
# ==============================================================================================
# ── PROCESS AN ARR ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Args: display_name, arr_type, url, api_key, api_version, enabled,
# version_major, version_api_prefix
#
# Exits cleanly if disabled.
# Checks API reachability and version before touching queue.
# Processes each problem item: blocklist + trigger new search.
# Silent when clean — only warns when problems found or actioned.
process_arr() {
local arr_name="$1"
local arr_type="$2"
local url="$3"
local api_key="$4"
local api_version="$5"
local enabled="$6"
local version_major="$7"
local version_api_prefix="$8"
local actioned=0 skipped_new=0
echo ""
echo "━━━ $ICON_SYNC $arr_name ━━━"
# Disabled — skip cleanly
if [[ "$enabled" != "true" ]]; then
log "$arr_name recovery disabled — skipping"
ARR_SUMMARIES+=("$arr_name: disabled")
return
fi
# URL not configured on this host — skip cleanly
if [[ -z "$url" ]]; then
log "$arr_name not configured on $MY_ID — skipping"
ARR_SUMMARIES+=("$arr_name: not configured on $MY_ID")
return
fi
# API reachability
if ! check_api "$url" "$arr_name" 10; then
warn "$arr_name API unreachable — skipping"
ARR_SUMMARIES+=("$arr_name: unreachable")
return
fi
# Version check — exit if API structure may have changed
if ! check_arr_version "$url" "$api_key" "$version_api_prefix" \
"$version_major" "$arr_name"; then
ARR_SUMMARIES+=("$arr_name: version mismatch — skipped")
return
fi
# Fetch queue
local queue_data
queue_data=$(get_queue_data "$url" "$api_key" "$api_version")
if [[ -z "$queue_data" ]]; then
warn "$arr_name — could not retrieve queue data"
ARR_SUMMARIES+=("$arr_name: queue fetch failed")
return
fi
local total_records
total_records=$(echo "$queue_data" | jq '.totalRecords // 0' 2>/dev/null)
log "$arr_name queue: $total_records total items"
# Filter for problem items — never touch downloading or imported
local problem_items
problem_items=$(echo "$queue_data" | jq -c '
.records // [] |
.[] |
select(
.trackedDownloadState != "downloading" and
.trackedDownloadState != "imported" and
(
.trackedDownloadState == "importFailed" or
.trackedDownloadState == "importPending" or
.trackedDownloadStatus == "error" or
(.status == "warning" and (
(.errorMessage // "" | ascii_downcase | contains("stalled")) or
(.statusMessages // [] | .[] | .messages // [] | .[] |
ascii_downcase | contains("stalled"))
))
)
)
' 2>/dev/null)
if [[ -z "$problem_items" ]]; then
echo "$arr_name — clean ✅ no failed imports or stalled downloads"
ARR_SUMMARIES+=("$arr_name: clean ✅")
return
fi
local problem_count
problem_count=$(echo "$problem_items" | wc -l)
warn "$arr_name — found $problem_count problem item(s)"
# Process each problem item
while IFS= read -r item; do
[[ -z "$item" ]] && continue
local queue_id title added tracked_state tracked_status problem_type media_id
queue_id=$(echo "$item" | jq -r '.id // empty' 2>/dev/null)
title=$(echo "$item" | jq -r '.title // "Unknown"' 2>/dev/null)
added=$(echo "$item" | jq -r '.added // empty' 2>/dev/null)
tracked_state=$(echo "$item" | jq -r '.trackedDownloadState // ""' 2>/dev/null)
tracked_status=$(echo "$item" | jq -r '.trackedDownloadStatus // ""' 2>/dev/null)
# Human-readable problem type
case "$tracked_state" in
importFailed) problem_type="import failed" ;;
importPending) problem_type="import pending/stuck" ;;
*)
[[ "$tracked_status" == "error" ]] && \
problem_type="error" || problem_type="stalled"
;;
esac
# Media ID for search trigger
case "$arr_type" in
sonarr) media_id=$(echo "$item" | jq -r '.episodeId // .episode.id // empty' 2>/dev/null) ;;
radarr) media_id=$(echo "$item" | jq -r '.movieId // .movie.id // empty' 2>/dev/null) ;;
lidarr) media_id=$(echo "$item" | jq -r '.albumId // .album.id // empty' 2>/dev/null) ;;
esac
[[ -z "$queue_id" ]] && continue
# Age check — skip items that are too new to have self-resolved
if ! item_is_old_enough "$added"; then
log " Skipping (too new < ${ARR_IMPORT_RECOVERY_AGE}hr): $title"
(( skipped_new++ ))
(( TOTAL_SKIPPED++ ))
continue
fi
warn " $ICON_TRASH $problem_type$title"
# Step 1: Blocklist + remove from queue
if ! blocklist_item "$url" "$api_key" "$api_version" "$queue_id"; then
warn " Failed to blocklist: $title"
(( TOTAL_SKIPPED++ ))
continue
fi
log " Blocklisted: $queue_id"
# Step 2: Trigger new search
if [[ -n "$media_id" ]]; then
if trigger_search "$url" "$api_key" "$api_version" "$arr_type" "$media_id"; then
log " New search triggered: $title"
else
warn " Blocklisted but search trigger failed: $title"
fi
else
warn " Blocklisted but no media ID found — search not triggered: $title"
fi
(( actioned++ ))
(( TOTAL_ACTIONED++ ))
done <<< "$problem_items"
if [[ "$actioned" -gt 0 ]]; then
warn "$arr_name — actioned: $actioned | skipped (too new): $skipped_new"
else
log "$arr_name — nothing actioned | skipped (too new): $skipped_new"
fi
ARR_SUMMARIES+=("$arr_name: actioned $actioned | too new $skipped_new")
}
# ==============================================================================================
# ━━━ Process Each Arr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Arrs Failed/Stalled Recovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)"
log "Age threshold: ${ARR_IMPORT_RECOVERY_AGE}hr"
START=$(date +%s)
# Sonarr — uses aliased vars set by detect_hosts()
process_arr \
"Sonarr" \
"sonarr" \
"${SONARR_URL:-}" \
"${SONARR_API_KEY:-}" \
"v3" \
"${SONARR_RECOVERY:-true}" \
"${SONARR_VERSION_MAJOR:-4}" \
"v3"
# Radarr — uses aliased vars set by detect_hosts()
process_arr \
"Radarr" \
"radarr" \
"${RADARR_URL:-}" \
"${RADARR_API_KEY:-}" \
"v3" \
"${RADARR_RECOVERY:-true}" \
"${RADARR_VERSION_MAJOR:-6}" \
"v3"
# Lidarr — HOST1 only, LIDARR_URL empty on HOST2 → exits cleanly via "not configured" guard
process_arr \
"Lidarr" \
"lidarr" \
"${LIDARR_URL:-}" \
"${LIDARR_API_KEY:-}" \
"v1" \
"${LIDARR_RECOVERY:-false}" \
"${LIDARR_VERSION_MAJOR:-3}" \
"v1"
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY ARR RECOVERY SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "$ICON_TRASH Actioned: $TOTAL_ACTIONED items blocklisted + searched"
echo "$ICON_SKIP Skipped: $TOTAL_SKIPPED items (too new)"
echo ""
for summary in "${ARR_SUMMARIES[@]}"; do
echo " $ICON_SUMMARY $summary"
done
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$TOTAL_ACTIONED" -gt 0 ]]; then
warn "$ICON_DONE Done — $TOTAL_ACTIONED item(s) blocklisted and re-searched"
notify "Arr recovery on $(hostname)$TOTAL_ACTIONED item(s) blocklisted and re-searched" \
"Arr Recovery" "warning"
else
echo "$ICON_DONE Done — nothing to recover (all arrs clean)"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Write stats for sunday_morning_coffee_report.sh
if [[ "$DRY_RUN" == false ]] && [[ -n "${ARR_RECOVERY_STATS:-}" ]]; then
echo "$(date '+%Y-%m-%d')|$(date '+%H:%M')|${TOTAL_ACTIONED}|${TOTAL_SKIPPED}" \
>> "$ARR_RECOVERY_STATS" 2>/dev/null || true
fi
exit 0
-635
View File
@@ -1,635 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ================================= Lidarr Cleanup =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Delete orphaned music files not tracked by Lidarr. Queries the API for all
# tracked file paths, walks the library on disk, and removes anything untracked
# that is old enough to be past the import window. Triggers an Emby library
# clean after runs where files were deleted so ghost entries disappear immediately.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Every file encountered on disk is classified into one of five categories:
#
# TRACKED — Lidarr API knows this exact path → leave it alone
# PROTECTED — matches LIDARR_PROTECTED_PATTERNS → never delete
# ORPHAN — music file, not tracked, older than LIDARR_ORPHAN_AGE → delete
# JUNK — not a music extension, not protected → delete regardless of age
# RECENT — not tracked, under LIDARR_ORPHAN_AGE → skip (may be mid-import)
#
# Lidarr generates cover art (*.jpg), metadata (*.nfo), and lyrics (*.lrc) but
# does NOT include these in its tracked file API response. Without PROTECTED
# classification these would be deleted — breaking Lidarr and Emby display.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# API as Ground Truth
# What Lidarr tracks is authoritative. Files not in the API response are
# orphans — Lidarr has no record of them and they serve no purpose.
# The script never infers ownership from directory structure alone.
#
# Age Gate Before Deletion
# Files under LIDARR_ORPHAN_AGE are left alone regardless of tracked status.
# Lidarr's import pipeline writes files before registering them — acting
# immediately would delete files mid-import.
#
# Seven-Gate Safety Model
# Multiple independent sanity checks must all pass before any file is touched.
# No single check is trusted in isolation — a misconfigured path returning an
# empty API response must not result in a wiped library.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Seven gates — ALL must pass before any file is touched:
# 1. Container running and not starting/unhealthy
# 2. API reachable
# 3. API version matches LIDARR_VERSION_MAJOR in master.conf
# 4. Artist count > 0
# 5. Tracked file count > 0
# 6. Tracked count >= LIDARR_MIN_TRACKED_PCT % of last known count
# 7. Deletion size < LIDARR_MAX_DELETE_GB — or --i-know-what-im-doing required
#
# acquire_lock "wait" — large scans take time, wait for previous run to finish
# jq + curl validation — exits if either tool missing
# DOCKER_TIMEOUT — container checks protected against daemon hangs
# 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
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# LIDARR_TRACKED_COUNT_FILE — persistent baseline for the tracked % safety check (gate 6)
# Updated after each successful run. Protects against misconfigured root path
# returning an empty API response and deleting the entire library.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY / HOST1_LIDARR_MUSIC_ROOT
# HOST1_LIDARR_PATH_MAP — container path → host path translation
# All aliased by detect_hosts() — script uses unprefixed names
#
# master.conf
#
# LIDARR_ORPHAN_AGE — days before untracked file is eligible for deletion
# LIDARR_MAX_DELETE_GB — require --i-know-what-im-doing above this
# LIDARR_MIN_TRACKED_PCT — abort if tracked count drops below this % of last run
# LIDARR_TRACKED_COUNT_FILE — persistent baseline file path
# LIDARR_EXTENSIONS — music file extensions for orphan classification
# LIDARR_PROTECTED_PATTERNS — file patterns that are never deleted
# LIDARR_VERSION_MAJOR — expected Lidarr major version for API safety check
# LIDARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)
# LIDARR_LOCK_WARN_AGE — override default lock warning age (large libraries)
# ARR_CLEANUP_STATS — stats file path (read by coffee report)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# lidarr_cleanup.sh — normal run
# lidarr_cleanup.sh --dry-run — preview, no deletions
# lidarr_cleanup.sh --log — verbose output
# lidarr_cleanup.sh --status — show config and exit
# lidarr_cleanup.sh --i-know-what-im-doing — bypass size threshold
# lidarr_cleanup.sh --i-know-what-im-doing --skip-age-check — NUCLEAR MODE
#
# NUCLEAR MODE: both flags bypass age check AND size threshold. Use when Soularr
# has filled the gaps and you want a clean one-pass wipe. User accepts full
# responsibility — the flag name is long and annoying by design.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Special flag pre-processing ───────────────────────────────────────────────────────────────
# Filter --i-know-what-im-doing and --skip-age-check before parse_args
# to avoid unknown flag errors — both are handled separately below.
I_KNOW=false
SKIP_AGE_CHECK=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--i-know-what-im-doing) I_KNOW=true ;;
--skip-age-check) SKIP_AGE_CHECK=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────
if [[ "$I_KNOW" == true ]] && [[ "$SKIP_AGE_CHECK" == true ]] && [[ "$DRY_RUN" != true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ WARNING — NUCLEAR MODE ACTIVE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Flags: --i-know-what-im-doing --skip-age-check"
echo " Age check: BYPASSED — deletes on first pass"
echo " Size threshold: BYPASSED — no GB limit"
echo " Data recovery: NOT POSSIBLE after deletion"
echo ""
echo " Review --dry-run output before proceeding."
echo " You have 10 seconds to cancel (Ctrl+C)..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
sleep 10
echo " Proceeding..."
echo ""
fi
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
# Tool validation — both required, fail fast
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for Lidarr API calls"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
notify "Lidarr cleanup failed on $(hostname) — jq not installed" "Lidarr Cleanup" "warning"
exit 1
fi
# Lock before detect_hosts — large library scans take time, wait mode appropriate
[[ -n "${LIDARR_LOCK_WARN_AGE:-}" ]] && LOCK_WARN_AGE="$LIDARR_LOCK_WARN_AGE"
acquire_lock "wait"
TMP_DIR="/tmp/lidarr_cleanup_$$"
mkdir -p "$TMP_DIR"
trap "_release_all_locks; rm -rf $TMP_DIR" EXIT
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID and aliases LIDARR_URL, LIDARR_API_KEY, LIDARR_MUSIC_ROOT
detect_hosts
# Skip if Lidarr is not configured on this host
if [[ -z "${LIDARR_URL:-}" ]] || [[ -z "${LIDARR_API_KEY:-}" ]]; then
info "Lidarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
exit 0
fi
DOCKER_TIMEOUT=15
LIDARR_CONTAINER="Lidarr" # container name on HOST1
# Build path map from MY_ID's Lidarr path map
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_LIDARR_PATH_MAP"
eval "for key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$key\"]=\"\${${local_path_map_var}[\$key]}\"
done"
# Validate required vars — detect_hosts() should have set these
require_var LIDARR_URL
require_var LIDARR_API_KEY
require_var LIDARR_MUSIC_ROOT
if [[ ! -d "$LIDARR_MUSIC_ROOT" ]]; then
error "Music root not found: $LIDARR_MUSIC_ROOT"
notify "Lidarr cleanup failed on $(hostname) — music root not found: $LIDARR_MUSIC_ROOT" \
"Lidarr Cleanup" "warning"
exit 1
fi
log "$ICON_GEAR Config: url=${LIDARR_URL} root=${LIDARR_MUSIC_ROOT}"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $LIDARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
[[ "$SKIP_AGE_CHECK" == true ]] && warn "OVERRIDE — --skip-age-check active — age check bypassed"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Lidarr URL: $LIDARR_URL"
echo "$ICON_GEAR Music root: $LIDARR_MUSIC_ROOT"
echo "$ICON_TIME Orphan age: ${LIDARR_ORPHAN_AGE} days"
echo "$ICON_GEAR Max delete: ${LIDARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)"
echo "$ICON_GEAR Min tracked %: ${LIDARR_MIN_TRACKED_PCT}%"
echo "$ICON_GEAR Lidarr ver: v${LIDARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Extensions: ${LIDARR_EXTENSIONS[*]}"
echo "$ICON_GEAR Protected patterns: ${LIDARR_PROTECTED_PATTERNS[*]}"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "$ICON_GEAR I know: $I_KNOW"
echo "$ICON_GEAR Skip age check: $SKIP_AGE_CHECK"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Safety Layer 1 — Container Health ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
CONTAINER_RUNNING=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Running}}' "$LIDARR_CONTAINER" 2>/dev/null)
if [[ "$CONTAINER_RUNNING" != "true" ]]; then
error "$LIDARR_CONTAINER is not running — aborting"
notify "Lidarr cleanup aborted on $(hostname) — container not running" \
"Lidarr Cleanup" "warning"
exit 1
fi
CONTAINER_HEALTH=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Health.Status}}' "$LIDARR_CONTAINER" 2>/dev/null)
case "$CONTAINER_HEALTH" in
healthy) info "$LIDARR_CONTAINER is healthy" ;;
"") info "$LIDARR_CONTAINER has no health check — proceeding" ;;
starting)
error "$LIDARR_CONTAINER is still starting — aborting"
notify "Lidarr cleanup aborted on $(hostname) — container still starting" \
"Lidarr Cleanup" "warning"
exit 1 ;;
unhealthy)
error "$LIDARR_CONTAINER is unhealthy — aborting"
notify "Lidarr cleanup aborted on $(hostname) — container unhealthy" \
"Lidarr Cleanup" "warning"
exit 1 ;;
*) warn "$LIDARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution" ;;
esac
info "Safety layer 1 passed — container healthy"
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Lidarr API call with HTTP status check
# Usage: lidarr_api "artist" | lidarr_api "trackFile?artistId=123"
lidarr_api() {
local endpoint="$1"
local response http_code body
response=$(curl -sf \
--max-time 30 \
-H "X-Api-Key: $LIDARR_API_KEY" \
-w "\n%{http_code}" \
"${LIDARR_URL}/api/v1/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "Lidarr API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
# Check if a file extension is a tracked music format
is_music_file() {
local ext="${1##*.}"
ext="${ext,,}"
for valid_ext in "${LIDARR_EXTENSIONS[@]}"; do
[[ "$ext" == "$valid_ext" ]] && return 0
done
return 1
}
# Check if a file matches any protected pattern
is_protected_file() {
local filename
filename=$(basename "$1")
for pattern in "${LIDARR_PROTECTED_PATTERNS[@]}"; do
# shellcheck disable=SC2254
case "$filename" in
$pattern) return 0 ;;
esac
done
return 1
}
# ==============================================================================================
# ━━━ Pre-flight: Lidarr Import Scan ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Pre-flight: Lidarr Import Scan ━━━"
# Reverse-lookup container path from path map so Lidarr gets its own path, not the host path
LIDARR_CONTAINER_ROOT=""
for _cp in "${!ARR_PATH_MAP[@]}"; do
if [[ "${ARR_PATH_MAP[$_cp]}" == "$LIDARR_MUSIC_ROOT" ]]; then
LIDARR_CONTAINER_ROOT="$_cp"
break
fi
done
unset _cp
if [[ -n "$LIDARR_CONTAINER_ROOT" ]]; then
info "Triggering DownloadedAlbumsScan on: $LIDARR_CONTAINER_ROOT"
SCAN_PAYLOAD="{\"name\": \"DownloadedAlbumsScan\", \"path\": \"$LIDARR_CONTAINER_ROOT\"}"
else
info "No path map match — triggering DownloadedAlbumsScan (all root folders)"
SCAN_PAYLOAD='{"name": "DownloadedAlbumsScan"}'
fi
SCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $LIDARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$SCAN_PAYLOAD" \
"${LIDARR_URL}/api/v1/command" 2>/dev/null)
SCAN_CMD_ID=$(echo "$SCAN_RESPONSE" | jq -r '.id // empty' 2>/dev/null)
if [[ -z "$SCAN_CMD_ID" ]]; then
warn "Could not trigger import scan — proceeding without pre-flight"
else
info "Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion..."
POLL_TIMEOUT=${LIDARR_IMPORT_SCAN_TIMEOUT:-600}
POLLED=0
while [[ "$POLLED" -lt "$POLL_TIMEOUT" ]]; do
SCAN_STATUS=$(curl -sf --max-time 10 \
-H "X-Api-Key: $LIDARR_API_KEY" \
"${LIDARR_URL}/api/v1/command/${SCAN_CMD_ID}" 2>/dev/null | \
jq -r '.status // empty' 2>/dev/null)
case "$SCAN_STATUS" in
completed) info "Import scan complete ✅"; break ;;
failed) warn "Import scan reported failed — proceeding anyway"; break ;;
esac
sleep 10
(( POLLED += 10 ))
[[ $(( POLLED % 60 )) -eq 0 ]] && log " Still scanning... (${POLLED}s elapsed)"
done
[[ "$POLLED" -ge "$POLL_TIMEOUT" ]] && \
warn "Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway"
fi
# ==============================================================================================
# ━━━ Fetch Lidarr Tracked Files ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Fetching Lidarr Tracked Files ━━━"
# Safety Layer 2 — API reachability
if ! check_api "$LIDARR_URL" "Lidarr" 10; then
notify "Lidarr cleanup aborted on $(hostname) — API unreachable" "Lidarr Cleanup" "warning"
exit 1
fi
# Safety Layer 3 — API version check
check_arr_version "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$LIDARR_VERSION_MAJOR" "Lidarr" || exit 1
info "Querying Lidarr API..."
# Fetch all artists
ARTIST_RESPONSE=$(lidarr_api "artist") || {
error "Failed to fetch artists from Lidarr"
notify "Lidarr cleanup failed on $(hostname) — could not fetch artists" \
"Lidarr Cleanup" "warning"
exit 1
}
ARTIST_IDS=$(echo "$ARTIST_RESPONSE" | jq -r '.[].id' 2>/dev/null)
ARTIST_COUNT=$(echo "$ARTIST_IDS" | grep -c "[0-9]" 2>/dev/null || echo 0)
# Safety Layer 4 — artist count > 0
if [[ "$ARTIST_COUNT" -eq 0 ]]; then
error "API returned 0 artists — aborting to prevent mass deletion"
notify "Lidarr cleanup aborted on $(hostname) — 0 artists returned" \
"Lidarr Cleanup" "warning"
exit 1
fi
info "Found $ARTIST_COUNT artists — fetching track files..."
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
> "$TRACKED_FILE"
while IFS= read -r artist_id; do
[[ -z "$artist_id" ]] && continue
ARTIST_TRACKS=$(lidarr_api "trackFile?artistId=${artist_id}" 2>/dev/null)
if [[ -n "$ARTIST_TRACKS" ]]; then
while IFS= read -r api_path; do
[[ -z "$api_path" ]] && continue
translate_path "$api_path" >> "$TRACKED_FILE"
done < <(echo "$ARTIST_TRACKS" | jq -r '.[].path' 2>/dev/null)
fi
done <<< "$ARTIST_IDS"
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
# Eliminates the main performance bottleneck for large libraries
declare -A TRACKED_MAP
while IFS= read -r _tracked_path; do
[[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1
done < "$TRACKED_FILE"
unset _tracked_path
info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths"
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
# Safety Layer 5 — tracked count > 0
if [[ "$TRACKED_COUNT" -eq 0 ]]; then
error "API returned 0 tracked files — aborting to prevent mass deletion"
notify "Lidarr cleanup aborted on $(hostname) — 0 tracked files returned" \
"Lidarr Cleanup" "warning"
exit 1
fi
info "$ARTIST_COUNT artists | $TRACKED_COUNT tracked files"
# Safety Layer 6 — percentage drop vs last known count
if [[ -f "$LIDARR_TRACKED_COUNT_FILE" ]]; then
LAST_COUNT=$(cat "$LIDARR_TRACKED_COUNT_FILE" 2>/dev/null || echo 0)
if [[ "$LAST_COUNT" -gt 0 ]]; then
PCT=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $LAST_COUNT) * 100}")
if [[ "$PCT" -lt "$LIDARR_MIN_TRACKED_PCT" ]]; then
error "Tracked count dropped to ${PCT}% of last run ($TRACKED_COUNT vs $LAST_COUNT)"
error "Suggests API issue — aborting to prevent mass deletion"
error "If expected (large library removal) delete: $LIDARR_TRACKED_COUNT_FILE"
notify "Lidarr cleanup aborted on $(hostname) — tracked count dropped to ${PCT}%" \
"Lidarr Cleanup" "warning"
exit 1
fi
info "Tracked count: ${PCT}% of last run ($TRACKED_COUNT vs $LAST_COUNT) ✅"
fi
else
info "No previous count on record — first run, saving baseline"
fi
echo "$TRACKED_COUNT" > "$LIDARR_TRACKED_COUNT_FILE"
# ==============================================================================================
# ━━━ Scan Music Root ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning Music Root ━━━"
info "Root: $LIDARR_MUSIC_ROOT | Orphan age: ${LIDARR_ORPHAN_AGE} days"
START=$(date +%s)
ORPHAN_COUNT=0
JUNK_COUNT=0
RECENT_COUNT=0
PROTECTED_COUNT=0
ORPHAN_BYTES=0
JUNK_BYTES=0
AGE_SECONDS=$(( LIDARR_ORPHAN_AGE * 86400 ))
NOW=$(date +%s)
MAX_DELETE_BYTES=$(awk "BEGIN {printf \"%d\", $LIDARR_MAX_DELETE_GB * 1073741824}")
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
# Tracked — leave alone
if [[ -n "${TRACKED_MAP[$filepath]:-}" ]]; then
log "TRACKED: $filepath"
continue
fi
# Protected — never delete
if is_protected_file "$filepath"; then
log "$ICON_PROTECTED PROTECTED: $filepath"
(( PROTECTED_COUNT++ ))
continue
fi
FILE_SIZE=$(stat -c%s "$filepath" 2>/dev/null || echo 0)
if is_music_file "$filepath"; then
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_AGE_CHECK" != true ]]; then
log "RECENT (skipping): $filepath"
(( RECENT_COUNT++ ))
continue
fi
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
fi
done < <(find "$LIDARR_MUSIC_ROOT" -type f 2>/dev/null)
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
# ==============================================================================================
# ━━━ Safety Layer 7 — Deletion Size Threshold ━━━
# ==============================================================================================
if [[ "$TOTAL_DELETE_BYTES" -gt "$MAX_DELETE_BYTES" ]]; then
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $TOTAL_DELETE_BYTES / 1073741824}")
if [[ "$I_KNOW" != true ]]; then
echo ""
error "Deletion would exceed ${LIDARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted"
error "Review ORPHAN lines above carefully before proceeding"
error "Rerun with: --i-know-what-im-doing"
error "To also bypass age check: add --skip-age-check"
notify "Lidarr cleanup halted on $(hostname)${TOTAL_HUMAN} requires --i-know-what-im-doing" \
"Lidarr Cleanup" "warning"
exit 1
else
warn "OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
# All safety layers passed — delete orphans and junk
if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
[[ -n "${TRACKED_MAP[$filepath]:-}" ]] && continue
is_protected_file "$filepath" && continue
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if is_music_file "$filepath"; then
[[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && \
[[ "$SKIP_AGE_CHECK" != true ]] && continue
fi
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < <(find "$LIDARR_MUSIC_ROOT" -type f 2>/dev/null)
info "Cleaning up empty folders..."
find "$LIDARR_MUSIC_ROOT" -mindepth 1 -type d -empty -delete 2>/dev/null
info "Empty folders removed"
fi
END=$(date +%s)
format_bytes() {
local bytes=$1
if (( bytes > 1073741824 )); then
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
elif (( bytes > 1048576 )); then
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
else
echo "${bytes}B"
fi
}
ORPHAN_HUMAN=$(format_bytes "$ORPHAN_BYTES")
JUNK_HUMAN=$(format_bytes "$JUNK_BYTES")
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY LIDARR CLEANUP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Tracked: $TRACKED_COUNT files ($ARTIST_COUNT artists)"
echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (cover art, metadata)"
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${LIDARR_ORPHAN_AGE} days)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no files deleted"
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
notify "Lidarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" "Lidarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Write stats for sunday_morning_coffee_report.sh
if [[ "$DRY_RUN" == false ]] && [[ -n "${ARR_CLEANUP_STATS:-}" ]]; then
echo "$(date '+%Y-%m-%d')|lidarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}" \
>> "$ARR_CLEANUP_STATS" 2>/dev/null || true
fi
exit 0
-473
View File
@@ -1,473 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ================================= Lidarr Missing Art =========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Fetch missing album and artist artwork for the Lidarr music library. Downloads
# only what is absent — never overwrites existing files. Idempotent re-runs are
# safe.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Artwork targets per album folder: cover.jpg cdart.png back.jpg
# Artwork targets per artist folder: folder.jpg fanart.jpg clearlogo.png banner.jpg
#
# Sources (tried in order, first success wins):
# Album covers: fanart.tv → iTunes fallback
# Artist art: fanart.tv → Deezer fallback → Last.fm fallback
#
# Reads from Lidarr API only — no writes back to Lidarr. Never modifies audio
# tags or renames media files. Only writes missing artwork files to existing
# album/artist directories.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Additive Only
# The script only adds missing files — it never overwrites existing artwork
# or touches audio files. Re-running after a partial fetch completes exactly
# where it left off with no side effects.
#
# Source Fallback Chain
# Multiple sources are tried in order of quality preference. fanart.tv is
# primary; fallbacks exist so partial coverage is better than none. A failed
# primary never blocks the fallback from running.
#
# External API Courtesy
# Rate limiting and parallel job caps prevent hammering fanart.tv and other
# external APIs. Burst behaviour during large initial runs would risk
# temporary blocks that break future scheduled fetches.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# acquire_lock — prevents concurrent runs during large library scans
# curl + jq check — fail fast if tools missing
# API reachability — verified before processing begins
# detect_hosts() — exits cleanly if LIDARR_URL empty (HOST2, no Lidarr)
# Skip existing — never overwrites, idempotent re-runs are safe
# Min file size check — rejects corrupt/placeholder downloads (LIDARR_ART_MIN_SIZE)
# Parallel job cap — LIDARR_ART_MAX_PARALLEL — avoids hammering external APIs
# Download retries — LIDARR_ART_RETRIES attempts per image before giving up
# Rate limiting — LIDARR_ART_SLEEP_BETWEEN between fanart.tv API calls
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY
# Aliased by detect_hosts() — script uses LIDARR_URL / LIDARR_API_KEY
#
# master.conf
#
# FANART_API_KEY — fanart.tv API key
# LASTFM_API_KEY — last.fm API key
# LIDARR_ART_MIN_SIZE — minimum valid download size in bytes
# LIDARR_ART_MAX_PARALLEL — concurrent background download jobs
# LIDARR_ART_RETRIES — download retry attempts per image
# LIDARR_ART_SLEEP_BETWEEN — seconds between fanart.tv API calls
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# lidarr_missing_art.sh — fetch all missing artwork
# lidarr_missing_art.sh --dry-run — preview without downloading
# lidarr_missing_art.sh --log — verbose per-item output
# lidarr_missing_art.sh --status — show config and exit
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for API calls"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
exit 1
fi
acquire_lock
detect_hosts
# HOST guard — Lidarr runs on HOST1 only
if [[ -z "$LIDARR_URL" ]]; then
echo "Lidarr not configured for $MY_ID — skipping"
exit 0
fi
# Build path map from MY_ID's Lidarr path map
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_LIDARR_PATH_MAP"
eval "for _key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$_key\"]=\"\${${local_path_map_var}[\$_key]}\"
done"
unset _key
info "$MY_ID ($LOCAL_SERVER_NAME) — tools OK"
log "$ICON_GEAR Config: url=${LIDARR_URL}"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Lidarr URL: $LIDARR_URL"
echo "$ICON_NET Fanart key: $([[ -n "${FANART_API_KEY:-}" ]] && echo "set" || echo "not set")"
echo "$ICON_NET LastFM key: $([[ -n "${LASTFM_API_KEY:-}" ]] && echo "set" || echo "not set")"
echo "$ICON_GEAR Min size: ${LIDARR_ART_MIN_SIZE} bytes"
echo "$ICON_GEAR Parallel: $LIDARR_ART_MAX_PARALLEL jobs"
echo "$ICON_RETRY Retries: $LIDARR_ART_RETRIES"
echo "$ICON_TIME API sleep: ${LIDARR_ART_SLEEP_BETWEEN}s"
echo "$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${MY_DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be written"
# ── Temp dir for subshell fetch/fail counters ─────────────────────────────────────────────────
LIDARR_TMP=$(mktemp -d)
trap '_release_all_locks; rm -rf "$LIDARR_TMP"' EXIT
touch "$LIDARR_TMP/album_fetches" "$LIDARR_TMP/album_fails" \
"$LIDARR_TMP/artist_fetches" "$LIDARR_TMP/artist_fails"
# ==============================================================================================
# ── FUNCTIONS ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
curl_json() {
curl -s --connect-timeout 5 --max-time 20 "$1"
}
job_count() {
jobs -rp | wc -l
}
wait_for_slot() {
while (( $(job_count) >= LIDARR_ART_MAX_PARALLEL )); do
sleep 0.2
done
}
# Downloads URL to dest only if dest doesn't exist and downloaded size >= MIN_SIZE.
# Returns 0 on success or skip (file already exists), 1 on failure.
download_if_valid() {
local url="$1"
local dest="$2"
[[ -z "$url" || "$url" == "null" ]] && return 1
[[ -f "$dest" ]] && return 0
if [[ "$DRY_RUN" == true ]]; then
log "DRY RUN — would fetch: $(basename "$dest")"
return 0
fi
local tmp="${dest}.tmp"
local i
for (( i=0; i<=LIDARR_ART_RETRIES; i++ )); do
curl -s --connect-timeout 5 --max-time 20 -L -o "$tmp" "$url"
local size
size=$(stat -c%s "$tmp" 2>/dev/null || echo 0)
if (( size > LIDARR_ART_MIN_SIZE )); then
mv "$tmp" "$dest"
log " Fetched: $(basename "$dest")"
return 0
fi
rm -f "$tmp"
sleep 1
done
warn "Failed to fetch: $(basename "$dest")"
return 1
}
deezer_artist_image() {
local artist="$1"
local query
query=$(printf "%s" "$artist" | sed 's/ /+/g')
curl_json "https://api.deezer.com/search/artist?q=$query" |
jq -r '.data[0].picture_xl // empty' 2>/dev/null
}
lastfm_artist_image() {
local artist="$1"
local encoded
encoded=$(printf "%s" "$artist" | sed 's/ /%20/g')
curl_json "https://ws.audioscrobbler.com/2.0/?method=artist.getinfo&artist=$encoded&api_key=$LASTFM_API_KEY&format=json" |
jq -r '.artist.image[-1]["#text"] // empty' 2>/dev/null
}
# ==============================================================================================
# ━━━ Verify Lidarr reachable ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_NET Lidarr API ━━━"
if ! curl_json "$LIDARR_URL/api/v1/system/status?apikey=$LIDARR_API_KEY" | jq -e '.version' >/dev/null 2>&1; then
error "Lidarr API unreachable at $LIDARR_URL — aborting"
notify "lidarr_missing_art failed — Lidarr API unreachable on $(hostname)" "Lidarr Missing Art" "warning"
exit 1
fi
info "Lidarr reachable — $LIDARR_URL"
START=$(date +%s)
ALBUMS_CHECKED=0
ALBUMS_COMPLETE=0
ARTISTS_CHECKED=0
ARTISTS_COMPLETE=0
# ==============================================================================================
# ━━━ Build Album Directory Map ━━━
# ==============================================================================================
# Lidarr's album API never populates .path — derive album dirs from track file paths instead.
echo ""
echo "━━━ $ICON_SYNC Building Album Directory Map ━━━"
declare -A ALBUM_DIR_MAP
_artist_list=$(curl_json "$LIDARR_URL/api/v1/artist?apikey=$LIDARR_API_KEY")
_map_artist_count=$(echo "$_artist_list" | jq '. | length')
info "Fetching track files for $_map_artist_count artists..."
while IFS= read -r _artist_id; do
[[ -z "$_artist_id" ]] && continue
while IFS=$'\t' read -r _album_id _track_path; do
[[ -z "$_album_id" || -z "$_track_path" || "$_track_path" == "null" ]] && continue
ALBUM_DIR_MAP["$_album_id"]=$(dirname "$_track_path")
done < <(curl_json "$LIDARR_URL/api/v1/trackFile?artistId=${_artist_id}&apikey=$LIDARR_API_KEY" | \
jq -r '.[] | [(.albumId | tostring), .path] | @tsv' 2>/dev/null)
done < <(echo "$_artist_list" | jq -r '.[].id')
unset _artist_list _map_artist_count _artist_id _album_id _track_path
info "Mapped ${#ALBUM_DIR_MAP[@]} albums with local tracks"
# ==============================================================================================
# ━━━ Albums ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_EMBY Albums ━━━"
albums=$(curl_json "$LIDARR_URL/api/v1/album?apikey=$LIDARR_API_KEY")
if [[ -z "$albums" || "$albums" == "null" ]]; then
error "Lidarr album API returned empty — aborting"
notify "lidarr_missing_art failed — album API empty on $(hostname)" "Lidarr Missing Art" "warning"
exit 1
fi
total_albums=$(echo "$albums" | jq '. | length')
info "Processing $total_albums albums..."
while IFS=$'\t' read -r mbid artist_name album_name album_id; do
raw_dir="${ALBUM_DIR_MAP[$album_id]:-}"
[[ -z "$raw_dir" ]] && continue # not downloaded, skip
local_path=$(translate_path "$raw_dir")
(( ALBUMS_CHECKED++ ))
[[ ! -d "$local_path" ]] && continue
log "[$ALBUMS_CHECKED/$total_albums] $artist_name$album_name"
if [[ -f "$local_path/cover.jpg" &&
-f "$local_path/cdart.png" &&
-f "$local_path/back.jpg" ]]; then
(( ALBUMS_COMPLETE++ ))
log " complete — skipping"
continue
fi
wait_for_slot
(
_fetches=0 _fails=0
JSON=""
if [[ -n "$mbid" && "$mbid" != "null" ]]; then
JSON=$(curl_json "http://webservice.fanart.tv/v3/music/albums/$mbid?api_key=$FANART_API_KEY")
sleep "$LIDARR_ART_SLEEP_BETWEEN"
fi
if [[ ! -f "$local_path/cover.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.[].albumcover[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/cover.jpg"; then
(( _fetches++ ))
else
query=$(printf "%s %s" "$artist_name" "$album_name" | sed 's/ /+/g')
itunes=$(curl_json "https://itunes.apple.com/search?term=$query&entity=album&limit=1" |
jq -r '.results[0].artworkUrl100 // empty' 2>/dev/null | sed 's/100x100/600x600/')
if download_if_valid "$itunes" "$local_path/cover.jpg"; then
(( _fetches++ ))
else
(( _fails++ ))
fi
fi
fi
if [[ ! -f "$local_path/cdart.png" ]]; then
IMG=$(echo "$JSON" | jq -r '.[].cdart[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/cdart.png"; then (( _fetches++ )); else (( _fails++ )); fi
fi
if [[ ! -f "$local_path/back.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.[].albumback[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/back.jpg"; then (( _fetches++ )); else (( _fails++ )); fi
fi
(( _fetches > 0 )) && printf '1\n' >> "$LIDARR_TMP/album_fetches"
(( _fails > 0 )) && printf '1\n' >> "$LIDARR_TMP/album_fails"
) &
done < <(echo "$albums" | jq -r '.[] | [(.foreignAlbumId // ""), (.artist.artistName // ""), (.title // ""), (.id | tostring)] | @tsv')
wait
ALBUM_FETCHED=$(wc -l < "$LIDARR_TMP/album_fetches" 2>/dev/null || echo 0)
ALBUM_FAILED=$(wc -l < "$LIDARR_TMP/album_fails" 2>/dev/null || echo 0)
ALBUM_MISSING=$(( ALBUMS_CHECKED - ALBUMS_COMPLETE ))
info "Checked: $ALBUMS_CHECKED | Complete: $ALBUMS_COMPLETE | Needed art: $ALBUM_MISSING | Fetched: $ALBUM_FETCHED | Failed: $ALBUM_FAILED"
# ==============================================================================================
# ━━━ Artists ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_EMBY Artists ━━━"
artists=$(curl_json "$LIDARR_URL/api/v1/artist?apikey=$LIDARR_API_KEY")
if [[ -z "$artists" || "$artists" == "null" ]]; then
error "Lidarr artist API returned empty — aborting"
notify "lidarr_missing_art failed — artist API empty on $(hostname)" "Lidarr Missing Art" "warning"
exit 1
fi
total_artists=$(echo "$artists" | jq '. | length')
info "Processing $total_artists artists..."
while IFS=$'\t' read -r local_path mbid name; do
local_path=$(translate_path "$local_path")
(( ARTISTS_CHECKED++ ))
[[ ! -d "$local_path" ]] && continue
[[ -z "$mbid" || "$mbid" == "null" ]] && continue
log "[$ARTISTS_CHECKED/$total_artists] $name"
if [[ -f "$local_path/folder.jpg" &&
-f "$local_path/fanart.jpg" &&
-f "$local_path/clearlogo.png" &&
-f "$local_path/banner.jpg" ]]; then
(( ARTISTS_COMPLETE++ ))
log " complete — skipping"
continue
fi
wait_for_slot
(
_fetches=0 _fails=0
JSON=$(curl_json "http://webservice.fanart.tv/v3/music/$mbid?api_key=$FANART_API_KEY")
sleep "$LIDARR_ART_SLEEP_BETWEEN"
if [[ ! -f "$local_path/folder.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.artistthumb[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
(( _fetches++ ))
else
IMG=$(deezer_artist_image "$name")
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
(( _fetches++ ))
else
IMG=$(lastfm_artist_image "$name")
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
(( _fetches++ ))
else
(( _fails++ ))
fi
fi
fi
fi
if [[ ! -f "$local_path/fanart.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.artistbackground[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/fanart.jpg"; then
(( _fetches++ ))
else
IMG=$(deezer_artist_image "$name")
if download_if_valid "$IMG" "$local_path/fanart.jpg"; then (( _fetches++ )); else (( _fails++ )); fi
fi
fi
if [[ ! -f "$local_path/clearlogo.png" ]]; then
IMG=$(echo "$JSON" | jq -r '.hdmusiclogo[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/clearlogo.png"; then (( _fetches++ )); else (( _fails++ )); fi
fi
if [[ ! -f "$local_path/banner.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.musicbanner[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/banner.jpg"; then (( _fetches++ )); else (( _fails++ )); fi
fi
(( _fetches > 0 )) && printf '1\n' >> "$LIDARR_TMP/artist_fetches"
(( _fails > 0 )) && printf '1\n' >> "$LIDARR_TMP/artist_fails"
) &
done < <(echo "$artists" | jq -r '.[] | [.path, .foreignArtistId, .artistName] | @tsv')
wait
ARTIST_FETCHED=$(wc -l < "$LIDARR_TMP/artist_fetches" 2>/dev/null || echo 0)
ARTIST_FAILED=$(wc -l < "$LIDARR_TMP/artist_fails" 2>/dev/null || echo 0)
ARTIST_MISSING=$(( ARTISTS_CHECKED - ARTISTS_COMPLETE ))
info "Checked: $ARTISTS_CHECKED | Complete: $ARTISTS_COMPLETE | Needed art: $ARTIST_MISSING | Fetched: $ARTIST_FETCHED | Failed: $ARTIST_FAILED"
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY LIDARR MISSING ART SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $((END - START)))"
echo "$ICON_EMBY Albums: $ALBUMS_CHECKED checked | $ALBUMS_COMPLETE complete | $ALBUM_FETCHED fetched | $ALBUM_FAILED failed"
echo "$ICON_EMBY Artists: $ARTISTS_CHECKED checked | $ARTISTS_COMPLETE complete | $ARTIST_FETCHED fetched | $ARTIST_FAILED failed"
if [[ "$DRY_RUN" == true ]]; then
echo "$ICON_WARN Status: DRY RUN — no files written"
else
echo "$ICON_DONE Status: $ICON_SUCCESS ALL DONE"
notify "Lidarr art fetch complete on $(hostname)${ALBUMS_CHECKED} albums, ${ARTISTS_CHECKED} artists processed" "Lidarr Missing Art" "normal"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
-496
View File
@@ -1,496 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ============================= Lidarr Release Fixer ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Fix Lidarr albums where the wrong MusicBrainz release edition was selected,
# causing on-disk files to appear as unimported despite being present.
#
# Root cause: Lidarr tracks one specific release edition per album using
# foreignReleaseId. When this doesn't match the MUSICBRAINZ_ALBUMID embedded
# in the actual files, track ID lookup fails and RescanFolders imports 0 tracks
# even with perfect, fully tagged files.
#
# Fix: Read the MusicBrainz Album ID from the first FLAC or MP3 found in each
# album directory, look that release up in Lidarr's known releases for that
# album, switch monitored=true to the correct one, then queue a RefreshArtist
# to re-sync track IDs and trigger Lidarr's own import scan.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# For each monitored album with 0 tracked files:
# 1. Locate the album directory under the artist's root path (title glob match)
# 2. Find the first FLAC or MP3 file in that directory
# 3. Read MUSICBRAINZ_ALBUMID from the file's tags
# 4. Fetch the album's available releases from Lidarr
# 5. If the file's release exists and differs from Lidarr's current selection:
# — PUT the album with the correct release set to monitored=true
# — Mark the artist for a RefreshArtist command
#
# RefreshArtist is batched — one per artist, even if multiple albums were fixed.
# Lidarr handles the post-refresh rescan and import automatically.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Fixer Before Cleanup
# Runs before lidarr_cleanup.sh in the daily job list. The strike system in
# cleanup provides a protection window, but correcting releases first means
# files that were wrongly treated as orphans get imported rather than aged out.
#
# No Blind Fixes
# Only switches to a release that is already in Lidarr's known release list for
# that album. If the file's MBID isn't a recognized release, the album is skipped
# rather than guessed at. False corrections are worse than leaving it alone.
#
# Files Are Never Touched
# This script only modifies the Lidarr database record (release selection). Audio
# files, tags, and directory structure are read-only. All actual importing is
# handled by Lidarr after RefreshArtist runs.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# check_api — API reachability verified before any processing
# check_arr_version — aborts if Lidarr major version doesn't match LIDARR_VERSION_MAJOR
# artist count > 0 — aborts if artist fetch returns empty (API anomaly guard)
# release list validation — only fixes to releases explicitly known to Lidarr for that album
# acquire_lock "skip" — skips if another instance is already running
# detect_hosts() — exits cleanly on hosts without Lidarr configured
# curl + jq + perl check — fail fast if any required tool is missing
#
# ==============================================================================================
# TAG READING
# ==============================================================================================
#
# FLAC — Vorbis comment block (block type 4), key MUSICBRAINZ_ALBUMID
# MP3 — ID3v2 TXXX frame, description "MusicBrainz Album Id"
# Supports Latin-1, UTF-8 (enc 0/3) and UTF-16 (enc 1/2) encodings
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY / HOST1_LIDARR_MUSIC_ROOT
# HOST1_LIDARR_PATH_MAP — container path → host path translation
# All aliased by detect_hosts() — script uses unprefixed names
#
# master.conf
#
# LIDARR_RELEASE_FIXER_ENABLED — set false to disable without removing from job list
# LIDARR_VERSION_MAJOR — expected Lidarr major version for API safety check
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# lidarr_release_fixer.sh — normal run
# lidarr_release_fixer.sh --dry-run — preview, no API writes
# lidarr_release_fixer.sh --log — verbose output
# lidarr_release_fixer.sh --status — show config and exit
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ── Setup ─────────────────────────────────────────────────────────────────────────────────────
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
for _cmd in curl jq perl; do
if ! command -v "$_cmd" >/dev/null 2>&1; then
error "$_cmd not found — required"
exit 1
fi
done
unset _cmd
acquire_lock "skip"
trap "_release_all_locks" EXIT
detect_hosts
if [[ -z "${LIDARR_URL:-}" ]] || [[ -z "${LIDARR_API_KEY:-}" ]]; then
info "Lidarr not configured on $MY_ID — skipping"
exit 0
fi
if [[ "${LIDARR_RELEASE_FIXER_ENABLED:-true}" == "false" ]]; then
info "LIDARR_RELEASE_FIXER_ENABLED=false — skipping"
exit 0
fi
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_LIDARR_PATH_MAP"
eval "for key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$key\"]=\"\${${local_path_map_var}[\$key]}\"
done"
require_var LIDARR_URL
require_var LIDARR_API_KEY
require_var LIDARR_MUSIC_ROOT
# ── Status ────────────────────────────────────────────────────────────────────────────────────
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Lidarr URL: $LIDARR_URL"
echo "$ICON_GEAR Music root: $LIDARR_MUSIC_ROOT"
echo "$ICON_GEAR Enabled: ${LIDARR_RELEASE_FIXER_ENABLED:-true}"
echo "$ICON_GEAR Dry run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ── API helpers ───────────────────────────────────────────────────────────────────────────────
lidarr_api() {
local endpoint="$1"
local response http_code body
response=$(curl -sf --max-time 30 \
-H "X-Api-Key: $LIDARR_API_KEY" \
-w "\n%{http_code}" \
"${LIDARR_URL}/api/v1/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "Lidarr API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
lidarr_api_put() {
local endpoint="$1"
local payload="$2"
local http_code
http_code=$(curl -sf --max-time 30 -X PUT \
-H "X-Api-Key: $LIDARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$payload" \
-w "%{http_code}" -o /dev/null \
"${LIDARR_URL}/api/v1/${endpoint}" 2>/dev/null)
if [[ "$http_code" != "202" ]] && [[ "$http_code" != "200" ]]; then
error "Lidarr PUT HTTP $http_code for: $endpoint"
return 1
fi
}
lidarr_api_post() {
local endpoint="$1"
local payload="$2"
curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $LIDARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$payload" \
-o /dev/null \
"${LIDARR_URL}/api/v1/${endpoint}" 2>/dev/null
}
# ── Tag readers ───────────────────────────────────────────────────────────────────────────────
# Read MUSICBRAINZ_ALBUMID from a FLAC file's Vorbis comment block (block type 4)
read_flac_mbid() {
perl -e '
open(my $fh, "<:raw", $ARGV[0]) or exit;
read($fh, my $magic, 4); substr($magic, 0, 4) eq "fLaC" or exit;
while (1) {
read($fh, my $hdr, 4) == 4 or last;
my $w = unpack("N", $hdr);
my $last = ($w >> 31) & 1;
my $type = ($w >> 24) & 0x7f;
my $len = $w & 0xffffff;
read($fh, my $data, $len);
if ($type == 4) {
my $pos = 0;
my $vl = unpack("V", substr($data, $pos, 4)); $pos += 4 + $vl;
my $n = unpack("V", substr($data, $pos, 4)); $pos += 4;
for (1..$n) {
my $cl = unpack("V", substr($data, $pos, 4)); $pos += 4;
my $c = substr($data, $pos, $cl); $pos += $cl;
if ($c =~ /^MUSICBRAINZ_ALBUMID=(.+)$/i) { print "$1\n"; exit; }
}
}
last if $last;
}
' "$1" 2>/dev/null
}
# Read MusicBrainz Album Id from an MP3's ID3v2 TXXX frame.
# Handles Latin-1/UTF-8 (enc 0/3) with single-null separator and
# UTF-16 (enc 1/2) by stripping null bytes and pattern-matching.
read_mp3_mbid() {
perl -e '
open(my $fh, "<:raw", $ARGV[0]) or exit;
read($fh, my $hdr, 10) == 10 or exit;
substr($hdr, 0, 3) eq "ID3" or exit;
my $ver = ord(substr($hdr, 3, 1));
my $sz = 0; $sz = ($sz << 7) | ord($_) for split //, substr($hdr, 6, 4);
read($fh, my $data, $sz) == $sz or exit;
my $pos = 0;
while ($pos + 10 <= $sz) {
my $id = substr($data, $pos, 4); $pos += 4;
last unless $id =~ /^[A-Z][A-Z0-9]{3}$/;
my $fs;
if ($ver >= 4) {
my $n = 0; $n = ($n << 7) | ord($_) for split //, substr($data, $pos, 4);
$fs = $n;
} else {
$fs = unpack("N", substr($data, $pos, 4));
}
$pos += 6;
last if $fs < 1 || $pos + $fs > $sz;
if ($id eq "TXXX") {
my $enc = ord(substr($data, $pos, 1));
my $body = substr($data, $pos + 1, $fs - 1);
if ($enc == 1 || $enc == 2) {
# UTF-16: strip BOM, collapse to ASCII, pattern match
$body =~ s/^\xff\xfe|^\xfe\xff//;
(my $flat = $body) =~ s/\x00//g;
if ($flat =~ /^MusicBrainz Album Id([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i) {
print "$1\n"; exit;
}
} else {
my ($desc, $val) = split /\x00/, $body, 2;
if (defined $desc && lc($desc) eq "musicbrainz album id" &&
defined $val &&
$val =~ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) {
print "$val\n"; exit;
}
}
}
$pos += $fs;
}
' "$1" 2>/dev/null
}
read_file_mbid() {
local file="$1"
case "${file##*.}" in
[Ff][Ll][Aa][Cc]) read_flac_mbid "$file" ;;
[Mm][Pp]3) read_mp3_mbid "$file" ;;
esac
}
translate_container_path() {
local cpath="$1"
for cp in "${!ARR_PATH_MAP[@]}"; do
if [[ "$cpath" == "${cp}"* ]]; then
echo "${ARR_PATH_MAP[$cp]}${cpath#$cp}"
return
fi
done
echo "$cpath"
}
# ── Safety checks ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
if ! check_api "$LIDARR_URL" "Lidarr" 10; then
notify "Lidarr release fixer aborted on $(hostname) — API unreachable" \
"Lidarr Release Fixer" "warning"
exit 1
fi
check_arr_version "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$LIDARR_VERSION_MAJOR" "Lidarr" || exit 1
info "API reachable and version OK"
# ── Fetch all artists and build path cache ────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SYNC Fetching artist paths ━━━"
declare -A ARTIST_PATH_CACHE
declare -A ARTIST_NAME_CACHE
ALL_ARTISTS=$(lidarr_api "artist") || {
error "Failed to fetch artists"
exit 1
}
while IFS= read -r artist; do
aid=$(echo "$artist" | jq -r '.id')
apath=$(echo "$artist" | jq -r '.path // empty')
aname=$(echo "$artist" | jq -r '.artistName // empty')
[[ -n "$apath" ]] && ARTIST_PATH_CACHE[$aid]=$(translate_container_path "$apath")
[[ -n "$aname" ]] && ARTIST_NAME_CACHE[$aid]="$aname"
done < <(echo "$ALL_ARTISTS" | jq -c '.[]' 2>/dev/null)
unset ALL_ARTISTS
ARTIST_COUNT="${#ARTIST_PATH_CACHE[@]}"
info "Loaded $ARTIST_COUNT artists"
if [[ "$ARTIST_COUNT" -eq 0 ]]; then
error "No artists returned — aborting"
exit 1
fi
# ── Fetch zero-file albums ────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SYNC Fetching zero-file monitored albums ━━━"
ALL_ALBUMS=$(lidarr_api "album") || {
error "Failed to fetch albums"
exit 1
}
ZERO_FILE_ALBUMS=$(echo "$ALL_ALBUMS" | jq -c \
'[.[] | select(.monitored == true and .statistics.trackFileCount == 0)]' 2>/dev/null)
unset ALL_ALBUMS
TOTAL_ZERO=$(echo "$ZERO_FILE_ALBUMS" | jq 'length' 2>/dev/null)
info "Monitored albums with 0 tracked files: $TOTAL_ZERO"
# ── Process albums ────────────────────────────────────────────────────────────────────────────
START=$(date +%s)
FIXED=0
SKIPPED_NO_DIR=0
SKIPPED_NO_FILE=0
SKIPPED_NO_MBID=0
SKIPPED_NO_MATCH=0
SKIPPED_CORRECT=0
ERRORS=0
declare -A ARTISTS_TO_REFRESH
echo ""
echo "━━━ $ICON_GEAR Processing albums ━━━"
while IFS= read -r album; do
album_id=$(echo "$album" | jq -r '.id')
album_title=$(echo "$album" | jq -r '.title')
artist_id=$(echo "$album" | jq -r '.artistId')
artist_name="${ARTIST_NAME_CACHE[$artist_id]:-artist $artist_id}"
log "Checking: $artist_name$album_title (album $album_id)"
artist_host_path="${ARTIST_PATH_CACHE[$artist_id]:-}"
if [[ -z "$artist_host_path" ]] || [[ ! -d "$artist_host_path" ]]; then
log "Artist dir not found: $artist_host_path"
(( SKIPPED_NO_DIR++ ))
continue
fi
# Find album directory — case-insensitive prefix match on title
album_dir=$(find "$artist_host_path" -maxdepth 1 -type d -iname "${album_title}*" \
2>/dev/null | head -1)
if [[ -z "$album_dir" ]]; then
log "Album dir not found: $album_title"
(( SKIPPED_NO_DIR++ ))
continue
fi
# Find first FLAC or MP3
music_file=$(find "$album_dir" -maxdepth 2 -type f \
\( -iname "*.flac" -o -iname "*.mp3" \) 2>/dev/null | head -1)
if [[ -z "$music_file" ]]; then
log "No FLAC or MP3 in: $album_dir"
(( SKIPPED_NO_FILE++ ))
continue
fi
# Read MBID from file tags
file_mbid=$(read_file_mbid "$music_file")
if [[ -z "$file_mbid" ]]; then
log "No MBID tag in: $music_file"
(( SKIPPED_NO_MBID++ ))
continue
fi
log "File MBID: $file_mbid"
# Fetch full album JSON (includes releases array)
album_full=$(lidarr_api "album/${album_id}") || { (( ERRORS++ )); sleep 0.2; continue; }
sleep 0.1
# Currently selected release
current_release_id=$(echo "$album_full" | jq -r \
'.releases[] | select(.monitored == true) | .foreignReleaseId' 2>/dev/null | head -1)
if [[ "$current_release_id" == "$file_mbid" ]]; then
log "Release already correct: $file_mbid"
(( SKIPPED_CORRECT++ ))
continue
fi
# Verify file's MBID is a known release for this album
release_known=$(echo "$album_full" | jq -r \
--arg rid "$file_mbid" \
'.releases[] | select(.foreignReleaseId == $rid) | .foreignReleaseId' 2>/dev/null)
if [[ -z "$release_known" ]]; then
log "File MBID $file_mbid not in Lidarr release list for: $album_title"
(( SKIPPED_NO_MATCH++ ))
continue
fi
# Build updated album object with correct release selected
updated_album=$(echo "$album_full" | jq \
--arg target "$file_mbid" \
'.releases = [.releases[] | .monitored = (.foreignReleaseId == $target)]' 2>/dev/null)
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would fix: $artist_name$album_title"
warn " $current_release_id$file_mbid"
(( FIXED++ ))
continue
fi
if lidarr_api_put "album/${album_id}" "$updated_album"; then
warn "$ICON_GEAR Fixed: $artist_name$album_title"
warn " $current_release_id$file_mbid"
ARTISTS_TO_REFRESH[$artist_id]="$artist_id"
(( FIXED++ ))
else
(( ERRORS++ ))
fi
sleep 0.2
done < <(echo "$ZERO_FILE_ALBUMS" | jq -c '.[]')
# ── Queue RefreshArtist for all fixed artists ─────────────────────────────────────────────────
if [[ "${#ARTISTS_TO_REFRESH[@]}" -gt 0 ]] && [[ "$DRY_RUN" == false ]]; then
echo ""
echo "━━━ $ICON_SYNC Queuing RefreshArtist ━━━"
for artist_id in "${!ARTISTS_TO_REFRESH[@]}"; do
if lidarr_api_post "command" \
"{\"name\": \"RefreshArtist\", \"artistId\": ${artist_id}}"; then
log "Queued RefreshArtist for: ${ARTIST_NAME_CACHE[$artist_id]:-$artist_id}"
else
error "Failed to queue RefreshArtist for artist $artist_id"
fi
sleep 0.1
done
info "Refresh queued for ${#ARTISTS_TO_REFRESH[@]} artist(s)"
fi
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY LIDARR RELEASE FIXER SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Candidates: $TOTAL_ZERO zero-file albums"
echo "$ICON_DONE Fixed: $FIXED"
echo "$ICON_SKIP Already correct: $SKIPPED_CORRECT"
echo "$ICON_SKIP No dir on disk: $SKIPPED_NO_DIR"
echo "$ICON_SKIP No music file: $SKIPPED_NO_FILE"
echo "$ICON_SKIP No MBID tag: $SKIPPED_NO_MBID"
echo "$ICON_SKIP MBID not in list: $SKIPPED_NO_MATCH"
[[ "$ERRORS" -gt 0 ]] && echo " Errors: $ERRORS"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ "$FIXED" -gt 0 ]] && [[ "$DRY_RUN" == false ]]; then
notify "Lidarr release fixer on $(hostname) — corrected $FIXED album release(s)" \
"Lidarr Release Fixer" "normal"
fi
exit 0
-743
View File
@@ -1,743 +0,0 @@
#!/bin/bash
# ==============================================================================================
# =========================== Playback-Aware Lidarr Discovery ==================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Two-stage behavior-driven music discovery.
#
# Stage 1 — Score what you actually played this week. The top artists become
# high-quality seeds, not just anything that hit the minimum play count.
#
# Stage 2 — Run Last.fm artist.getSimilar on those seeds. Score the recommendations
# and add the top artists to Lidarr.
#
# Goal: 05 meaningful Lidarr adds per week, not bulk imports.
#
# ==============================================================================================
# FLOW
# ==============================================================================================
#
# 1. Fetch play completions from Emby activity log (last LOOKBACK_DAYS days)
# 2. Aggregate by artist — apply per-user influence cap to play weights
#
# ── Stage 1 ─────────────────────────────────────────────────────────────────
# 3. Score each played artist: user signal + recency + Last.fm popularity/quality
# 4. Take top MAX_ADDS by score → discovery seeds
#
# ── Stage 2 ─────────────────────────────────────────────────────────────────
# 5. For each seed, call Last.fm artist.getSimilar → collect candidates
# 6. Aggregate: affinity (weighted similarity × seed score) + breadth (distinct seeds)
# 7. Filter: already in Lidarr, already in Emby, placeholder artists, cooldown
# 8. Score candidates: affinity + breadth + popularity + quality
# 9. Take top MAX_ADDS above threshold → add to Lidarr
#
# ==============================================================================================
# SCORING MODEL
# ==============================================================================================
#
# Stage 1 — seed selection (max 100)
# user_score (0-40) — effective plays × 5, cap 40
# recency_score (0-30) — days since last play; today→30, older→less
# popularity_score (0-20) — Last.fm listeners
# quality_score (0-10) — Last.fm global playcount
#
# Stage 2 — candidate scoring (max 100)
# affinity_score (0-40) — seed_score × similarity, normalized; raw/20, cap 40
# breadth_score (0-30) — 1 seed→5, 2 seeds→18, 3+seeds→30
# popularity_score (0-20) — Last.fm listeners
# quality_score (0-10) — Last.fm global playcount
#
# Threshold: LIDARR_DISCOVERY_THRESHOLD (default 70) applied to both stages
# Max adds: LIDARR_DISCOVERY_MAX_ADDS (default 5) caps each stage
#
# ==============================================================================================
# REQUIREMENTS
# ==============================================================================================
#
# Last.fm API key — required for both stages
# Configure HOST*_LASTFM_API_KEY in host*.conf
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Playback as Intent Signal
# What users actually listen to is a stronger signal than what they follow or
# own. The scoring model weights demonstrated listening behaviour — recency,
# play count, user breadth — over passive library membership.
#
# Selective by Design
# 05 adds per week is the target, not bulk imports. A high score threshold
# combined with MAX_ADDS ensures only high-confidence recommendations are
# acted on. Volume is not the goal — meaningful discovery is.
#
# Two-Stage Filtering
# Stage 1 rejects weak seeds before they drive Stage 2. Low-quality seeds
# produce low-quality similar-artist recommendations. Filtering at the seed
# stage improves the entire output, not just the top of the list.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# acquire_lock and Lidarr API writes require root. Script exits cleanly if not root.
#
# Dry-Run Mode
# --dry-run scores and ranks all candidates but makes no Lidarr API calls and does not
# write to the history file. Safe to run at any time to preview what would be added.
#
# Add-Only
# Only adds artists to Lidarr. Never deletes or modifies existing entries.
#
# Cooldown Guard
# Candidates rejected this run are recorded in the history file and not re-evaluated
# until LIDARR_DISCOVERY_REJECT_COOLDOWN days have elapsed.
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# LIDARR_DISCOVERY_HISTORY (default: $DATA_DIR/lidarr_discovery_history.db)
# Tracks added artists and rejected candidates with timestamps. Written after every
# non-dry-run. Enforces reject cooldown and prevents re-adding items added by previous
# runs. Safe to delete — next run starts fresh with no memory.
#
# ==============================================================================================
# CONFIGURATION (master.conf)
# ==============================================================================================
#
# LIDARR_DISCOVERY_THRESHOLD — minimum score for Stage 1 seeds and Stage 2 adds (default: 70)
# LIDARR_DISCOVERY_LOOKBACK_DAYS — Emby play history window in days (default: 7)
# LIDARR_DISCOVERY_MIN_PLAYS — min plays to be evaluated in Stage 1 (default: 3)
# LIDARR_DISCOVERY_MAX_ADDS — max seeds (Stage 1) and max adds (Stage 2) (default: 5)
# LIDARR_DISCOVERY_USER_CAP_PCT — max % any one user contributes to play weight (default: 35)
# LIDARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a Stage 2 reject (default: 30)
# LIDARR_DISCOVERY_HISTORY — history/state file path
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# playback_aware_lidarr_discovery.sh — normal run
# playback_aware_lidarr_discovery.sh --dry-run — score and rank, no Lidarr changes
# playback_aware_lidarr_discovery.sh --log — verbose output
# playback_aware_lidarr_discovery.sh --status — show config and exit
#
# Recommended schedule: weekly (WEEKLY_MAINTENANCE_SCRIPTS in master.conf)
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../Kernel/decision_engine.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
detect_hosts
if ! command -v jq >/dev/null 2>&1; then
error "jq not installed — required for API JSON parsing"
exit 1
fi
if [[ -z "${EMBY_URL:-}" || -z "${EMBY_API_KEY:-}" ]]; then
error "EMBY_URL / EMBY_API_KEY not configured — check host*.conf"
exit 1
fi
if [[ -z "${LIDARR_URL:-}" || -z "${LIDARR_API_KEY:-}" ]]; then
error "LIDARR_URL / LIDARR_API_KEY not configured — check host*.conf"
exit 1
fi
if [[ -z "${LASTFM_API_KEY:-}" ]]; then
error "LASTFM_API_KEY not configured — required for discovery"
error "Configure HOST*_LASTFM_API_KEY in host*.conf"
exit 1
fi
THRESHOLD="${LIDARR_DISCOVERY_THRESHOLD:-70}"
LOOKBACK_DAYS="${LIDARR_DISCOVERY_LOOKBACK_DAYS:-7}"
MIN_PLAYS="${LIDARR_DISCOVERY_MIN_PLAYS:-3}"
MAX_ADDS="${LIDARR_DISCOVERY_MAX_ADDS:-5}"
USER_CAP_PCT="${LIDARR_DISCOVERY_USER_CAP_PCT:-35}"
REJECT_COOLDOWN="${LIDARR_DISCOVERY_REJECT_COOLDOWN:-30}"
HISTORY_FILE="${LIDARR_DISCOVERY_HISTORY:-${DATA_DIR}/lidarr_discovery_history.db}"
log "$ICON_GEAR Config: threshold=${THRESHOLD} lookback=${LOOKBACK_DAYS}d min-plays=${MIN_PLAYS} max-adds=${MAX_ADDS} user-cap=${USER_CAP_PCT}% reject-cooldown=${REJECT_COOLDOWN}d"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no artists will be added to Lidarr"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY LIDARR DISCOVERY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Emby: ${EMBY_URL}"
echo "$ICON_SYNC Lidarr: ${LIDARR_URL}"
echo "$ICON_GEAR Threshold: ${THRESHOLD} / 100 (both stages)"
echo "$ICON_TIME Lookback: ${LOOKBACK_DAYS} days"
echo "$ICON_GEAR Min plays: ${MIN_PLAYS}"
echo "$ICON_GEAR Max adds: ${MAX_ADDS} (seeds in Stage 1, adds in Stage 2)"
echo "$ICON_GEAR User cap: ${USER_CAP_PCT}% max per user (floor: 3 plays)"
echo "$ICON_GEAR Reject TTL: ${REJECT_COOLDOWN} days"
echo "$ICON_GEAR History file: ${HISTORY_FILE}"
echo "$ICON_GEAR Last.fm: configured"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── API HELPERS ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_lidarr_get() {
curl -sf --max-time 20 \
-H "X-Api-Key: $LIDARR_API_KEY" \
"${LIDARR_URL}/api/v1/${1}" 2>/dev/null
}
_lidarr_lookup() {
curl -sf --max-time 20 --get \
--data-urlencode "term=$1" \
-H "X-Api-Key: $LIDARR_API_KEY" \
"${LIDARR_URL}/api/v1/artist/lookup" 2>/dev/null
}
_lidarr_post() {
curl -sf --max-time 20 -X POST \
-H "X-Api-Key: $LIDARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$1" \
"${LIDARR_URL}/api/v1/artist" 2>/dev/null
}
_lastfm_info() {
curl -sf --max-time 10 --get \
--data-urlencode "method=artist.getinfo" \
--data-urlencode "artist=$1" \
--data-urlencode "api_key=$LASTFM_API_KEY" \
--data-urlencode "format=json" \
"http://ws.audioscrobbler.com/2.0/" 2>/dev/null
}
_lastfm_similar() {
curl -sf --max-time 15 --get \
--data-urlencode "method=artist.getSimilar" \
--data-urlencode "artist=$1" \
--data-urlencode "limit=10" \
--data-urlencode "api_key=$LASTFM_API_KEY" \
--data-urlencode "format=json" \
"http://ws.audioscrobbler.com/2.0/" 2>/dev/null
}
# ==============================================================================================
# ── SCORING HELPERS ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_user_score() {
local s=$(( $1 * 5 ))
(( s > 40 )) && s=40
echo "$s"
}
_recency_score() {
local days="$1"
if (( days == 0 )); then echo 30
elif (( days == 1 )); then echo 25
elif (( days == 2 )); then echo 20
elif (( days == 3 )); then echo 15
elif (( days == 4 )); then echo 10
elif (( days == 5 )); then echo 8
else echo 5
fi
}
_affinity_score() {
local s=$(( $1 / 20 ))
(( s > 40 )) && s=40
echo "$s"
}
_breadth_score() {
local seeds="$1"
if (( seeds >= 3 )); then echo 30
elif (( seeds == 2 )); then echo 18
else echo 5
fi
}
_popularity_score() {
local listeners="$1"
if (( listeners >= 5000000 )); then echo 20
elif (( listeners >= 1000000 )); then echo 15
elif (( listeners >= 500000 )); then echo 10
elif (( listeners >= 100000 )); then echo 5
else echo 2
fi
}
_quality_score() {
local playcount="$1"
if (( playcount >= 100000000 )); then echo 10
elif (( playcount >= 10000000 )); then echo 7
elif (( playcount >= 1000000 )); then echo 4
else echo 2
fi
}
_is_placeholder_artist() {
local a="${1,,}"
[[ "$a" =~ ^(va|various|various artists|unknown artist|unknown|soundtrack|original soundtrack|ost)$ ]]
}
_lfm_scores() {
local artist="$1"
local LFM_JSON lfm_listeners lfm_playcount
LFM_JSON=$(_lastfm_info "$artist")
if [[ -n "$LFM_JSON" ]] && echo "$LFM_JSON" | jq -e '.artist' >/dev/null 2>&1; then
lfm_listeners=$(echo "$LFM_JSON" | jq -r '.artist.stats.listeners // "0"' | tr -d ',')
lfm_playcount=$(echo "$LFM_JSON" | jq -r '.artist.stats.playcount // "0"' | tr -d ',')
lfm_listeners=${lfm_listeners//[^0-9]/}; lfm_listeners=${lfm_listeners:-0}
lfm_playcount=${lfm_playcount//[^0-9]/}; lfm_playcount=${lfm_playcount:-0}
echo "${lfm_listeners}|${lfm_playcount}"
else
echo "0|0"
fi
}
# ==============================================================================================
# ━━━ Fetch Emby Play History ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY Lidarr Discovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━"
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) | lookback: ${LOOKBACK_DAYS}d | threshold: ${THRESHOLD}/100 | max: ${MAX_ADDS}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no artists will be added"
echo ""
echo "━━━ $ICON_SYNC Emby Play History ━━━"
CUTOFF_ISO=$(date -d "${LOOKBACK_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ')
TODAY_EPOCH=$(date +%s)
TODAY=$(date +%Y-%m-%d)
ACTIVITY_JSON=$(emby_api "System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000") || {
error "Could not fetch Emby activity log"
exit 1
}
declare -A ARTIST_PLAYS
declare -A ARTIST_LAST_PLAY
declare -A ARTIST_USER_PLAYS
declare -A ARTIST_EFFECTIVE # precomputed effective plays after user cap
TOTAL_PLAYS=0
while IFS='|' read -r play_date artist user_id; do
[[ -z "$artist" || "$artist" == "null" ]] && continue
_is_placeholder_artist "$artist" && continue
(( TOTAL_PLAYS++ ))
ARTIST_PLAYS["$artist"]=$(( ${ARTIST_PLAYS["$artist"]:-0} + 1 ))
ARTIST_USER_PLAYS["${artist}|${user_id}"]=$(( ${ARTIST_USER_PLAYS["${artist}|${user_id}"]:-0} + 1 ))
current="${ARTIST_LAST_PLAY["$artist"]:-}"
if [[ -z "$current" || "$play_date" > "$current" ]]; then
ARTIST_LAST_PLAY["$artist"]="$play_date"
fi
done < <(echo "$ACTIVITY_JSON" | jq -r '
.Items // [] | .[] |
select(.Name | test("has finished playing"; "i")) |
select(.Name | test(" - ")) |
select(.Name | test(", Ep[0-9]") | not) |
[
.Date,
(
.Name |
split(" has finished playing ")[1] |
split(" on ") | .[0:-1] | join(" on ") |
split(" - ")[0] | ltrimstr(" ") | rtrimstr(" ")
),
(.UserId // "unknown")
] | join("|")
' 2>/dev/null)
# Precompute effective plays (user cap applied) for all qualifying artists
for _a in "${!ARTIST_PLAYS[@]}"; do
_plays="${ARTIST_PLAYS["$_a"]}"
(( _plays < MIN_PLAYS )) && continue
_cap=$(( (_plays * USER_CAP_PCT + 99) / 100 ))
(( _cap < 3 )) && _cap=3
_eff=0
for _ukey in "${!ARTIST_USER_PLAYS[@]}"; do
[[ "$_ukey" == "${_a}|"* ]] || continue
_uc="${ARTIST_USER_PLAYS["$_ukey"]}"
(( _eff += _uc < _cap ? _uc : _cap ))
done
(( _eff == 0 )) && _eff="$_plays"
ARTIST_EFFECTIVE["$_a"]="$_eff"
done
UNIQUE_ARTISTS=${#ARTIST_PLAYS[@]}
QUALIFIED=$(( $(echo "${!ARTIST_EFFECTIVE[@]}" | wc -w) ))
log "$TOTAL_PLAYS play completions | $UNIQUE_ARTISTS unique artists | $QUALIFIED qualify (${MIN_PLAYS}+ plays)"
if [[ "$QUALIFIED" -eq 0 ]]; then
warn "No artists qualify this week — nothing to evaluate"
exit 0
fi
# ==============================================================================================
# ━━━ Stage 1: Score Played Artists → Select Seeds ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Stage 1: Scoring Played Artists ━━━"
S1_SCORED=() # "score|artist"
for artist in "${!ARTIST_EFFECTIVE[@]}"; do
effective="${ARTIST_EFFECTIVE["$artist"]}"
last_played="${ARTIST_LAST_PLAY["$artist"]}"
last_epoch=$(date -d "$last_played" +%s 2>/dev/null || echo "$TODAY_EPOCH")
days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
user_s=$(_user_score "$effective")
recency_s=$(_recency_score "$days_ago")
IFS='|' read -r lfm_listeners lfm_playcount <<< "$(_lfm_scores "$artist")"
popularity_s=$(_popularity_score "$lfm_listeners")
quality_s=$(_quality_score "$lfm_playcount")
total=$(score_candidate "$user_s" "$popularity_s" "$recency_s" "$quality_s")
total=$(apply_temporal_decay "$total" "$days_ago")
log " [${total}] $artist (plays: ${ARTIST_PLAYS["$artist"]}${effective} | ${days_ago}d ago)"
S1_SCORED+=("${total}|${artist}")
done
IFS=$'\n' S1_SORTED=($(printf '%s\n' "${S1_SCORED[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
echo ""
echo "━━━ $ICON_SUMMARY Stage 1 Results (top ${MAX_ADDS} seeds) ━━━"
SEEDS=()
SEED_SCORES=() # parallel array: score for each seed (used as Stage 2 weight)
for _entry in "${S1_SORTED[@]}"; do
(( ${#SEEDS[@]} >= MAX_ADDS )) && break
_score="${_entry%%|*}"
_artist="${_entry#*|}"
(( _score < THRESHOLD )) && break # sorted desc — below threshold means rest are too
SEEDS+=("$_artist")
SEED_SCORES+=("$_score")
printf " Seed [%3s] %s (plays: %s)\n" "$_score" "$_artist" "${ARTIST_PLAYS["$_artist"]}"
done
if [[ "${#SEEDS[@]}" -eq 0 ]]; then
warn "No played artists scored above ${THRESHOLD} this week — no seeds for discovery"
exit 0
fi
log "${#SEEDS[@]} seed(s) selected"
# ==============================================================================================
# ━━━ Stage 2: Last.fm Similarity Discovery ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Stage 2: Last.fm Similarity Discovery ━━━"
declare -A CANDIDATE_SCORE
declare -A CANDIDATE_SEED_COUNT
declare -A CANDIDATE_LAST_SEED
for (( _i=0; _i<${#SEEDS[@]}; _i++ )); do
seed_artist="${SEEDS[$_i]}"
seed_score="${SEED_SCORES[$_i]}"
seed_last_play="${ARTIST_LAST_PLAY["$seed_artist"]}"
log " Seed: $seed_artist (score: $seed_score)"
SIMILAR_JSON=$(_lastfm_similar "$seed_artist")
if [[ -z "$SIMILAR_JSON" ]]; then
warn " Last.fm similar unavailable for: $seed_artist"
continue
fi
while IFS='|' read -r sim_name sim_match; do
[[ -z "$sim_name" || "$sim_name" == "null" ]] && continue
_is_placeholder_artist "$sim_name" && continue
[[ "${sim_name,,}" == "${seed_artist,,}" ]] && continue
sim_pct=$(echo "$sim_match" | awk '{printf "%d", $1 * 100 + 0.5}')
(( sim_pct < 1 )) && sim_pct=1
# Weight contribution by seed's Stage 1 score, not raw play count
contribution=$(( seed_score * sim_pct ))
CANDIDATE_SCORE["$sim_name"]=$(( ${CANDIDATE_SCORE["$sim_name"]:-0} + contribution ))
CANDIDATE_SEED_COUNT["$sim_name"]=$(( ${CANDIDATE_SEED_COUNT["$sim_name"]:-0} + 1 ))
existing_date="${CANDIDATE_LAST_SEED["$sim_name"]:-}"
if [[ -z "$existing_date" || "$seed_last_play" > "$existing_date" ]]; then
CANDIDATE_LAST_SEED["$sim_name"]="$seed_last_play"
fi
done < <(echo "$SIMILAR_JSON" | jq -r '
.similarartists.artist // [] | .[] |
[.name, .match] | join("|")
' 2>/dev/null)
done
CANDIDATE_COUNT=${#CANDIDATE_SCORE[@]}
log "$CANDIDATE_COUNT discovery candidates from Last.fm"
if [[ "$CANDIDATE_COUNT" -eq 0 ]]; then
warn "No candidates returned from Last.fm — check API key or seed artist names"
exit 0
fi
# ==============================================================================================
# ━━━ Fetch Existing Libraries ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Existing Libraries ━━━"
LIDARR_ARTISTS_JSON=$(_lidarr_get "artist") || { error "Could not fetch Lidarr artists"; exit 1; }
LIDARR_NAMES=$(echo "$LIDARR_ARTISTS_JSON" | jq -r '.[].artistName' 2>/dev/null)
LIDARR_COUNT=$(echo "$LIDARR_NAMES" | grep -c . 2>/dev/null || echo 0)
log "$LIDARR_COUNT artists in Lidarr"
EMBY_LIBRARY_JSON=$(emby_api "Items?IncludeItemTypes=MusicAlbum&Recursive=true&Fields=AlbumArtists&Limit=10000") || {
warn "Could not fetch Emby artist library — skipping Emby filter"
EMBY_ARTIST_NAMES=""
}
EMBY_ARTIST_NAMES=$(echo "$EMBY_LIBRARY_JSON" | jq -r '.Items[] | .AlbumArtists[]?.Name' 2>/dev/null)
EMBY_ARTIST_COUNT=$(echo "$EMBY_ARTIST_NAMES" | grep -c . 2>/dev/null || echo 0)
log "$EMBY_ARTIST_COUNT album artists in Emby library"
_in_lidarr() { echo "$LIDARR_NAMES" | grep -iq "^${1}$"; }
_in_emby_library(){ [[ -n "$EMBY_ARTIST_NAMES" ]] && echo "$EMBY_ARTIST_NAMES" | grep -iq "^${1}$"; }
# ==============================================================================================
# ━━━ Score Stage 2 Candidates ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Stage 2: Scoring Candidates ━━━"
ACCEPT_LIST=()
REJECT_LIST=()
ALREADY_KNOWN=0
SKIP_COOLDOWN=0
for candidate in "${!CANDIDATE_SCORE[@]}"; do
raw_score="${CANDIDATE_SCORE["$candidate"]}"
seed_count="${CANDIDATE_SEED_COUNT["$candidate"]}"
last_seed_date="${CANDIDATE_LAST_SEED["$candidate"]:-}"
if _in_lidarr "$candidate"; then
(( ALREADY_KNOWN++ ))
log " $ICON_SKIP In Lidarr: $candidate"
continue
fi
if _in_emby_library "$candidate"; then
(( ALREADY_KNOWN++ ))
log " $ICON_SKIP In Emby: $candidate"
continue
fi
if [[ -f "$HISTORY_FILE" ]]; then
last_rejection=$(grep -i "^REJECT|${candidate}|" "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d'|' -f3)
if [[ -n "$last_rejection" ]]; then
last_epoch=$(date -d "$last_rejection" +%s 2>/dev/null || echo 0)
days_since=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
if (( days_since < REJECT_COOLDOWN )); then
(( SKIP_COOLDOWN++ ))
log " $ICON_SKIP Cooldown (rejected ${days_since}d ago): $candidate"
continue
fi
fi
fi
if [[ -n "$last_seed_date" ]]; then
last_epoch=$(date -d "$last_seed_date" +%s 2>/dev/null || echo "$TODAY_EPOCH")
days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
else
days_ago=0
fi
affinity_s=$(_affinity_score "$raw_score")
breadth_s=$(_breadth_score "$seed_count")
IFS='|' read -r lfm_listeners lfm_playcount <<< "$(_lfm_scores "$candidate")"
if (( lfm_listeners > 0 || lfm_playcount > 0 )); then
lfm_label="${lfm_listeners} lfm listeners"
popularity_s=$(_popularity_score "$lfm_listeners")
quality_s=$(_quality_score "$lfm_playcount")
else
lfm_label="not on Last.fm"
popularity_s=0
quality_s=0
fi
total=$(score_candidate "$affinity_s" "$popularity_s" "$breadth_s" "$quality_s")
total=$(apply_temporal_decay "$total" "$days_ago")
decision=$(make_decision "$total" "$THRESHOLD")
entry="${total}|${candidate}|${seed_count}|${affinity_s}|${lfm_label}"
[[ "$decision" == "ACCEPT" ]] && ACCEPT_LIST+=("$entry") || REJECT_LIST+=("$entry")
done
# ==============================================================================================
# ━━━ Results ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SUMMARY Stage 2 Results (threshold: ${THRESHOLD}, max: ${MAX_ADDS}) ━━━"
IFS=$'\n' _ALL_ACCEPTS=($(printf '%s\n' "${ACCEPT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
IFS=$'\n' SORTED_REJECTS=($(printf '%s\n' "${REJECT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
# Cap to MAX_ADDS — overflow goes to reject history so they don't resurface for REJECT_COOLDOWN days
SORTED_ACCEPTS=("${_ALL_ACCEPTS[@]:0:$MAX_ADDS}")
for (( _i=MAX_ADDS; _i<${#_ALL_ACCEPTS[@]}; _i++ )); do
SORTED_REJECTS+=("${_ALL_ACCEPTS[$_i]}")
done
_print_row() {
local label="$1" entry="$2"
IFS='|' read -r score name seeds affinity lfm <<< "$entry"
printf " %-8s [%3s] %-40s seeds: %s | affinity: %2s | %s\n" \
"$label" "$score" "$name" "$seeds" "$affinity" "$lfm"
}
for entry in "${SORTED_ACCEPTS[@]}"; do [[ -n "$entry" ]] && _print_row "ACCEPT" "$entry"; done
for entry in "${SORTED_REJECTS[@]}"; do [[ -n "$entry" ]] && _print_row "REJECT" "$entry"; done
echo ""
echo " Already in library: $ALREADY_KNOWN | Cooldown: $SKIP_COOLDOWN"
echo " ACCEPT: ${#SORTED_ACCEPTS[@]} | REJECT: ${#SORTED_REJECTS[@]}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN complete — run without --dry-run to add accepted artists"
exit 0
fi
if [[ "${#SORTED_ACCEPTS[@]}" -eq 0 ]]; then
log "No artists above threshold — nothing to add"
for entry in "${SORTED_REJECTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score name _ <<< "$entry"
echo "REJECT|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null
done
exit 0
fi
# ==============================================================================================
# ━━━ Add to Lidarr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Adding to Lidarr ━━━"
LIDARR_ROOT=$(_lidarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null)
if [[ -z "$LIDARR_ROOT" ]]; then error "Could not determine Lidarr root folder"; exit 1; fi
QUALITY_PROFILES=$(_lidarr_get "qualityprofile") || { error "Could not fetch quality profiles"; exit 1; }
METADATA_PROFILES=$(_lidarr_get "metadataprofile") || { error "Could not fetch metadata profiles"; exit 1; }
DEFAULT_QUALITY_ID=$(echo "$QUALITY_PROFILES" | jq -r '.[0].id' 2>/dev/null)
DEFAULT_METADATA_ID=$(echo "$METADATA_PROFILES" | jq -r '
first(.[] | select(.name | test("Standard"; "i")) | .id) // .[0].id' 2>/dev/null)
log "Quality profile: $DEFAULT_QUALITY_ID | Metadata profile: $DEFAULT_METADATA_ID"
ADDED=0
FAILED=0
for entry in "${SORTED_ACCEPTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score name seeds affinity lfm <<< "$entry"
LOOKUP=$(_lidarr_lookup "$name")
if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then
warn " $ICON_WARN No match in Lidarr lookup: $name"
echo "FAIL|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
continue
fi
ARTIST_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null)
MBID=$(echo "$ARTIST_DATA" | jq -r '.foreignArtistId // ""' 2>/dev/null)
LIDARR_NAME=$(echo "$ARTIST_DATA" | jq -r '.artistName // ""' 2>/dev/null)
if [[ -z "$MBID" ]]; then
warn " $ICON_WARN No MusicBrainz ID for: $name"
echo "FAIL|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
continue
fi
PAYLOAD=$(echo "$ARTIST_DATA" | jq \
--arg root "$LIDARR_ROOT" \
--argjson qid "$DEFAULT_QUALITY_ID" \
--argjson mid "$DEFAULT_METADATA_ID" \
'. + {
rootFolderPath: $root,
qualityProfileId: $qid,
metadataProfileId: $mid,
monitored: true,
addOptions: {
monitor: "all",
searchForMissingAlbums: true
}
}' 2>/dev/null)
RESULT=$(_lidarr_post "$PAYLOAD")
if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then
log " $ICON_DONE Added: $LIDARR_NAME (score: $score | seeds: $seeds)"
echo "ACCEPT|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null
(( ADDED++ ))
else
warn " $ICON_WARN Failed to add: $name"
log " $(echo "$RESULT" | head -c 200)"
echo "FAIL|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
fi
done
for entry in "${SORTED_REJECTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score name _ <<< "$entry"
echo "REJECT|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null
done
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY DISCOVERY COMPLETE ━━━━━"
echo " $ICON_DONE Added: $ADDED"
[[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED"
echo " $ICON_SKIP Rejected: ${#SORTED_REJECTS[@]}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ADDED" -gt 0 ]] && notify \
"$ADDED artist(s) added to Lidarr via discovery on $(hostname)" \
"Lidarr Discovery" "normal"
exit 0
-727
View File
@@ -1,727 +0,0 @@
#!/bin/bash
# ==============================================================================================
# =========================== Playback-Aware Radarr Discovery ==================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Two-stage behavior-driven movie discovery.
#
# Stage 1 — Score recently watched movies in Emby. The top movies become
# high-quality seeds, weighted by recency and TMDB rating.
#
# Stage 2 — Run TMDB recommendations on those seeds. Score the candidates
# and add the top movies to Radarr.
#
# Goal: 05 meaningful Radarr adds per run, not bulk imports.
#
# ==============================================================================================
# FLOW
# ==============================================================================================
#
# 1. Fetch recently watched movies from Emby (SEED_LIBRARIES, last LOOKBACK_DAYS days)
# — only movies with a TMDB ID are eligible as seeds
#
# ── Stage 1 ─────────────────────────────────────────────────────────────────
# 2. Score each watched movie: recency + TMDB rating + vote count
# 3. Take top MAX_SEEDS by score → discovery seeds
#
# ── Stage 2 ─────────────────────────────────────────────────────────────────
# 4. For each seed, call TMDB movie recommendations → collect candidates
# 5. Aggregate: breadth (distinct seeds recommending this movie)
# 6. Filter: already in Radarr, already in Emby, below min votes/rating, cooldown
# 7. Score candidates: breadth + TMDB rating + vote count
# 8. Take top MAX_ADDS above threshold → add to Radarr
#
# ==============================================================================================
# SCORING MODEL
# ==============================================================================================
#
# Stage 1 — seed selection (max 100)
# recency_score (0-50) — days since last watch; 0-3d→50, 4-7d→40, 8-14d→30, 15-21d→20, 22-30d→10
# rating_score (0-30) — TMDB vote_average: 8.0+→30, 7.5+→25, 7.0+→18, 6.5+→12, 6.0+→8, else→3
# votes_score (0-20) — TMDB vote_count: 10k+→20, 5k+→15, 1k+→10, 200+→5, else→2
#
# Stage 2 — candidate scoring (max 100)
# breadth_score (0-40) — 3+ seeds→40, 2 seeds→25, 1 seed→10
# rating_score (0-40) — TMDB vote_average: 8.0+→40, 7.5+→32, 7.0+→25, 6.5+→18, 6.0+→12, else→5
# votes_score (0-20) — TMDB vote_count (same thresholds)
#
# Threshold: RADARR_DISCOVERY_THRESHOLD (default 60) — lower than Lidarr since tastes are broader
# Max adds: RADARR_DISCOVERY_MAX_ADDS (default 5)
#
# ==============================================================================================
# REQUIREMENTS
# ==============================================================================================
#
# 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
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Playback as Intent Signal
# Recently watched movies are a stronger signal than what is in the library or
# on watchlists. The scoring model weights demonstrated viewing behaviour —
# recency, rating, vote confidence — over passive ownership.
#
# Selective by Design
# 05 adds per run is the target, not bulk imports. A score threshold combined
# with MAX_ADDS ensures only high-confidence recommendations are acted on.
# Volume is not the goal — meaningful discovery is.
#
# Two-Stage Filtering
# Stage 1 rejects weak seeds before they drive Stage 2. Low-quality or
# low-confidence watched movies produce poor recommendations. Filtering at
# the seed stage improves the entire output.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# acquire_lock and Radarr API writes require root. Script exits cleanly if not root.
#
# Dry-Run Mode
# --dry-run scores and ranks all candidates but makes no Radarr API calls and does not
# write to the history file. Safe to run at any time to preview what would be added.
#
# Add-Only
# Only adds movies to Radarr. Never deletes or modifies existing entries.
#
# Cooldown Guard
# Candidates rejected this run are recorded in the history file and not re-evaluated
# until RADARR_DISCOVERY_REJECT_COOLDOWN days have elapsed.
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# RADARR_DISCOVERY_HISTORY (default: $DATA_DIR/radarr_discovery_history.db)
# Tracks added movies and rejected candidates with timestamps. Written after every
# non-dry-run. Enforces reject cooldown and prevents re-adding items added by previous
# runs. Safe to delete — next run starts fresh with no memory.
#
# ==============================================================================================
# CONFIGURATION (master.conf)
# ==============================================================================================
#
# RADARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 52)
# RADARR_DISCOVERY_LOOKBACK_DAYS — Emby watch history window in days (default: 30)
# RADARR_DISCOVERY_MAX_SEEDS — max seed movies from Stage 1 (default: 5)
# RADARR_DISCOVERY_MAX_ADDS — max movies to add per run (default: 5)
# RADARR_DISCOVERY_MIN_VOTE_COUNT — min TMDB votes for a candidate (default: 100)
# RADARR_DISCOVERY_MIN_RATING — min TMDB vote_average × 10 (default: 60 = 6.0/10)
# RADARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a rejected movie (default: 60)
# RADARR_DISCOVERY_SEED_LIBRARIES — Emby library names to draw seeds from (default: ("Movies"))
# RADARR_DISCOVERY_HISTORY — history/state file path
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# playback_aware_radarr_discovery.sh — normal run
# playback_aware_radarr_discovery.sh --dry-run — score and rank, no Radarr changes
# playback_aware_radarr_discovery.sh --log — verbose output
# playback_aware_radarr_discovery.sh --status — show config and exit
#
# Recommended schedule: weekly (WEEKLY_MAINTENANCE_SCRIPTS in master.conf)
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../Kernel/decision_engine.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
detect_hosts
if ! command -v jq >/dev/null 2>&1; then
error "jq not installed — required for API JSON parsing"
exit 1
fi
if [[ -z "${EMBY_URL:-}" || -z "${EMBY_API_KEY:-}" ]]; then
error "EMBY_URL / EMBY_API_KEY not configured — check host*.conf"
exit 1
fi
if [[ -z "${RADARR_URL:-}" || -z "${RADARR_API_KEY:-}" ]]; then
error "RADARR_URL / RADARR_API_KEY not configured — check host*.conf"
exit 1
fi
if [[ -z "${TMDB_API_KEY:-}" ]]; then
error "TMDB_API_KEY not configured — required for discovery"
error "Get a free key at https://www.themoviedb.org/settings/api"
error "Configure HOST*_TMDB_API_KEY in host*.conf"
exit 1
fi
THRESHOLD="${RADARR_DISCOVERY_THRESHOLD:-60}"
LOOKBACK_DAYS="${RADARR_DISCOVERY_LOOKBACK_DAYS:-30}"
MAX_SEEDS="${RADARR_DISCOVERY_MAX_SEEDS:-5}"
MAX_ADDS="${RADARR_DISCOVERY_MAX_ADDS:-5}"
MIN_VOTE_COUNT="${RADARR_DISCOVERY_MIN_VOTE_COUNT:-100}"
MIN_RATING="${RADARR_DISCOVERY_MIN_RATING:-60}"
REJECT_COOLDOWN="${RADARR_DISCOVERY_REJECT_COOLDOWN:-60}"
HISTORY_FILE="${RADARR_DISCOVERY_HISTORY:-${DATA_DIR}/radarr_discovery_history.db}"
SEED_LIBRARIES=("${RADARR_DISCOVERY_SEED_LIBRARIES[@]:-Movies}")
log "$ICON_GEAR Config: threshold=${THRESHOLD} lookback=${LOOKBACK_DAYS}d max-seeds=${MAX_SEEDS} max-adds=${MAX_ADDS} min-votes=${MIN_VOTE_COUNT} min-rating=${MIN_RATING} reject-cooldown=${REJECT_COOLDOWN}d"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added to Radarr"
_fmt_rating() { local v="${1:-0}"; echo "${v::-1}.${v: -1}" 2>/dev/null || echo "$v"; }
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR DISCOVERY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Emby: ${EMBY_URL}"
echo "$ICON_SYNC Radarr: ${RADARR_URL}"
echo "$ICON_GEAR Threshold: ${THRESHOLD} / 100"
echo "$ICON_TIME Lookback: ${LOOKBACK_DAYS} days"
echo "$ICON_GEAR Max seeds: ${MAX_SEEDS}"
echo "$ICON_GEAR Max adds: ${MAX_ADDS}"
echo "$ICON_GEAR Min rating: ${MIN_RATING} ($(_fmt_rating "$MIN_RATING")/10 TMDB)"
echo "$ICON_GEAR Min votes: ${MIN_VOTE_COUNT}"
echo "$ICON_GEAR Reject TTL: ${REJECT_COOLDOWN} days"
echo "$ICON_GEAR Seed libs: ${SEED_LIBRARIES[*]}"
echo "$ICON_GEAR History file: ${HISTORY_FILE}"
echo "$ICON_GEAR TMDB: configured"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── API HELPERS ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_radarr_get() {
curl -sf --max-time 20 \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/${1}" 2>/dev/null
}
_radarr_lookup() {
curl -sf --max-time 20 --get \
--data-urlencode "term=tmdb:${1}" \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/movie/lookup" 2>/dev/null
}
_radarr_post() {
curl -sf --max-time 20 -X POST \
-H "X-Api-Key: $RADARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$1" \
"${RADARR_URL}/api/v3/movie" 2>/dev/null
}
_radarr_command() {
curl -sf --max-time 20 -X POST \
-H "X-Api-Key: $RADARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$1" \
"${RADARR_URL}/api/v3/command" 2>/dev/null
}
_tmdb_recommendations() {
curl -sf --max-time 15 \
"https://api.themoviedb.org/3/movie/${1}/recommendations?api_key=${TMDB_API_KEY}&language=en-US&page=1" \
2>/dev/null
}
# ==============================================================================================
# ── SCORING HELPERS ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_recency_score() {
local days="$1"
if (( days <= 3 )); then echo 50
elif (( days <= 7 )); then echo 40
elif (( days <= 14 )); then echo 30
elif (( days <= 21 )); then echo 20
elif (( days <= 30 )); then echo 10
else echo 0
fi
}
# Stage 1: play frequency score (max 50) — complements recency (max 50) for 100 total
_freq_score() {
local c="$1"
if (( c >= 4 )); then echo 50
elif (( c >= 2 )); then echo 35
else echo 20
fi
}
# vote_avg_int = vote_average × 10 as integer (e.g. 7.8 → 78)
_rating_score_s2() {
local v="$1"
if (( v >= 80 )); then echo 40
elif (( v >= 75 )); then echo 32
elif (( v >= 70 )); then echo 25
elif (( v >= 65 )); then echo 18
elif (( v >= 60 )); then echo 12
else echo 5
fi
}
_votes_score() {
local c="$1"
if (( c >= 10000 )); then echo 20
elif (( c >= 5000 )); then echo 15
elif (( c >= 1000 )); then echo 10
elif (( c >= 200 )); then echo 5
else echo 2
fi
}
_breadth_score() {
local seeds="$1"
if (( seeds >= 3 )); then echo 40
elif (( seeds == 2 )); then echo 25
else echo 10
fi
}
# ==============================================================================================
# ━━━ Fetch Emby Libraries + Recently Watched Movies ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY Radarr Discovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━"
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) | lookback: ${LOOKBACK_DAYS}d | threshold: ${THRESHOLD}/100 | max: ${MAX_ADDS}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added"
echo ""
echo "━━━ $ICON_SYNC Emby Watch History ━━━"
LIBRARIES_JSON=$(emby_api "Library/VirtualFolders") || { error "Could not fetch Emby libraries"; exit 1; }
CUTOFF_ISO=$(date -d "${LOOKBACK_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ')
TODAY_EPOCH=$(date +%s)
TODAY=$(date +%Y-%m-%d)
# Build TMDB index of all movies in Emby — used in Stage 2 to filter already-owned movies
declare -A EMBY_TMDB_IDS # tmdb_id → 1
for lib_name in "${SEED_LIBRARIES[@]}"; do
lib_id=$(echo "$LIBRARIES_JSON" | jq -r --arg n "$lib_name" '.[] | select(.Name == $n) | .ItemId' 2>/dev/null)
if [[ -z "$lib_id" ]]; then
warn "Emby library not found: $lib_name"
continue
fi
log "Indexing library: $lib_name (ItemId: $lib_id)"
LIB_JSON=$(emby_api "Items?ParentId=${lib_id}&IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds&Limit=10000") || {
warn "Could not index library: $lib_name"
continue
}
while IFS= read -r tmdb_id; do
[[ -n "$tmdb_id" && "$tmdb_id" != "null" ]] && EMBY_TMDB_IDS["$tmdb_id"]=1
done < <(echo "$LIB_JSON" | jq -r '.Items[].ProviderIds.Tmdb // empty' 2>/dev/null)
done
log "${#EMBY_TMDB_IDS[@]} movies in Emby TMDB index"
# Fetch recent play completions from the server-level activity log.
# Each entry includes an ItemId — batch-fetch those items to determine type (Movie vs. Music/TV).
# SortBy=DatePlayed on Items requires UserId context and errors without one; the activity log
# is server-scoped and doesn't have that limitation.
ACTIVITY_JSON=$(emby_api "System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000") || {
error "Could not fetch Emby activity log"
exit 1
}
declare -A ITEM_PLAYS # emby_item_id → play count
declare -A ITEM_LAST_PLAY # emby_item_id → ISO date of most recent play
while IFS='|' read -r item_id play_date; do
[[ -z "$item_id" || "$item_id" == "null" ]] && continue
ITEM_PLAYS["$item_id"]=$(( ${ITEM_PLAYS["$item_id"]:-0} + 1 ))
current="${ITEM_LAST_PLAY["$item_id"]:-}"
if [[ -z "$current" || "$play_date" > "$current" ]]; then
ITEM_LAST_PLAY["$item_id"]="$play_date"
fi
done < <(echo "$ACTIVITY_JSON" | jq -r '
.Items[] | select(.Type == "playback.stop") | select(.ItemId != null and .ItemId != "") |
[.ItemId, .Date] | join("|")
' 2>/dev/null)
log "${#ITEM_PLAYS[@]} unique items in activity log"
declare -A WATCHED_MOVIES # tmdb_id → "title|play_count|last_play_date"
if [[ "${#ITEM_PLAYS[@]}" -gt 0 ]]; then
# Fetch in batches of 100 — large ID lists exceed GET URL limits
ALL_ITEM_IDS=("${!ITEM_PLAYS[@]}")
BATCH_SIZE=100
for (( _b=0; _b<${#ALL_ITEM_IDS[@]}; _b+=BATCH_SIZE )); do
BATCH=("${ALL_ITEM_IDS[@]:_b:BATCH_SIZE}")
IDS_CSV=$(printf '%s,' "${BATCH[@]}"); IDS_CSV="${IDS_CSV%,}"
ITEMS_DETAIL=$(emby_api "Items?Ids=${IDS_CSV}&Fields=ProviderIds,Type&Limit=200") || {
warn "Could not fetch item batch starting at $_b"
continue
}
while IFS='|' read -r item_id item_type tmdb_id title; do
[[ "$item_type" != "Movie" ]] && continue
[[ -z "$tmdb_id" || "$tmdb_id" == "null" ]] && continue
play_count="${ITEM_PLAYS["$item_id"]:-1}"
last_play="${ITEM_LAST_PLAY["$item_id"]:-}"
if [[ -n "${WATCHED_MOVIES["$tmdb_id"]:-}" ]]; then
IFS='|' read -r ex_title ex_plays ex_date <<< "${WATCHED_MOVIES["$tmdb_id"]}"
play_count=$(( ex_plays + play_count ))
[[ "$last_play" > "$ex_date" ]] || last_play="$ex_date"
title="$ex_title"
fi
WATCHED_MOVIES["$tmdb_id"]="${title}|${play_count}|${last_play}"
done < <(echo "$ITEMS_DETAIL" | jq -r '.Items[] |
[(.Id | tostring), .Type, (.ProviderIds.Tmdb // ""), .Name] | join("|")
' 2>/dev/null)
done
fi
WATCHED_COUNT=${#WATCHED_MOVIES[@]}
log "$WATCHED_COUNT movies watched in the last ${LOOKBACK_DAYS} days with TMDB IDs"
if [[ "$WATCHED_COUNT" -eq 0 ]]; then
warn "No recently watched movies found — nothing to seed from"
exit 0
fi
# ==============================================================================================
# ━━━ Stage 1: Score Watched Movies → Select Seeds ━━━
# ==============================================================================================
# Scoring: recency (0-50) + play frequency across all users (0-50) = max 100
# No TMDB rating at Stage 1 — CommunityRating is not exposed by Emby's Items API
echo ""
echo "━━━ $ICON_GEAR Stage 1: Scoring Watched Movies ━━━"
S1_SCORED=() # "score|tmdb_id|title"
for tmdb_id in "${!WATCHED_MOVIES[@]}"; do
IFS='|' read -r title play_count last_play <<< "${WATCHED_MOVIES["$tmdb_id"]}"
last_epoch=$(date -d "$last_play" +%s 2>/dev/null || echo "$TODAY_EPOCH")
days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
recency_s=$(_recency_score "$days_ago")
freq_s=$(_freq_score "$play_count")
total=$(( recency_s + freq_s ))
log " [${total}] $title (TMDB: $tmdb_id | ${days_ago}d ago | plays: $play_count)"
S1_SCORED+=("${total}|${tmdb_id}|${title}")
done
IFS=$'\n' S1_SORTED=($(printf '%s\n' "${S1_SCORED[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
echo ""
echo "━━━ $ICON_SUMMARY Stage 1 Results (top ${MAX_SEEDS} seeds) ━━━"
SEEDS_TMDB=()
SEEDS_TITLES=()
for _entry in "${S1_SORTED[@]}"; do
(( ${#SEEDS_TMDB[@]} >= MAX_SEEDS )) && break
_score="${_entry%%|*}"
_rest="${_entry#*|}"
_tmdb="${_rest%%|*}"
_title="${_rest#*|}"
SEEDS_TMDB+=("$_tmdb")
SEEDS_TITLES+=("$_title")
printf " Seed [%3s] %s (TMDB: %s)\n" "$_score" "$_title" "$_tmdb"
done
if [[ "${#SEEDS_TMDB[@]}" -eq 0 ]]; then
warn "No recently watched movies qualify — no seeds for discovery"
exit 0
fi
log "${#SEEDS_TMDB[@]} seed(s) selected"
# ==============================================================================================
# ━━━ Stage 2: TMDB Recommendations ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Stage 2: TMDB Recommendations ━━━"
declare -A CANDIDATE_SEEDS # tmdb_id → seed count
declare -A CANDIDATE_TITLE # tmdb_id → title
declare -A CANDIDATE_RATING # tmdb_id → vote_avg_int
declare -A CANDIDATE_VOTES # tmdb_id → vote_count
for (( _i=0; _i<${#SEEDS_TMDB[@]}; _i++ )); do
seed_tmdb="${SEEDS_TMDB[$_i]}"
seed_title="${SEEDS_TITLES[$_i]}"
log " Seed: $seed_title (TMDB: $seed_tmdb)"
RECS_JSON=$(_tmdb_recommendations "$seed_tmdb")
if [[ -z "$RECS_JSON" ]] || echo "$RECS_JSON" | jq -e '.results == [] or .results == null' >/dev/null 2>&1; then
warn " No TMDB recommendations for: $seed_title"
continue
fi
while IFS='|' read -r rec_id rec_title rec_avg rec_votes; do
[[ -z "$rec_id" || "$rec_id" == "null" ]] && continue
[[ "$rec_id" == "$seed_tmdb" ]] && continue
vote_avg_int=$(echo "$rec_avg" | awk '{printf "%d", $1 * 10 + 0.5}' 2>/dev/null)
vote_avg_int=${vote_avg_int:-0}
rec_votes=${rec_votes:-0}
CANDIDATE_SEEDS["$rec_id"]=$(( ${CANDIDATE_SEEDS["$rec_id"]:-0} + 1 ))
CANDIDATE_TITLE["$rec_id"]="$rec_title"
CANDIDATE_RATING["$rec_id"]="$vote_avg_int"
CANDIDATE_VOTES["$rec_id"]="$rec_votes"
done < <(echo "$RECS_JSON" | jq -r '.results[] |
[
(.id | tostring),
.title,
(.vote_average | tostring),
(.vote_count | tostring)
] | join("|")' 2>/dev/null)
done
CANDIDATE_COUNT=${#CANDIDATE_SEEDS[@]}
log "$CANDIDATE_COUNT discovery candidates from TMDB"
if [[ "$CANDIDATE_COUNT" -eq 0 ]]; then
warn "No candidates returned from TMDB — check API key"
exit 0
fi
# ==============================================================================================
# ━━━ Fetch Existing Radarr Library ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Existing Libraries ━━━"
RADARR_MOVIES_JSON=$(_radarr_get "movie") || { error "Could not fetch Radarr library"; exit 1; }
declare -A RADARR_TMDB # tmdb_id → 1
while read -r tmdb_id; do
[[ -n "$tmdb_id" && "$tmdb_id" != "0" ]] && RADARR_TMDB["$tmdb_id"]=1
done < <(echo "$RADARR_MOVIES_JSON" | jq -r '.[].tmdbId // 0 | tostring' 2>/dev/null)
RADARR_COUNT=${#RADARR_TMDB[@]}
EMBY_COUNT=${#EMBY_TMDB_IDS[@]}
log "$RADARR_COUNT movies in Radarr | $EMBY_COUNT movies in Emby"
_in_radarr() { [[ "${RADARR_TMDB["$1"]+x}" ]]; }
_in_emby() { [[ "${EMBY_TMDB_IDS["$1"]+x}" ]]; }
# ==============================================================================================
# ━━━ Score Stage 2 Candidates ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Stage 2: Scoring Candidates ━━━"
ACCEPT_LIST=()
REJECT_LIST=()
ALREADY_KNOWN=0
SKIP_COOLDOWN=0
SKIP_QUALITY=0
for rec_id in "${!CANDIDATE_SEEDS[@]}"; do
seed_count="${CANDIDATE_SEEDS["$rec_id"]}"
title="${CANDIDATE_TITLE["$rec_id"]}"
vote_avg_int="${CANDIDATE_RATING["$rec_id"]}"
vote_count="${CANDIDATE_VOTES["$rec_id"]}"
if _in_radarr "$rec_id"; then
(( ALREADY_KNOWN++ ))
log " $ICON_SKIP In Radarr: $title"
continue
fi
if _in_emby "$rec_id"; then
(( ALREADY_KNOWN++ ))
log " $ICON_SKIP In Emby: $title"
continue
fi
# Hard quality floor — skip before cooldown check to avoid polluting history
if (( vote_count < MIN_VOTE_COUNT || vote_avg_int < MIN_RATING )); then
(( SKIP_QUALITY++ ))
log " $ICON_SKIP Below quality floor (rating: $(_fmt_rating "$vote_avg_int") | votes: $vote_count): $title"
continue
fi
if [[ -f "$HISTORY_FILE" ]]; then
last_rejection=$(grep -i "^REJECT|${rec_id}|" "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d'|' -f3)
if [[ -n "$last_rejection" ]]; then
last_epoch=$(date -d "$last_rejection" +%s 2>/dev/null || echo 0)
days_since=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
if (( days_since < REJECT_COOLDOWN )); then
(( SKIP_COOLDOWN++ ))
log " $ICON_SKIP Cooldown (rejected ${days_since}d ago): $title"
continue
fi
fi
fi
breadth_s=$(_breadth_score "$seed_count")
rating_s=$(_rating_score_s2 "$vote_avg_int")
votes_s=$(_votes_score "$vote_count")
total=$(( breadth_s + rating_s + votes_s ))
entry="${total}|${rec_id}|${title}|${seed_count}|${vote_avg_int}|${vote_count}"
if (( total >= THRESHOLD )); then
ACCEPT_LIST+=("$entry")
else
REJECT_LIST+=("$entry")
fi
log " [${total}] $title (seeds: $seed_count | rating: $(_fmt_rating "$vote_avg_int") | votes: $vote_count)"
done
# ==============================================================================================
# ━━━ Results ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SUMMARY Stage 2 Results (threshold: ${THRESHOLD}, max: ${MAX_ADDS}) ━━━"
IFS=$'\n' _ALL_ACCEPTS=($(printf '%s\n' "${ACCEPT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
IFS=$'\n' SORTED_REJECTS=($(printf '%s\n' "${REJECT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
SORTED_ACCEPTS=("${_ALL_ACCEPTS[@]:0:$MAX_ADDS}")
for (( _i=MAX_ADDS; _i<${#_ALL_ACCEPTS[@]}; _i++ )); do
SORTED_REJECTS+=("${_ALL_ACCEPTS[$_i]}")
done
_print_row() {
local label="$1" entry="$2"
IFS='|' read -r score rec_id title seeds avg_int votes <<< "$entry"
local rating_fmt
rating_fmt=$(_fmt_rating "$avg_int")
printf " %-8s [%3s] %-45s seeds: %s | rating: %s | votes: %s\n" \
"$label" "$score" "$title" "$seeds" "$rating_fmt" "$votes"
}
for entry in "${SORTED_ACCEPTS[@]}"; do [[ -n "$entry" ]] && _print_row "ACCEPT" "$entry"; done
for entry in "${SORTED_REJECTS[@]}"; do [[ -n "$entry" ]] && _print_row "REJECT" "$entry"; done
echo ""
echo " Already in library: $ALREADY_KNOWN | Below quality floor: $SKIP_QUALITY | Cooldown: $SKIP_COOLDOWN"
echo " ACCEPT: ${#SORTED_ACCEPTS[@]} | REJECT: ${#SORTED_REJECTS[@]}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN complete — run without --dry-run to add accepted movies"
exit 0
fi
if [[ "${#SORTED_ACCEPTS[@]}" -eq 0 ]]; then
log "No movies above threshold — nothing to add"
for entry in "${SORTED_REJECTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score rec_id title _ <<< "$entry"
echo "REJECT|${rec_id}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
done
exit 0
fi
# ==============================================================================================
# ━━━ Add to Radarr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Adding to Radarr ━━━"
RADARR_ROOT=$(_radarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null)
if [[ -z "$RADARR_ROOT" ]]; then error "Could not determine Radarr root folder"; exit 1; fi
QUALITY_ID=$(_radarr_get "qualityprofile" | jq -r '.[0].id' 2>/dev/null)
log "Root folder: $RADARR_ROOT | Quality profile: $QUALITY_ID"
ADDED=0
FAILED=0
for entry in "${SORTED_ACCEPTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score rec_id title seeds avg_int votes <<< "$entry"
LOOKUP=$(_radarr_lookup "$rec_id")
if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then
warn " $ICON_WARN No match in Radarr lookup: $title (TMDB: $rec_id)"
echo "FAIL|${rec_id}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
continue
fi
MOVIE_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null)
RADARR_TITLE=$(echo "$MOVIE_DATA" | jq -r '.title // ""' 2>/dev/null)
if [[ -z "$RADARR_TITLE" ]]; then
warn " $ICON_WARN Empty result for: $title"
echo "FAIL|${rec_id}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
continue
fi
PAYLOAD=$(echo "$MOVIE_DATA" | jq \
--arg root "$RADARR_ROOT" \
--argjson qid "$QUALITY_ID" \
'. + {
rootFolderPath: $root,
qualityProfileId: $qid,
monitored: true,
addOptions: {
searchForMovie: false
}
}' 2>/dev/null)
RESULT=$(_radarr_post "$PAYLOAD")
if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then
MOVIE_ID=$(echo "$RESULT" | jq -r '.id')
_radarr_command "{\"name\":\"MoviesSearch\",\"movieIds\":[${MOVIE_ID}]}" >/dev/null
log " $ICON_DONE Added: $RADARR_TITLE (score: $score | seeds: $seeds)"
echo "ACCEPT|${rec_id}|${TODAY}|${RADARR_TITLE}" >> "$HISTORY_FILE" 2>/dev/null
(( ADDED++ ))
else
warn " $ICON_WARN Failed to add: $title"
log " $(echo "$RESULT" | head -c 200)"
echo "FAIL|${rec_id}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
fi
done
for entry in "${SORTED_REJECTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score rec_id title _ <<< "$entry"
echo "REJECT|${rec_id}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
done
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY DISCOVERY COMPLETE ━━━━━"
echo " $ICON_DONE Added: $ADDED"
[[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED"
echo " $ICON_SKIP Rejected: ${#SORTED_REJECTS[@]}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ADDED" -gt 0 ]] && notify \
"$ADDED movie(s) added to Radarr via discovery on $(hostname)" \
"Radarr Discovery" "normal"
exit 0
-868
View File
@@ -1,868 +0,0 @@
#!/bin/bash
# ==============================================================================================
# =========================== Playback-Aware Sonarr Discovery ==================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Two-stage behavior-driven TV show discovery.
#
# Stage 1 — Score recently watched series in Emby. The top series become
# high-quality seeds, weighted by user diversity, recency, and
# per-user-capped episode volume.
#
# Stage 2 — Run TMDB TV recommendations on those seeds. Score the candidates
# and add the top shows to Sonarr.
#
# Goal: 03 meaningful Sonarr adds per run, not bulk imports.
#
# ==============================================================================================
# FLOW
# ==============================================================================================
#
# 1. Fetch all Series from Emby SONARR_EMBY_LIBRARIES — build TMDB+TVDB index
#
# ── Stage 1 ─────────────────────────────────────────────────────────────────
# 2. Fetch Emby activity log (last LOOKBACK_DAYS days) — episode play events
# 3. Batch-fetch episode items → map episode → series → TMDB ID
# 4. Aggregate per-user episode counts per series
# 5. Score each series: user diversity + recency + capped volume
# 6. Take top MAX_SEEDS → discovery seeds
#
# ── Stage 2 ─────────────────────────────────────────────────────────────────
# 7. For each seed, call TMDB TV recommendations → collect candidates
# 8. Aggregate: breadth (distinct seeds recommending this show)
# 9. Filter: already in Sonarr, already in Emby, below min votes/rating, cooldown
# 10. Score candidates: breadth + TMDB rating + vote count
# 11. Take top MAX_ADDS above threshold
# 12. Get TVDB ID via TMDB external_ids → Sonarr lookup → add + trigger SeriesSearch
#
# ==============================================================================================
# SCORING MODEL
# ==============================================================================================
#
# Stage 1 — seed selection (max 100)
# user_diversity_score (0-50) — unique users who watched: 1→10, 2→25, 3→40, 4+→50
# recency_score (0-30) — days since last episode; 0-7d→30, 8-14d→20, 15-21d→12, 22+→5
# volume_score (0-20) — sum of min(user_eps, USER_EPISODE_CAP); 1-4→3, 5-12→8, 13-24→14, 25+→20
#
# USER_EPISODE_CAP prevents one person binge-watching from dominating seeds.
# Example: 4 users × 2 eps each beats 1 user × 50 eps (58 vs 18, before recency).
#
# Stage 2 — candidate scoring (max 100)
# breadth_score (0-40) — 3+ seeds→40, 2 seeds→25, 1 seed→10
# rating_score (0-40) — TMDB vote_average × 10: 80+→40, 75+→32, 70+→25, 65+→18, 60+→12, else→5
# votes_score (0-20) — TMDB vote_count: 10k+→20, 5k+→15, 1k+→10, 200+→5, else→2
#
# Threshold: SONARR_DISCOVERY_THRESHOLD (default 52)
# Max adds: SONARR_DISCOVERY_MAX_ADDS (default 3) — TV is a larger commitment than movies
#
# ==============================================================================================
# REQUIREMENTS
# ==============================================================================================
#
# 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
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Playback as Intent Signal
# Recently watched episodes are a stronger signal than what is in the library.
# User diversity across a series is weighted above a single user binge —
# broad household interest is a better predictor of a good addition than
# one person's session.
#
# Selective by Design
# 03 adds per run is the target. TV is a larger commitment than movies —
# a lower MAX_ADDS cap reflects that. Volume is not the goal.
#
# Two-Stage Filtering
# Stage 1 rejects weak seeds before they drive Stage 2. A poorly-watched
# or niche series produces poor recommendations. Filtering at the seed
# stage improves the entire output.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# acquire_lock and Sonarr API writes require root. Script exits cleanly if not root.
#
# Dry-Run Mode
# --dry-run scores and ranks all candidates but makes no Sonarr API calls and does not
# write to the history file. Safe to run at any time to preview what would be added.
#
# Add-Only
# Only adds series to Sonarr. Never deletes or modifies existing entries.
#
# Cooldown Guard
# Candidates rejected this run are recorded in the history file and not re-evaluated
# until SONARR_DISCOVERY_REJECT_COOLDOWN days have elapsed.
#
# Monitor Mode
# SONARR_DISCOVERY_MONITOR_MODE="all" monitors every season on add — correct for shows
# where you want Sonarr to search back-catalogue. "future" only marks upcoming seasons;
# use only when you intentionally want to skip existing seasons.
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# SONARR_DISCOVERY_HISTORY (default: $DATA_DIR/sonarr_discovery_history.db)
# Tracks added series and rejected candidates with timestamps. Written after every
# non-dry-run. Enforces reject cooldown and prevents re-adding items added by previous
# runs. Safe to delete — next run starts fresh with no memory.
#
# ==============================================================================================
# CONFIGURATION (master.conf)
# ==============================================================================================
#
# SONARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 52)
# SONARR_DISCOVERY_LOOKBACK_DAYS — Emby watch history window in days (default: 14)
# SONARR_DISCOVERY_MAX_SEEDS — max seed series from Stage 1 (default: 5)
# SONARR_DISCOVERY_MAX_ADDS — max shows to add per run (default: 3)
# SONARR_DISCOVERY_MIN_VOTE_COUNT — min TMDB votes for a candidate (default: 50)
# SONARR_DISCOVERY_MIN_RATING — min TMDB vote_average × 10 (default: 65 = 6.5/10)
# SONARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a rejected show (default: 60)
# SONARR_DISCOVERY_USER_EPISODE_CAP — max episodes per user in seed scoring (default: 8)
# SONARR_DISCOVERY_MONITOR_MODE — Sonarr monitor mode on add: "all" or "future" (default: "all")
# SONARR_EMBY_LIBRARIES — Emby library names to draw seeds from
# SONARR_DISCOVERY_HISTORY — history/state file path
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# playback_aware_sonarr_discovery.sh — normal run
# playback_aware_sonarr_discovery.sh --dry-run — score and rank, no Sonarr changes
# playback_aware_sonarr_discovery.sh --log — verbose output
# playback_aware_sonarr_discovery.sh --status — show config and exit
#
# Recommended schedule: weekly (WEEKLY_MAINTENANCE_SCRIPTS in master.conf)
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../Kernel/decision_engine.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
detect_hosts
if ! command -v jq >/dev/null 2>&1; then
error "jq not installed — required for API JSON parsing"
exit 1
fi
if [[ -z "${EMBY_URL:-}" || -z "${EMBY_API_KEY:-}" ]]; then
error "EMBY_URL / EMBY_API_KEY not configured — check host*.conf"
exit 1
fi
if [[ -z "${SONARR_URL:-}" || -z "${SONARR_API_KEY:-}" ]]; then
error "SONARR_URL / SONARR_API_KEY not configured — check host*.conf"
exit 1
fi
if [[ -z "${TMDB_API_KEY:-}" ]]; then
error "TMDB_API_KEY not configured — required for discovery"
error "Get a free key at https://www.themoviedb.org/settings/api"
error "Configure HOST*_TMDB_API_KEY in host*.conf"
exit 1
fi
if [[ "${#SONARR_EMBY_LIBRARIES[@]}" -eq 0 ]]; then
error "SONARR_EMBY_LIBRARIES not configured — check master.conf"
exit 1
fi
THRESHOLD="${SONARR_DISCOVERY_THRESHOLD:-52}"
LOOKBACK_DAYS="${SONARR_DISCOVERY_LOOKBACK_DAYS:-14}"
MAX_SEEDS="${SONARR_DISCOVERY_MAX_SEEDS:-5}"
MAX_ADDS="${SONARR_DISCOVERY_MAX_ADDS:-3}"
MIN_VOTE_COUNT="${SONARR_DISCOVERY_MIN_VOTE_COUNT:-50}"
MIN_RATING="${SONARR_DISCOVERY_MIN_RATING:-65}"
REJECT_COOLDOWN="${SONARR_DISCOVERY_REJECT_COOLDOWN:-60}"
USER_EPISODE_CAP="${SONARR_DISCOVERY_USER_EPISODE_CAP:-8}"
MONITOR_MODE="${SONARR_DISCOVERY_MONITOR_MODE:-all}"
HISTORY_FILE="${SONARR_DISCOVERY_HISTORY:-${DATA_DIR}/sonarr_discovery_history.db}"
log "$ICON_GEAR Config: threshold=${THRESHOLD} lookback=${LOOKBACK_DAYS}d max-seeds=${MAX_SEEDS} max-adds=${MAX_ADDS} min-votes=${MIN_VOTE_COUNT} min-rating=${MIN_RATING} reject-cooldown=${REJECT_COOLDOWN}d monitor=${MONITOR_MODE}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no shows will be added to Sonarr"
_fmt_rating() { local v="${1:-0}"; echo "${v::-1}.${v: -1}" 2>/dev/null || echo "$v"; }
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY SONARR DISCOVERY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Emby: ${EMBY_URL}"
echo "$ICON_SYNC Sonarr: ${SONARR_URL}"
echo "$ICON_GEAR Threshold: ${THRESHOLD} / 100"
echo "$ICON_TIME Lookback: ${LOOKBACK_DAYS} days"
echo "$ICON_GEAR Max seeds: ${MAX_SEEDS}"
echo "$ICON_GEAR Max adds: ${MAX_ADDS}"
echo "$ICON_GEAR Min rating: ${MIN_RATING} ($(_fmt_rating "$MIN_RATING")/10 TMDB)"
echo "$ICON_GEAR Min votes: ${MIN_VOTE_COUNT}"
echo "$ICON_GEAR Reject TTL: ${REJECT_COOLDOWN} days"
echo "$ICON_GEAR User ep cap: ${USER_EPISODE_CAP} episodes"
echo "$ICON_GEAR Monitor mode: ${MONITOR_MODE}"
echo "$ICON_GEAR Seed libs: ${SONARR_EMBY_LIBRARIES[*]}"
echo "$ICON_GEAR History file: ${HISTORY_FILE}"
echo "$ICON_GEAR TMDB: configured"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── API HELPERS ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_sonarr_get() {
curl -sf --max-time 30 \
-H "X-Api-Key: $SONARR_API_KEY" \
"${SONARR_URL}/api/v3/${1}" 2>/dev/null
}
_sonarr_lookup() {
curl -sf --max-time 20 --get \
--data-urlencode "term=$1" \
-H "X-Api-Key: $SONARR_API_KEY" \
"${SONARR_URL}/api/v3/series/lookup" 2>/dev/null
}
_sonarr_post() {
curl -sf --max-time 20 -X POST \
-H "X-Api-Key: $SONARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$1" \
"${SONARR_URL}/api/v3/series" 2>/dev/null
}
_sonarr_command() {
curl -sf --max-time 20 -X POST \
-H "X-Api-Key: $SONARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$1" \
"${SONARR_URL}/api/v3/command" 2>/dev/null
}
_tmdb_tv_recommendations() {
curl -sf --max-time 15 \
"https://api.themoviedb.org/3/tv/${1}/recommendations?api_key=${TMDB_API_KEY}&language=en-US&page=1" \
2>/dev/null
}
_tmdb_external_ids() {
curl -sf --max-time 15 \
"https://api.themoviedb.org/3/tv/${1}/external_ids?api_key=${TMDB_API_KEY}" \
2>/dev/null
}
# ==============================================================================================
# ── SCORING HELPERS ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Stage 1: unique user count score (0-50) — primary seed driver
_diversity_score() {
local n="$1"
if (( n >= 4 )); then echo 50
elif (( n == 3 )); then echo 40
elif (( n == 2 )); then echo 25
else echo 10
fi
}
# Stage 1: recency of most recent episode watched (0-30)
_recency_score() {
local days="$1"
if (( days <= 7 )); then echo 30
elif (( days <= 14 )); then echo 20
elif (( days <= 21 )); then echo 12
elif (( days <= 30 )); then echo 5
else echo 0
fi
}
# Stage 1: per-user-capped episode volume score (0-20)
_volume_score() {
local v="$1"
if (( v >= 25 )); then echo 20
elif (( v >= 13 )); then echo 14
elif (( v >= 5 )); then echo 8
elif (( v >= 1 )); then echo 3
else echo 0
fi
}
# Stage 2: TMDB vote_average × 10 (0-40)
_rating_score_s2() {
local v="$1"
if (( v >= 80 )); then echo 40
elif (( v >= 75 )); then echo 32
elif (( v >= 70 )); then echo 25
elif (( v >= 65 )); then echo 18
elif (( v >= 60 )); then echo 12
else echo 5
fi
}
# Stage 2: vote count (0-20)
_votes_score() {
local c="$1"
if (( c >= 10000 )); then echo 20
elif (( c >= 5000 )); then echo 15
elif (( c >= 1000 )); then echo 10
elif (( c >= 200 )); then echo 5
else echo 2
fi
}
# Stage 2: seed breadth (0-40)
_breadth_score() {
local seeds="$1"
if (( seeds >= 3 )); then echo 40
elif (( seeds == 2 )); then echo 25
else echo 10
fi
}
# ==============================================================================================
# ━━━ Fetch Emby Series Library ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY Sonarr Discovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━"
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) | lookback: ${LOOKBACK_DAYS}d | threshold: ${THRESHOLD}/100 | max: ${MAX_ADDS}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no shows will be added"
echo ""
echo "━━━ $ICON_SYNC Emby Series Library ━━━"
LIBRARIES_JSON=$(emby_api "Library/VirtualFolders") || { error "Could not fetch Emby libraries"; exit 1; }
CUTOFF_ISO=$(date -d "${LOOKBACK_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ')
TODAY_EPOCH=$(date +%s)
TODAY=$(date +%Y-%m-%d)
# EMBY_SERIES_BY_ID[emby_item_id] = tmdb_id — used to map episode.SeriesId → series TMDB ID
# EMBY_SERIES_NAME[tmdb_id] = name — used to display seed names in Stage 1
# EMBY_TVDB_IDS[tvdb_id] = 1 — used to filter already-owned shows in Stage 2
# EMBY_TMDB_IDS[tmdb_id] = 1 — used to filter already-owned shows in Stage 2
declare -A EMBY_SERIES_BY_ID
declare -A EMBY_SERIES_NAME
declare -A EMBY_TVDB_IDS
declare -A EMBY_TMDB_IDS
for lib_name in "${SONARR_EMBY_LIBRARIES[@]}"; do
lib_id=$(echo "$LIBRARIES_JSON" | jq -r --arg n "$lib_name" '.[] | select(.Name == $n) | .ItemId' 2>/dev/null)
if [[ -z "$lib_id" ]]; then
warn "Emby library not found: $lib_name"
continue
fi
log "Scanning library: $lib_name (ItemId: $lib_id)"
LIB_JSON=$(emby_api "Items?ParentId=${lib_id}&IncludeItemTypes=Series&Recursive=true&Fields=ProviderIds&Limit=5000") || {
warn "Could not fetch series from library: $lib_name"
continue
}
while IFS='|' read -r emby_id tmdb_id tvdb_id name; do
[[ -z "$emby_id" || "$emby_id" == "null" ]] && continue
[[ -n "$tmdb_id" && "$tmdb_id" != "null" ]] && {
EMBY_SERIES_BY_ID["$emby_id"]="$tmdb_id"
EMBY_TMDB_IDS["$tmdb_id"]=1
[[ -n "$name" && "$name" != "null" ]] && EMBY_SERIES_NAME["$tmdb_id"]="$name"
}
[[ -n "$tvdb_id" && "$tvdb_id" != "null" ]] && EMBY_TVDB_IDS["$tvdb_id"]=1
done < <(echo "$LIB_JSON" | jq -r '.Items[] |
[.Id, (.ProviderIds.Tmdb // ""), (.ProviderIds.Tvdb // ""), .Name] | join("|")
' 2>/dev/null)
done
log "${#EMBY_SERIES_BY_ID[@]} series in Emby TMDB index"
if [[ "${#EMBY_SERIES_BY_ID[@]}" -eq 0 ]]; then
warn "No series found in Emby libraries — nothing to seed from"
exit 0
fi
# ==============================================================================================
# ━━━ Fetch Emby Activity Log — Episode Plays ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Emby Watch History ━━━"
# Activity log entries: each playback.stop has ItemId (episode), UserId, Date.
# Track per-user episode plays so we can cap any single user's influence on seed scoring.
declare -A ITEM_USER_PLAYS # "episode_item_id|user_id" → play count
declare -A ITEM_LAST_PLAY # episode_item_id → ISO date of most recent play
ACTIVITY_JSON=$(emby_api "System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000") || {
error "Could not fetch Emby activity log"
exit 1
}
while IFS='|' read -r item_id user_id play_date; do
[[ -z "$item_id" || "$item_id" == "null" ]] && continue
[[ -z "$user_id" || "$user_id" == "null" ]] && user_id="unknown"
key="${item_id}|${user_id}"
ITEM_USER_PLAYS["$key"]=$(( ${ITEM_USER_PLAYS["$key"]:-0} + 1 ))
current="${ITEM_LAST_PLAY["$item_id"]:-}"
if [[ -z "$current" || "$play_date" > "$current" ]]; then
ITEM_LAST_PLAY["$item_id"]="$play_date"
fi
done < <(echo "$ACTIVITY_JSON" | jq -r '
.Items[] |
select(.Type == "playback.stop") |
select(.ItemId != null and .ItemId != "") |
[.ItemId, (.UserId // "unknown"), .Date] | join("|")
' 2>/dev/null)
# Deduplicate episode IDs for batch fetch
declare -A _UNIQUE_IDS
for key in "${!ITEM_USER_PLAYS[@]}"; do
episode_id="${key%%|*}"
_UNIQUE_IDS["$episode_id"]=1
done
ALL_EPISODE_IDS=("${!_UNIQUE_IDS[@]}")
unset _UNIQUE_IDS
log "${#ALL_EPISODE_IDS[@]} unique episode items in activity log"
# ==============================================================================================
# ━━━ Map Episodes → Series via Batch Fetch ━━━
# ==============================================================================================
# EPISODE_TO_SERIES[episode_item_id] = series_tmdb_id
declare -A EPISODE_TO_SERIES
if [[ "${#ALL_EPISODE_IDS[@]}" -gt 0 ]]; then
BATCH_SIZE=100
for (( _b=0; _b<${#ALL_EPISODE_IDS[@]}; _b+=BATCH_SIZE )); do
BATCH=("${ALL_EPISODE_IDS[@]:_b:BATCH_SIZE}")
IDS_CSV=$(printf '%s,' "${BATCH[@]}"); IDS_CSV="${IDS_CSV%,}"
ITEMS_DETAIL=$(emby_api "Items?Ids=${IDS_CSV}&Fields=SeriesId,Type&Limit=200") || {
warn "Could not fetch episode batch starting at $_b"
continue
}
while IFS='|' read -r item_id item_type series_emby_id; do
[[ "$item_type" != "Episode" ]] && continue
[[ -z "$series_emby_id" || "$series_emby_id" == "null" ]] && continue
series_tmdb="${EMBY_SERIES_BY_ID["$series_emby_id"]:-}"
[[ -z "$series_tmdb" ]] && continue
EPISODE_TO_SERIES["$item_id"]="$series_tmdb"
done < <(echo "$ITEMS_DETAIL" | jq -r '.Items[] |
[(.Id | tostring), .Type, (.SeriesId // "")] | join("|")
' 2>/dev/null)
done
fi
# Aggregate per-user episode counts per series
# SERIES_USER_PLAYS["series_tmdb_id|user_id"] = total episodes played by that user
# One key per (series, user) pair — safe to count for diversity and cap for volume
declare -A SERIES_USER_PLAYS
declare -A SERIES_LAST_PLAY
for key in "${!ITEM_USER_PLAYS[@]}"; do
episode_id="${key%%|*}"
user_id="${key#*|}"
series_tmdb="${EPISODE_TO_SERIES["$episode_id"]:-}"
[[ -z "$series_tmdb" ]] && continue
plays="${ITEM_USER_PLAYS[$key]}"
skey="${series_tmdb}|${user_id}"
SERIES_USER_PLAYS["$skey"]=$(( ${SERIES_USER_PLAYS["$skey"]:-0} + plays ))
play_date="${ITEM_LAST_PLAY["$episode_id"]:-}"
current="${SERIES_LAST_PLAY["$series_tmdb"]:-}"
if [[ -n "$play_date" && ( -z "$current" || "$play_date" > "$current" ) ]]; then
SERIES_LAST_PLAY["$series_tmdb"]="$play_date"
fi
done
# Pre-aggregate stats per series for scoring
declare -A SERIES_UNIQUE_USERS # series_tmdb → unique user count
declare -A SERIES_CAPPED_VOLUME # series_tmdb → sum of min(user_episodes, cap)
for key in "${!SERIES_USER_PLAYS[@]}"; do
series_tmdb="${key%%|*}"
plays="${SERIES_USER_PLAYS[$key]}"
capped=$(( plays > USER_EPISODE_CAP ? USER_EPISODE_CAP : plays ))
SERIES_UNIQUE_USERS["$series_tmdb"]=$(( ${SERIES_UNIQUE_USERS["$series_tmdb"]:-0} + 1 ))
SERIES_CAPPED_VOLUME["$series_tmdb"]=$(( ${SERIES_CAPPED_VOLUME["$series_tmdb"]:-0} + capped ))
done
SERIES_COUNT=${#SERIES_UNIQUE_USERS[@]}
log "$SERIES_COUNT series with recent episode activity"
if [[ "$SERIES_COUNT" -eq 0 ]]; then
warn "No recently watched series found — nothing to seed from"
exit 0
fi
# ==============================================================================================
# ━━━ Stage 1: Score Watched Series → Select Seeds ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Stage 1: Scoring Watched Series ━━━"
S1_SCORED=() # "score|tmdb_id|title"
for series_tmdb in "${!SERIES_UNIQUE_USERS[@]}"; do
n_users="${SERIES_UNIQUE_USERS[$series_tmdb]}"
capped_vol="${SERIES_CAPPED_VOLUME[$series_tmdb]:-0}"
last_play="${SERIES_LAST_PLAY[$series_tmdb]:-}"
series_name="${EMBY_SERIES_NAME[$series_tmdb]:-TMDB:${series_tmdb}}"
last_epoch=$(date -d "$last_play" +%s 2>/dev/null || echo "$TODAY_EPOCH")
days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
diversity_s=$(_diversity_score "$n_users")
recency_s=$(_recency_score "$days_ago")
volume_s=$(_volume_score "$capped_vol")
total=$(( diversity_s + recency_s + volume_s ))
log " [${total}] $series_name | users: $n_users | vol(capped): $capped_vol | ${days_ago}d ago"
S1_SCORED+=("${total}|${series_tmdb}|${series_name}")
done
IFS=$'\n' S1_SORTED=($(printf '%s\n' "${S1_SCORED[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
echo ""
echo "━━━ $ICON_SUMMARY Stage 1 Results (top ${MAX_SEEDS} seeds) ━━━"
SEEDS_TMDB=()
for _entry in "${S1_SORTED[@]}"; do
(( ${#SEEDS_TMDB[@]} >= MAX_SEEDS )) && break
_score="${_entry%%|*}"
_rest="${_entry#*|}"
_tmdb="${_rest%%|*}"
_name="${_rest#*|}"
SEEDS_TMDB+=("$_tmdb")
n_users="${SERIES_UNIQUE_USERS[$_tmdb]:-0}"
capped_vol="${SERIES_CAPPED_VOLUME[$_tmdb]:-0}"
printf " Seed [%3s] %-40s | users: %s | vol: %s\n" "$_score" "$_name" "$n_users" "$capped_vol"
done
if [[ "${#SEEDS_TMDB[@]}" -eq 0 ]]; then
warn "No series qualify as seeds — nothing to discover from"
exit 0
fi
log "${#SEEDS_TMDB[@]} seed(s) selected"
# ==============================================================================================
# ━━━ Stage 2: TMDB TV Recommendations ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Stage 2: TMDB Recommendations ━━━"
declare -A CANDIDATE_SEEDS # tmdb_id → seed count
declare -A CANDIDATE_TITLE # tmdb_id → show name
declare -A CANDIDATE_RATING # tmdb_id → vote_avg_int
declare -A CANDIDATE_VOTES # tmdb_id → vote_count
for seed_tmdb in "${SEEDS_TMDB[@]}"; do
log " Seed TMDB: $seed_tmdb"
RECS_JSON=$(_tmdb_tv_recommendations "$seed_tmdb")
if [[ -z "$RECS_JSON" ]] || echo "$RECS_JSON" | jq -e '.results == [] or .results == null' >/dev/null 2>&1; then
warn " No TMDB recommendations for TMDB:$seed_tmdb"
continue
fi
while IFS='|' read -r rec_id rec_name rec_avg rec_votes; do
[[ -z "$rec_id" || "$rec_id" == "null" ]] && continue
[[ "$rec_id" == "$seed_tmdb" ]] && continue
vote_avg_int=$(echo "$rec_avg" | awk '{printf "%d", $1 * 10 + 0.5}' 2>/dev/null)
vote_avg_int=${vote_avg_int:-0}
rec_votes=${rec_votes:-0}
CANDIDATE_SEEDS["$rec_id"]=$(( ${CANDIDATE_SEEDS["$rec_id"]:-0} + 1 ))
CANDIDATE_TITLE["$rec_id"]="$rec_name"
CANDIDATE_RATING["$rec_id"]="$vote_avg_int"
CANDIDATE_VOTES["$rec_id"]="$rec_votes"
done < <(echo "$RECS_JSON" | jq -r '.results[] |
[
(.id | tostring),
.name,
(.vote_average | tostring),
(.vote_count | tostring)
] | join("|")' 2>/dev/null)
done
CANDIDATE_COUNT=${#CANDIDATE_SEEDS[@]}
log "$CANDIDATE_COUNT discovery candidates from TMDB"
if [[ "$CANDIDATE_COUNT" -eq 0 ]]; then
warn "No candidates returned from TMDB — check API key"
exit 0
fi
# ==============================================================================================
# ━━━ Fetch Existing Sonarr Library ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Existing Libraries ━━━"
SONARR_SERIES_JSON=$(_sonarr_get "series") || { error "Could not fetch Sonarr library"; exit 1; }
declare -A SONARR_TVDB # tvdb_id → 1
declare -A SONARR_TMDB # tmdb_id → 1 (Sonarr v4 exposes tmdbId)
while IFS='|' read -r tvdb_id tmdb_id; do
[[ -n "$tvdb_id" && "$tvdb_id" != "0" ]] && SONARR_TVDB["$tvdb_id"]=1
[[ -n "$tmdb_id" && "$tmdb_id" != "0" ]] && SONARR_TMDB["$tmdb_id"]=1
done < <(echo "$SONARR_SERIES_JSON" | jq -r '.[] |
[(.tvdbId // 0 | tostring), (.tmdbId // 0 | tostring)] | join("|")
' 2>/dev/null)
log "${#SONARR_TVDB[@]} series in Sonarr | ${#EMBY_TMDB_IDS[@]} series in Emby"
_in_sonarr() { [[ "${SONARR_TVDB["$1"]+x}" || "${SONARR_TMDB["$2"]+x}" ]]; }
_in_emby() { [[ "${EMBY_TMDB_IDS["$1"]+x}" || "${EMBY_TVDB_IDS["$2"]+x}" ]]; }
# ==============================================================================================
# ━━━ Score Stage 2 Candidates ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Stage 2: Scoring Candidates ━━━"
ACCEPT_LIST=()
REJECT_LIST=()
ALREADY_KNOWN=0
SKIP_COOLDOWN=0
SKIP_QUALITY=0
for rec_tmdb in "${!CANDIDATE_SEEDS[@]}"; do
seed_count="${CANDIDATE_SEEDS["$rec_tmdb"]}"
title="${CANDIDATE_TITLE["$rec_tmdb"]}"
vote_avg_int="${CANDIDATE_RATING["$rec_tmdb"]}"
vote_count="${CANDIDATE_VOTES["$rec_tmdb"]}"
# Check Sonarr by TMDB (tvdb unknown at this point — we resolve it only at add time)
if [[ "${SONARR_TMDB["$rec_tmdb"]+x}" ]]; then
(( ALREADY_KNOWN++ ))
log " $ICON_SKIP In Sonarr: $title"
continue
fi
if [[ "${EMBY_TMDB_IDS["$rec_tmdb"]+x}" ]]; then
(( ALREADY_KNOWN++ ))
log " $ICON_SKIP In Emby: $title"
continue
fi
# Hard quality floor — skip before cooldown to avoid polluting history
if (( vote_count < MIN_VOTE_COUNT || vote_avg_int < MIN_RATING )); then
(( SKIP_QUALITY++ ))
log " $ICON_SKIP Below quality floor (rating: $(_fmt_rating "$vote_avg_int") | votes: $vote_count): $title"
continue
fi
if [[ -f "$HISTORY_FILE" ]]; then
last_rejection=$(grep -i "^REJECT|${rec_tmdb}|" "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d'|' -f3)
if [[ -n "$last_rejection" ]]; then
last_epoch=$(date -d "$last_rejection" +%s 2>/dev/null || echo 0)
days_since=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
if (( days_since < REJECT_COOLDOWN )); then
(( SKIP_COOLDOWN++ ))
log " $ICON_SKIP Cooldown (rejected ${days_since}d ago): $title"
continue
fi
fi
fi
breadth_s=$(_breadth_score "$seed_count")
rating_s=$(_rating_score_s2 "$vote_avg_int")
votes_s=$(_votes_score "$vote_count")
total=$(( breadth_s + rating_s + votes_s ))
entry="${total}|${rec_tmdb}|${title}|${seed_count}|${vote_avg_int}|${vote_count}"
if (( total >= THRESHOLD )); then
ACCEPT_LIST+=("$entry")
else
REJECT_LIST+=("$entry")
fi
log " [${total}] $title (seeds: $seed_count | rating: $(_fmt_rating "$vote_avg_int") | votes: $vote_count)"
done
# ==============================================================================================
# ━━━ Results ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SUMMARY Stage 2 Results (threshold: ${THRESHOLD}, max: ${MAX_ADDS}) ━━━"
IFS=$'\n' _ALL_ACCEPTS=($(printf '%s\n' "${ACCEPT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
IFS=$'\n' SORTED_REJECTS=($(printf '%s\n' "${REJECT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
SORTED_ACCEPTS=("${_ALL_ACCEPTS[@]:0:$MAX_ADDS}")
for (( _i=MAX_ADDS; _i<${#_ALL_ACCEPTS[@]}; _i++ )); do
SORTED_REJECTS+=("${_ALL_ACCEPTS[$_i]}")
done
_print_row() {
local label="$1" entry="$2"
IFS='|' read -r score rec_tmdb title seeds avg_int votes <<< "$entry"
local rating_fmt
rating_fmt=$(_fmt_rating "$avg_int")
printf " %-8s [%3s] %-45s seeds: %s | rating: %s | votes: %s\n" \
"$label" "$score" "$title" "$seeds" "$rating_fmt" "$votes"
}
for entry in "${SORTED_ACCEPTS[@]}"; do [[ -n "$entry" ]] && _print_row "ACCEPT" "$entry"; done
for entry in "${SORTED_REJECTS[@]}"; do [[ -n "$entry" ]] && _print_row "REJECT" "$entry"; done
echo ""
echo " Already in library: $ALREADY_KNOWN | Below quality floor: $SKIP_QUALITY | Cooldown: $SKIP_COOLDOWN"
echo " ACCEPT: ${#SORTED_ACCEPTS[@]} | REJECT: ${#SORTED_REJECTS[@]}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN complete — run without --dry-run to add accepted shows"
exit 0
fi
if [[ "${#SORTED_ACCEPTS[@]}" -eq 0 ]]; then
log "No shows above threshold — nothing to add"
for entry in "${SORTED_REJECTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score rec_tmdb title _ <<< "$entry"
echo "REJECT|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
done
exit 0
fi
# ==============================================================================================
# ━━━ Add to Sonarr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Adding to Sonarr ━━━"
SONARR_ROOT=$(_sonarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null)
if [[ -z "$SONARR_ROOT" ]]; then error "Could not determine Sonarr root folder"; exit 1; fi
QUALITY_ID=$(_sonarr_get "qualityprofile" | jq -r '.[0].id' 2>/dev/null)
log "Root folder: $SONARR_ROOT | Quality profile: $QUALITY_ID | Monitor: $MONITOR_MODE"
ADDED=0
FAILED=0
for entry in "${SORTED_ACCEPTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score rec_tmdb title seeds avg_int votes <<< "$entry"
# Resolve TVDB ID via TMDB external_ids — Sonarr lookup requires tvdb: term
EXT_JSON=$(_tmdb_external_ids "$rec_tmdb")
tvdb_id=$(echo "$EXT_JSON" | jq -r '.tvdb_id // empty' 2>/dev/null)
if [[ -z "$tvdb_id" ]]; then
warn " $ICON_WARN No TVDB ID from TMDB for: $title (TMDB: $rec_tmdb)"
echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
continue
fi
# Check if already in Sonarr by TVDB (may have been added since we loaded the library)
if [[ "${SONARR_TVDB["$tvdb_id"]+x}" ]]; then
log " $ICON_SKIP Already in Sonarr (TVDB $tvdb_id): $title"
(( ALREADY_KNOWN++ ))
continue
fi
# Check if already in Emby by TVDB
if [[ "${EMBY_TVDB_IDS["$tvdb_id"]+x}" ]]; then
log " $ICON_SKIP Already in Emby (TVDB $tvdb_id): $title"
(( ALREADY_KNOWN++ ))
continue
fi
LOOKUP=$(_sonarr_lookup "tvdb:$tvdb_id")
if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then
warn " $ICON_WARN No match in Sonarr lookup: $title (TVDB: $tvdb_id)"
echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
continue
fi
SERIES_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null)
SONARR_TITLE=$(echo "$SERIES_DATA" | jq -r '.title // ""' 2>/dev/null)
if [[ -z "$SONARR_TITLE" ]]; then
warn " $ICON_WARN Empty result for: $title"
echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
continue
fi
PAYLOAD=$(echo "$SERIES_DATA" | jq \
--arg root "$SONARR_ROOT" \
--argjson qid "$QUALITY_ID" \
--arg mon "$MONITOR_MODE" \
'. + {
rootFolderPath: $root,
qualityProfileId: $qid,
monitored: true,
seasonFolder: true,
addOptions: {
monitor: $mon,
searchForMissingEpisodes: false,
searchForCutoffUnmetEpisodes: false
}
}' 2>/dev/null)
RESULT=$(_sonarr_post "$PAYLOAD")
if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then
SERIES_ID=$(echo "$RESULT" | jq -r '.id')
_sonarr_command "{\"name\":\"SeriesSearch\",\"seriesId\":${SERIES_ID}}" >/dev/null
log " $ICON_DONE Added: $SONARR_TITLE (score: $score | seeds: $seeds | TVDB: $tvdb_id)"
echo "ACCEPT|${rec_tmdb}|${TODAY}|${SONARR_TITLE}" >> "$HISTORY_FILE" 2>/dev/null
(( ADDED++ ))
else
warn " $ICON_WARN Failed to add: $title"
log " $(echo "$RESULT" | head -c 200)"
echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
fi
done
for entry in "${SORTED_REJECTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score rec_tmdb title _ <<< "$entry"
echo "REJECT|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
done
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY DISCOVERY COMPLETE ━━━━━"
echo " $ICON_DONE Added: $ADDED"
[[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED"
echo " $ICON_SKIP Rejected: ${#SORTED_REJECTS[@]}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ADDED" -gt 0 ]] && notify \
"$ADDED show(s) added to Sonarr via discovery on $(hostname)" \
"Sonarr Discovery" "normal"
exit 0
-602
View File
@@ -1,602 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ================================= Radarr Cleanup =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Delete orphaned movie files not tracked by Radarr. Queries the API for all
# tracked movie file paths, walks the library on disk, and removes anything
# untracked that is old enough to be past the import window. Triggers an Emby
# library clean after each deletion run so ghost entries disappear immediately.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Every file encountered on disk is classified into one of five categories:
#
# TRACKED — Radarr API knows this exact path → leave it alone
# PROTECTED — matches RADARR_PROTECTED_PATTERNS → never delete
# ORPHAN — video file, not tracked, older than RADARR_ORPHAN_AGE → delete
# JUNK — not a video extension, not protected → delete regardless of age
# RECENT — not tracked, under RADARR_ORPHAN_AGE → skip (may be mid-import)
#
# Radarr generates movie artwork (*.jpg), metadata (*.nfo), and manages subtitles
# (*.srt, *.sub, *.ass) but does NOT include these in its tracked file API response.
# Without PROTECTED classification these would be deleted — breaking Radarr and
# Emby metadata display.
#
# After deletions: notify_emby_scan() triggers Emby "Clean Missing Files" task.
# Emby removes ghost entries immediately — no user-facing file-not-found errors.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# API as Ground Truth
# What Radarr tracks is authoritative. Files not in the API response are
# orphans — Radarr has no record of them and they serve no purpose.
# The script never infers ownership from directory structure alone.
#
# Age Gate Before Deletion
# Files under RADARR_ORPHAN_AGE are left alone regardless of tracked status.
# Radarr's import pipeline writes files before registering them — acting
# immediately would delete files mid-import.
#
# Emby Cleanup Is Part of the Job
# Deleting a file without telling Emby leaves ghost entries that show as
# broken items. Triggering the Emby clean is not optional — it completes
# the deletion from the user's perspective.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Six gates — ALL must pass before any file is touched:
# 1. Container running and not starting/unhealthy
# 2. API reachable
# 3. API version matches RADARR_VERSION_MAJOR in master.conf
# 4. Movie count > 0
# 5. Tracked file count > 0
# 6. Deletion size < RADARR_MAX_DELETE_GB — or --i-know-what-im-doing required
#
# acquire_lock "wait" — large scans take time, wait for previous run to finish
# jq + curl validation — exits if either tool missing
# DOCKER_TIMEOUT — container checks protected against daemon hangs
# notify_emby_scan() — triggers Emby clean after deletion
# platform_require_cmd — notify script validated before use
# Silent by default — orphans/junk warn(), clean library logs silently
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_RADARR_URL / HOST*_RADARR_API_KEY / HOST*_RADARR_MOVIES_ROOT
# HOST*_RADARR_PATH_MAP — container path → host path translation
# All aliased by detect_hosts() — script uses unprefixed names
#
# master.conf
#
# RADARR_ORPHAN_AGE — days before untracked file eligible for deletion
# RADARR_MAX_DELETE_GB — require --i-know-what-im-doing above this
# RADARR_EXTENSIONS — video file extensions for orphan classification
# RADARR_PROTECTED_PATTERNS — file patterns never deleted
# RADARR_VERSION_MAJOR — expected Radarr major version for API safety check
# RADARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)
# ARR_CLEANUP_STATS — stats file path (read by coffee report)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# radarr_cleanup.sh — normal run
# radarr_cleanup.sh --dry-run — preview, no deletions
# radarr_cleanup.sh --log — verbose output
# radarr_cleanup.sh --status — show config and exit
# radarr_cleanup.sh --i-know-what-im-doing — bypass size threshold
# radarr_cleanup.sh --i-know-what-im-doing --skip-age-check — NUCLEAR MODE
#
# NUCLEAR MODE: both flags bypass age check AND size threshold. User accepts full
# responsibility — the flag name is long and annoying by design.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Special flag pre-processing ───────────────────────────────────────────────────────────────
I_KNOW=false
SKIP_AGE_CHECK=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--i-know-what-im-doing) I_KNOW=true ;;
--skip-age-check) SKIP_AGE_CHECK=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────
if [[ "$I_KNOW" == true ]] && [[ "$SKIP_AGE_CHECK" == true ]] && [[ "$DRY_RUN" != true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ WARNING — NUCLEAR MODE ACTIVE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Flags: --i-know-what-im-doing --skip-age-check"
echo " Age check: BYPASSED — deletes on first pass"
echo " Size threshold: BYPASSED — no GB limit"
echo " Data recovery: NOT POSSIBLE after deletion"
echo ""
echo " Review --dry-run output before proceeding."
echo " You have 10 seconds to cancel (Ctrl+C)..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
sleep 10
echo " Proceeding..."
echo ""
fi
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for Radarr API calls"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
notify "Radarr cleanup failed on $(hostname) — jq not installed" "Radarr Cleanup" "warning"
exit 1
fi
acquire_lock "wait"
TMP_DIR="/tmp/radarr_cleanup_$$"
mkdir -p "$TMP_DIR"
trap "_release_all_locks; rm -rf $TMP_DIR" EXIT
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID and aliases RADARR_URL, RADARR_API_KEY, RADARR_MOVIES_ROOT
detect_hosts
DOCKER_TIMEOUT=15
RADARR_CONTAINER="Radarr"
# Build path map from MY_ID's Radarr path map
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_RADARR_PATH_MAP"
eval "for key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$key\"]=\"\${${local_path_map_var}[\$key]}\"
done"
require_var RADARR_URL
require_var RADARR_API_KEY
require_var RADARR_MOVIES_ROOT
if [[ ! -d "$RADARR_MOVIES_ROOT" ]]; then
error "Movies root not found: $RADARR_MOVIES_ROOT"
notify "Radarr cleanup failed on $(hostname) — movies root not found: $RADARR_MOVIES_ROOT" \
"Radarr Cleanup" "warning"
exit 1
fi
log "$ICON_GEAR Config: url=${RADARR_URL} root=${RADARR_MOVIES_ROOT}"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $RADARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
[[ "$SKIP_AGE_CHECK" == true ]] && warn "OVERRIDE — --skip-age-check active — age check bypassed"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Radarr URL: $RADARR_URL"
echo "$ICON_GEAR Movies root: $RADARR_MOVIES_ROOT"
echo "$ICON_TIME Orphan age: ${RADARR_ORPHAN_AGE} days"
echo "$ICON_GEAR Max delete: ${RADARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)"
echo "$ICON_GEAR Radarr ver: v${RADARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Extensions: ${RADARR_EXTENSIONS[*]}"
echo "$ICON_GEAR Protected patterns: ${RADARR_PROTECTED_PATTERNS[*]}"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "$ICON_GEAR I know: $I_KNOW"
echo "$ICON_GEAR Skip age check: $SKIP_AGE_CHECK"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Safety Layer 1 — Container Health ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
CONTAINER_RUNNING=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Running}}' "$RADARR_CONTAINER" 2>/dev/null)
if [[ "$CONTAINER_RUNNING" != "true" ]]; then
error "$RADARR_CONTAINER is not running — aborting"
notify "Radarr cleanup aborted on $(hostname) — container not running" \
"Radarr Cleanup" "warning"
exit 1
fi
CONTAINER_HEALTH=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Health.Status}}' "$RADARR_CONTAINER" 2>/dev/null)
case "$CONTAINER_HEALTH" in
healthy) info "$RADARR_CONTAINER is healthy" ;;
"") info "$RADARR_CONTAINER has no health check — proceeding" ;;
starting)
error "$RADARR_CONTAINER is still starting — aborting"
notify "Radarr cleanup aborted on $(hostname) — container still starting" \
"Radarr Cleanup" "warning"
exit 1 ;;
unhealthy)
error "$RADARR_CONTAINER is unhealthy — aborting"
notify "Radarr cleanup aborted on $(hostname) — container unhealthy" \
"Radarr Cleanup" "warning"
exit 1 ;;
*) warn "$RADARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution" ;;
esac
info "Safety layer 1 passed — container healthy"
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
radarr_api() {
local endpoint="$1"
local response http_code body
response=$(curl -sf \
--max-time 30 \
-H "X-Api-Key: $RADARR_API_KEY" \
-w "\n%{http_code}" \
"${RADARR_URL}/api/v3/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "Radarr API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
is_video_file() {
local ext="${1##*.}"
ext="${ext,,}"
for valid_ext in "${RADARR_EXTENSIONS[@]}"; do
[[ "$ext" == "$valid_ext" ]] && return 0
done
return 1
}
is_protected_file() {
local filename
filename=$(basename "$1")
for pattern in "${RADARR_PROTECTED_PATTERNS[@]}"; do
# shellcheck disable=SC2254
case "$filename" in
$pattern) return 0 ;;
esac
done
return 1
}
format_bytes() {
local bytes=$1
if (( bytes > 1073741824 )); then
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
elif (( bytes > 1048576 )); then
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
else
echo "${bytes}B"
fi
}
# ==============================================================================================
# ━━━ Pre-flight: Radarr Import Scan ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Pre-flight: Radarr Import Scan ━━━"
# Fetch root folders from Radarr API and translate container paths to host paths
mapfile -t SCAN_ROOTS < <(
radarr_api "rootfolder" | \
jq -r '.[].path' 2>/dev/null | \
while IFS= read -r cp; do translate_path "$cp"; done
)
if [[ "${#SCAN_ROOTS[@]}" -eq 0 ]]; then
error "No root folders returned from Radarr API — aborting"
notify "Radarr cleanup aborted on $(hostname) — no root folders from API" \
"Radarr Cleanup" "warning"
exit 1
fi
info "Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}"
info "Triggering ProcessMonitoredDownloads pre-flight"
SCAN_PAYLOAD='{"name": "ProcessMonitoredDownloads"}'
SCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $RADARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$SCAN_PAYLOAD" \
"${RADARR_URL}/api/v3/command" 2>/dev/null)
SCAN_CMD_ID=$(echo "$SCAN_RESPONSE" | jq -r '.id // empty' 2>/dev/null)
if [[ -z "$SCAN_CMD_ID" ]]; then
warn "Could not trigger import scan — proceeding without pre-flight"
else
info "Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion..."
POLL_TIMEOUT=${RADARR_IMPORT_SCAN_TIMEOUT:-600}
POLLED=0
while [[ "$POLLED" -lt "$POLL_TIMEOUT" ]]; do
SCAN_STATUS=$(curl -sf --max-time 10 \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/command/${SCAN_CMD_ID}" 2>/dev/null | \
jq -r '.status // empty' 2>/dev/null)
case "$SCAN_STATUS" in
completed) info "Import scan complete ✅"; break ;;
failed) warn "Import scan reported failed — proceeding anyway"; break ;;
esac
sleep 10
(( POLLED += 10 ))
[[ $(( POLLED % 60 )) -eq 0 ]] && log " Still scanning... (${POLLED}s elapsed)"
done
[[ "$POLLED" -ge "$POLL_TIMEOUT" ]] && \
warn "Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway"
fi
# ==============================================================================================
# ━━━ Fetch Radarr Tracked Files ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Fetching Radarr Tracked Files ━━━"
# Safety Layer 2 — API reachability
if ! check_api "$RADARR_URL" "Radarr" 10; then
notify "Radarr cleanup aborted on $(hostname) — API unreachable" "Radarr Cleanup" "warning"
exit 1
fi
# Safety Layer 3 — API version check
check_arr_version "$RADARR_URL" "$RADARR_API_KEY" "v3" "$RADARR_VERSION_MAJOR" "Radarr" || exit 1
info "Querying Radarr API..."
# Fetch all movies
MOVIES_RESPONSE=$(radarr_api "movie") || {
error "Failed to fetch movies from Radarr"
notify "Radarr cleanup failed on $(hostname) — could not fetch movies" \
"Radarr Cleanup" "warning"
exit 1
}
MOVIE_IDS=$(echo "$MOVIES_RESPONSE" | jq -r '.[].id' 2>/dev/null)
MOVIE_COUNT=$(echo "$MOVIE_IDS" | grep -c "." 2>/dev/null || echo 0)
# Safety Layer 4 — movie count > 0
if [[ "$MOVIE_COUNT" -eq 0 ]]; then
error "API returned 0 movies — aborting to prevent mass deletion"
notify "Radarr cleanup aborted on $(hostname) — 0 movies returned" \
"Radarr Cleanup" "warning"
exit 1
fi
info "Found $MOVIE_COUNT movies — fetching movie files..."
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
> "$TRACKED_FILE"
MOVIE_INDEX=0
while IFS= read -r movie_id; do
[[ -z "$movie_id" ]] && continue
(( MOVIE_INDEX++ ))
[[ $(( MOVIE_INDEX % 100 )) -eq 0 ]] && \
log "Fetching files: $MOVIE_INDEX/$MOVIE_COUNT movies..."
MOVIE_FILES=$(radarr_api "moviefile?movieId=${movie_id}" 2>/dev/null)
if [[ -n "$MOVIE_FILES" ]]; then
while IFS= read -r api_path; do
[[ -z "$api_path" ]] && continue
translate_path "$api_path" >> "$TRACKED_FILE"
done < <(echo "$MOVIE_FILES" | jq -r '.[].path // .path' 2>/dev/null)
fi
done <<< "$MOVIE_IDS"
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
# Eliminates the main performance bottleneck for large libraries
declare -A TRACKED_MAP
while IFS= read -r _tracked_path; do
[[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1
done < "$TRACKED_FILE"
unset _tracked_path
info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths"
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
# Safety Layer 5 — tracked count > 0
if [[ "$TRACKED_COUNT" -eq 0 ]]; then
error "API returned 0 tracked files — aborting to prevent mass deletion"
notify "Radarr cleanup aborted on $(hostname) — 0 tracked files returned" \
"Radarr Cleanup" "warning"
exit 1
fi
info "$MOVIE_COUNT movies | $TRACKED_COUNT tracked movie files"
# ==============================================================================================
# ━━━ Scan Movies Root ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning Movies Root ━━━"
info "Root: $RADARR_MOVIES_ROOT | Orphan age: ${RADARR_ORPHAN_AGE} days"
START=$(date +%s)
ORPHAN_COUNT=0
JUNK_COUNT=0
RECENT_COUNT=0
PROTECTED_COUNT=0
ORPHAN_BYTES=0
JUNK_BYTES=0
AGE_SECONDS=$(( RADARR_ORPHAN_AGE * 86400 ))
NOW=$(date +%s)
MAX_DELETE_BYTES=$(awk "BEGIN {printf \"%d\", $RADARR_MAX_DELETE_GB * 1073741824}")
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
if [[ -n "${TRACKED_MAP[$filepath]:-}" ]]; then
log "TRACKED: $filepath"
continue
fi
if is_protected_file "$filepath"; then
log "$ICON_PROTECTED PROTECTED: $filepath"
(( PROTECTED_COUNT++ ))
continue
fi
FILE_SIZE=$(stat -c%s "$filepath" 2>/dev/null || echo 0)
if is_video_file "$filepath"; then
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_AGE_CHECK" != true ]]; then
log "RECENT (skipping): $filepath"
(( RECENT_COUNT++ ))
continue
fi
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
fi
done < <(
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && find "$host_path" -type f 2>/dev/null
done | sort -u
)
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
# ==============================================================================================
# ━━━ Safety Layer 6 — Deletion Size Threshold ━━━
# ==============================================================================================
if [[ "$TOTAL_DELETE_BYTES" -gt "$MAX_DELETE_BYTES" ]]; then
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $TOTAL_DELETE_BYTES / 1073741824}")
if [[ "$I_KNOW" != true ]]; then
echo ""
error "Deletion would exceed ${RADARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted"
error "Review ORPHAN lines above carefully before proceeding"
error "Rerun with: --i-know-what-im-doing"
error "To also bypass age check: add --skip-age-check"
notify "Radarr cleanup halted on $(hostname)${TOTAL_HUMAN} requires --i-know-what-im-doing" \
"Radarr Cleanup" "warning"
exit 1
else
warn "OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
[[ -n "${TRACKED_MAP[$filepath]:-}" ]] && continue
is_protected_file "$filepath" && continue
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if is_video_file "$filepath"; then
[[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && \
[[ "$SKIP_AGE_CHECK" != true ]] && continue
fi
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < <(
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && find "$host_path" -type f 2>/dev/null
done | sort -u
)
info "Cleaning up empty folders..."
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && \
find "$host_path" -mindepth 1 -type d -empty -delete 2>/dev/null
done
info "Empty folders removed"
fi
END=$(date +%s)
ORPHAN_HUMAN=$(format_bytes "$ORPHAN_BYTES")
JUNK_HUMAN=$(format_bytes "$JUNK_BYTES")
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR CLEANUP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Tracked: $TRACKED_COUNT files ($MOVIE_COUNT movies)"
echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles, metadata)"
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${RADARR_ORPHAN_AGE} days)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no files deleted"
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
notify "Radarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
"Radarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Write stats for sunday_morning_coffee_report.sh
if [[ "$DRY_RUN" == false ]] && [[ -n "${ARR_CLEANUP_STATS:-}" ]]; then
echo "$(date '+%Y-%m-%d')|radarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}" \
>> "$ARR_CLEANUP_STATS" 2>/dev/null || true
fi
exit 0
-269
View File
@@ -1,269 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ============================= Radarr — TMDb Removed ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Remove movies from Radarr that TMDb has dropped. Radarr marks these with
# status="deleted" — they generate health errors and can never be monitored
# or downloaded. Most are announced-but-never-released films delisted before
# release.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Queries Radarr API for movies with status="deleted" (TMDb removal marker)
# Reports each entry with file status and size
# Removes the movie record from Radarr
# Optionally deletes associated files (disabled by default — most have none)
# Optionally adds to Radarr's import exclusion list (default: true — prevents re-add)
#
# Files are NOT deleted by default — use --delete-files to also remove from disk.
# Per-deletion output is always visible — deletions are never silently swallowed.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Health Error Hygiene
# status="deleted" entries can never be monitored or downloaded — they only
# generate persistent health errors. Removing them is maintenance, not
# data loss: the content never existed on disk for most of these entries.
#
# Conservative File Handling
# Files are not deleted by default because most TMDb-removed entries are
# announced-but-never-released films with no files. The --delete-files flag
# is an explicit opt-in, not the default path.
#
# Exclusion List Prevents Re-add
# Removed entries are added to Radarr's import exclusion list by default.
# Without this, the same deleted entry can be re-added by lists or searches
# and immediately generate the same health error again.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# detect_hosts() — exits cleanly if RADARR_URL empty (Radarr not on this host)
# API pre-flight — verifies Radarr reachable before querying
# Dry-run mode — full preview without removing anything
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# RADARR_DROPPED_ADD_EXCLUSION — add removed movies to import exclusion (default: true)
#
# host*.conf
#
# HOST1_RADARR_URL / HOST1_RADARR_API_KEY
# Aliased by detect_hosts() — script uses RADARR_URL / RADARR_API_KEY
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# radarr_tmdb_removed.sh — remove records, keep files, add exclusion
# radarr_tmdb_removed.sh --delete-files — also delete files from disk
# radarr_tmdb_removed.sh --dry-run — preview without removing anything
# radarr_tmdb_removed.sh --log — verbose output
# radarr_tmdb_removed.sh --status — show config and exit
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Parse --delete-files before standard parse_args ───────────────────────────────────────────
DELETE_FILES=false
FILTERED_ARGS=()
for arg in "$@"; do
if [[ "$arg" == "--delete-files" ]]; then
DELETE_FILES=true
else
FILTERED_ARGS+=("$arg")
fi
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found"
exit 1
fi
acquire_lock
detect_hosts
if [[ -z "$RADARR_URL" ]]; then
echo "Radarr not configured for $MY_ID — skipping"
exit 0
fi
ADD_EXCLUSION="${RADARR_DROPPED_ADD_EXCLUSION:-true}"
log "$ICON_GEAR Config: url=${RADARR_URL} add-exclusion=${ADD_EXCLUSION} delete-files=${DELETE_FILES:-false}"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $RADARR_URL"
[[ "$DELETE_FILES" == true ]] && warn "DELETE FILES MODE — files will be removed from disk"
[[ "$DELETE_FILES" == false ]] && echo " Files: records only (use --delete-files to also remove from disk)"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Radarr URL: $RADARR_URL"
echo "$ICON_GEAR Add exclusion: $ADD_EXCLUSION"
echo "$ICON_GEAR Delete files: $DELETE_FILES"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Query Radarr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Querying Radarr ━━━"
if ! curl -sf --connect-timeout 5 --max-time 10 \
"$RADARR_URL/api/v3/system/status?apikey=$RADARR_API_KEY" | jq -e '.version' >/dev/null 2>&1; then
error "Radarr API unreachable at $RADARR_URL"
exit 1
fi
MOVIES=$(curl -sf --connect-timeout 5 --max-time 30 \
"$RADARR_URL/api/v3/movie?apikey=$RADARR_API_KEY" 2>/dev/null)
if [[ -z "$MOVIES" || "$MOVIES" == "null" ]]; then
error "Radarr movie API returned empty"
exit 1
fi
TOTAL=$(echo "$MOVIES" | jq '. | length')
DROPPED=$(echo "$MOVIES" | jq '[.[] | select(.status == "deleted")] | length')
echo " $TOTAL movies total — $DROPPED dropped from TMDb"
if [[ "$DROPPED" -eq 0 ]]; then
echo "No TMDb-removed movies found — nothing to do"
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR TMDB REMOVED SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_DONE Status: nothing to remove"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Remove Dropped Movies ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_TRASH Remove TMDb-Dropped Movies ━━━"
START=$(date +%s)
REMOVED=()
FAILED=()
FILES_DELETED=0
FILES_SKIPPED=0
while IFS=$'\t' read -r id title year tmdb_id has_file file_size; do
[[ -z "$id" ]] && continue
SIZE_HUMAN=""
if [[ "$has_file" == "true" && "$file_size" -gt 0 ]]; then
SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $file_size / 1073741824}")
log "$ICON_WARN $title ($year) [tmdbid $tmdb_id] — HAS FILE: $SIZE_HUMAN"
else
log "$ICON_TRASH $title ($year) [tmdbid $tmdb_id] — no file"
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove: $title"
[[ "$DELETE_FILES" == true && "$has_file" == "true" ]] && \
warn "DRY RUN — would delete file: $SIZE_HUMAN"
REMOVED+=("$title")
continue
fi
DELETE_PARAM="false"
if [[ "$DELETE_FILES" == true && "$has_file" == "true" ]]; then
DELETE_PARAM="true"
fi
RESP=$(curl -sf -X DELETE --connect-timeout 5 --max-time 15 \
"$RADARR_URL/api/v3/movie/${id}?deleteFiles=${DELETE_PARAM}&addImportExclusion=${ADD_EXCLUSION}&apikey=$RADARR_API_KEY" \
2>/dev/null)
CURL_EXIT=$?
if [[ "$CURL_EXIT" -eq 0 ]]; then
echo " Removed from Radarr ✅"
REMOVED+=("$title")
if [[ "$DELETE_PARAM" == "true" ]]; then
(( FILES_DELETED++ ))
elif [[ "$has_file" == "true" ]]; then
(( FILES_SKIPPED++ ))
fi
else
warn " Failed to remove $title (curl exit $CURL_EXIT)"
FAILED+=("$title")
fi
done < <(echo "$MOVIES" | jq -r '
.[] | select(.status == "deleted") |
[
(.id | tostring),
.title,
(.year | tostring),
(.tmdbId | tostring),
(if .hasFile then "true" else "false" end),
(if .movieFile.size? then (.movieFile.size | tostring) else "0" end)
] | @tsv
')
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR TMDB REMOVED SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "$ICON_TRASH Removed: ${#REMOVED[@]} of $DROPPED"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
[[ "$FILES_DELETED" -gt 0 ]] && echo "$ICON_TRASH Files deleted: $FILES_DELETED"
[[ "$FILES_SKIPPED" -gt 0 ]] && echo "$ICON_WARN Files kept: $FILES_SKIPPED (had files — use --delete-files to remove)"
[[ "$ADD_EXCLUSION" == "true" ]] && echo "$ICON_GEAR Import exclusion added for removed entries"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: done ✅"
else
warn "Status: ${#FAILED[@]} removal(s) failed"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#FAILED[@]} -gt 0 ]] && exit 1
exit 0
-601
View File
@@ -1,601 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ================================= Sonarr Cleanup =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Delete orphaned TV episode files not tracked by Sonarr. Queries the API for
# all tracked episode file paths, walks the library on disk, and removes anything
# untracked that is old enough to be past the import window. Triggers an Emby
# library clean after each deletion run so ghost entries disappear immediately.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Every file encountered on disk is classified into one of five categories:
#
# TRACKED — Sonarr API knows this exact path → leave it alone
# PROTECTED — matches SONARR_PROTECTED_PATTERNS → never delete
# ORPHAN — video file, not tracked, older than SONARR_ORPHAN_AGE → delete
# JUNK — not a video extension, not protected → delete regardless of age
# RECENT — not tracked, under SONARR_ORPHAN_AGE → skip (may be mid-import)
#
# Sonarr generates show artwork (*.jpg), metadata (*.nfo), and manages subtitles
# (*.srt, *.sub, *.ass) but does NOT include these in its tracked file API response.
# Without PROTECTED classification these would be deleted — breaking Sonarr and
# Emby metadata display.
#
# After deletions: notify_emby_scan() triggers Emby "Clean Missing Files" task.
# Emby removes ghost entries immediately — no user-facing file-not-found errors.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# API as Ground Truth
# What Sonarr tracks is authoritative. Files not in the API response are
# orphans — Sonarr has no record of them and they serve no purpose.
# The script never infers ownership from directory structure alone.
#
# Age Gate Before Deletion
# Files under SONARR_ORPHAN_AGE are left alone regardless of tracked status.
# Sonarr's import pipeline writes files before registering them — acting
# immediately would delete files mid-import.
#
# Emby Cleanup Is Part of the Job
# Deleting a file without telling Emby leaves ghost entries that show as
# broken items. Triggering the Emby clean is not optional — it completes
# the deletion from the user's perspective.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Six gates — ALL must pass before any file is touched:
# 1. Container running and not starting/unhealthy
# 2. API reachable
# 3. API version matches SONARR_VERSION_MAJOR in master.conf
# 4. Series count > 0
# 5. Tracked file count > 0
# 6. Deletion size < SONARR_MAX_DELETE_GB — or --i-know-what-im-doing required
#
# acquire_lock "wait" — large scans take time, wait for previous run to finish
# jq + curl validation — exits if either tool missing
# DOCKER_TIMEOUT — container checks protected against daemon hangs
# notify_emby_scan() — triggers Emby clean after deletion
# platform_require_cmd — notify script validated before use
# Silent by default — orphans/junk warn(), clean library logs silently
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_SONARR_URL / HOST*_SONARR_API_KEY / HOST*_SONARR_TV_ROOT
# HOST*_SONARR_PATH_MAP — container path → host path translation
# All aliased by detect_hosts() — script uses unprefixed names
#
# master.conf
#
# SONARR_ORPHAN_AGE — days before untracked file eligible for deletion
# SONARR_MAX_DELETE_GB — require --i-know-what-im-doing above this
# SONARR_EXTENSIONS — video file extensions for orphan classification
# SONARR_PROTECTED_PATTERNS — file patterns never deleted
# SONARR_VERSION_MAJOR — expected Sonarr major version for API safety check
# SONARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)
# ARR_CLEANUP_STATS — stats file path (read by coffee report)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# sonarr_cleanup.sh — normal run
# sonarr_cleanup.sh --dry-run — preview, no deletions
# sonarr_cleanup.sh --log — verbose output
# sonarr_cleanup.sh --status — show config and exit
# sonarr_cleanup.sh --i-know-what-im-doing — bypass size threshold
# sonarr_cleanup.sh --i-know-what-im-doing --skip-age-check — NUCLEAR MODE
#
# NUCLEAR MODE: both flags bypass age check AND size threshold. User accepts full
# responsibility — the flag name is long and annoying by design.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Special flag pre-processing ───────────────────────────────────────────────────────────────
I_KNOW=false
SKIP_AGE_CHECK=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--i-know-what-im-doing) I_KNOW=true ;;
--skip-age-check) SKIP_AGE_CHECK=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────
if [[ "$I_KNOW" == true ]] && [[ "$SKIP_AGE_CHECK" == true ]] && [[ "$DRY_RUN" != true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ WARNING — NUCLEAR MODE ACTIVE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Flags: --i-know-what-im-doing --skip-age-check"
echo " Age check: BYPASSED — deletes on first pass"
echo " Size threshold: BYPASSED — no GB limit"
echo " Data recovery: NOT POSSIBLE after deletion"
echo ""
echo " Review --dry-run output before proceeding."
echo " You have 10 seconds to cancel (Ctrl+C)..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
sleep 10
echo " Proceeding..."
echo ""
fi
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for Sonarr API calls"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
notify "Sonarr cleanup failed on $(hostname) — jq not installed" "Sonarr Cleanup" "warning"
exit 1
fi
acquire_lock "wait"
TMP_DIR="/tmp/sonarr_cleanup_$$"
mkdir -p "$TMP_DIR"
trap "_release_all_locks; rm -rf $TMP_DIR" EXIT
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID and aliases SONARR_URL, SONARR_API_KEY, SONARR_TV_ROOT
detect_hosts
DOCKER_TIMEOUT=15
SONARR_CONTAINER="Sonarr"
# Build path map from MY_ID's Sonarr path map
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_SONARR_PATH_MAP"
eval "for key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$key\"]=\"\${${local_path_map_var}[\$key]}\"
done"
require_var SONARR_URL
require_var SONARR_API_KEY
require_var SONARR_TV_ROOT
if [[ ! -d "$SONARR_TV_ROOT" ]]; then
error "TV root not found: $SONARR_TV_ROOT"
notify "Sonarr cleanup failed on $(hostname) — TV root not found: $SONARR_TV_ROOT" \
"Sonarr Cleanup" "warning"
exit 1
fi
log "$ICON_GEAR Config: url=${SONARR_URL} root=${SONARR_TV_ROOT}"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
[[ "$SKIP_AGE_CHECK" == true ]] && warn "OVERRIDE — --skip-age-check active — age check bypassed"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Sonarr URL: $SONARR_URL"
echo "$ICON_GEAR TV root: $SONARR_TV_ROOT"
echo "$ICON_TIME Orphan age: ${SONARR_ORPHAN_AGE} days"
echo "$ICON_GEAR Max delete: ${SONARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)"
echo "$ICON_GEAR Sonarr ver: v${SONARR_VERSION_MAJOR} expected"
echo "$ICON_GEAR Extensions: ${SONARR_EXTENSIONS[*]}"
echo "$ICON_GEAR Protected patterns: ${SONARR_PROTECTED_PATTERNS[*]}"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "$ICON_GEAR I know: $I_KNOW"
echo "$ICON_GEAR Skip age check: $SKIP_AGE_CHECK"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Safety Layer 1 — Container Health ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
CONTAINER_RUNNING=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Running}}' "$SONARR_CONTAINER" 2>/dev/null)
if [[ "$CONTAINER_RUNNING" != "true" ]]; then
error "$SONARR_CONTAINER is not running — aborting"
notify "Sonarr cleanup aborted on $(hostname) — container not running" \
"Sonarr Cleanup" "warning"
exit 1
fi
CONTAINER_HEALTH=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Health.Status}}' "$SONARR_CONTAINER" 2>/dev/null)
case "$CONTAINER_HEALTH" in
healthy) info "$SONARR_CONTAINER is healthy" ;;
"") info "$SONARR_CONTAINER has no health check — proceeding" ;;
starting)
error "$SONARR_CONTAINER is still starting — aborting"
notify "Sonarr cleanup aborted on $(hostname) — container still starting" \
"Sonarr Cleanup" "warning"
exit 1 ;;
unhealthy)
error "$SONARR_CONTAINER is unhealthy — aborting"
notify "Sonarr cleanup aborted on $(hostname) — container unhealthy" \
"Sonarr Cleanup" "warning"
exit 1 ;;
*) warn "$SONARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution" ;;
esac
info "Safety layer 1 passed — container healthy"
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
sonarr_api() {
local endpoint="$1"
local response http_code body
response=$(curl -sf \
--max-time 30 \
-H "X-Api-Key: $SONARR_API_KEY" \
-w "\n%{http_code}" \
"${SONARR_URL}/api/v3/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "Sonarr API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
is_video_file() {
local ext="${1##*.}"
ext="${ext,,}"
for valid_ext in "${SONARR_EXTENSIONS[@]}"; do
[[ "$ext" == "$valid_ext" ]] && return 0
done
return 1
}
is_protected_file() {
local filename
filename=$(basename "$1")
for pattern in "${SONARR_PROTECTED_PATTERNS[@]}"; do
# shellcheck disable=SC2254
case "$filename" in
$pattern) return 0 ;;
esac
done
return 1
}
format_bytes() {
local bytes=$1
if (( bytes > 1073741824 )); then
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
elif (( bytes > 1048576 )); then
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
else
echo "${bytes}B"
fi
}
# ==============================================================================================
# ━━━ Pre-flight: Sonarr Import Scan ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Pre-flight: Sonarr Import Scan ━━━"
# Fetch root folders from Sonarr API and translate container paths to host paths
mapfile -t SCAN_ROOTS < <(
sonarr_api "rootfolder" | \
jq -r '.[].path' 2>/dev/null | \
while IFS= read -r cp; do translate_path "$cp"; done
)
if [[ "${#SCAN_ROOTS[@]}" -eq 0 ]]; then
error "No root folders returned from Sonarr API — aborting"
notify "Sonarr cleanup aborted on $(hostname) — no root folders from API" \
"Sonarr Cleanup" "warning"
exit 1
fi
info "Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}"
info "Triggering ProcessMonitoredDownloads pre-flight"
SCAN_PAYLOAD='{"name": "ProcessMonitoredDownloads"}'
SCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $SONARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$SCAN_PAYLOAD" \
"${SONARR_URL}/api/v3/command" 2>/dev/null)
SCAN_CMD_ID=$(echo "$SCAN_RESPONSE" | jq -r '.id // empty' 2>/dev/null)
if [[ -z "$SCAN_CMD_ID" ]]; then
warn "Could not trigger import scan — proceeding without pre-flight"
else
info "Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion..."
POLL_TIMEOUT=${SONARR_IMPORT_SCAN_TIMEOUT:-600}
POLLED=0
while [[ "$POLLED" -lt "$POLL_TIMEOUT" ]]; do
SCAN_STATUS=$(curl -sf --max-time 10 \
-H "X-Api-Key: $SONARR_API_KEY" \
"${SONARR_URL}/api/v3/command/${SCAN_CMD_ID}" 2>/dev/null | \
jq -r '.status // empty' 2>/dev/null)
case "$SCAN_STATUS" in
completed) info "Import scan complete ✅"; break ;;
failed) warn "Import scan reported failed — proceeding anyway"; break ;;
esac
sleep 10
(( POLLED += 10 ))
[[ $(( POLLED % 60 )) -eq 0 ]] && log " Still scanning... (${POLLED}s elapsed)"
done
[[ "$POLLED" -ge "$POLL_TIMEOUT" ]] && \
warn "Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway"
fi
# ==============================================================================================
# ━━━ Fetch Sonarr Tracked Files ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Fetching Sonarr Tracked Files ━━━"
# Safety Layer 2 — API reachability
if ! check_api "$SONARR_URL" "Sonarr" 10; then
notify "Sonarr cleanup aborted on $(hostname) — API unreachable" "Sonarr Cleanup" "warning"
exit 1
fi
# Safety Layer 3 — API version check
check_arr_version "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SONARR_VERSION_MAJOR" "Sonarr" || exit 1
info "Querying Sonarr API..."
# Fetch all series
SERIES_RESPONSE=$(sonarr_api "series") || {
error "Failed to fetch series from Sonarr"
notify "Sonarr cleanup failed on $(hostname) — could not fetch series" \
"Sonarr Cleanup" "warning"
exit 1
}
SERIES_IDS=$(echo "$SERIES_RESPONSE" | jq -r '.[].id' 2>/dev/null)
SERIES_COUNT=$(echo "$SERIES_IDS" | grep -c "." 2>/dev/null || echo 0)
# Safety Layer 4 — series count > 0
if [[ "$SERIES_COUNT" -eq 0 ]]; then
error "API returned 0 series — aborting to prevent mass deletion"
notify "Sonarr cleanup aborted on $(hostname) — 0 series returned" \
"Sonarr Cleanup" "warning"
exit 1
fi
info "Found $SERIES_COUNT series — fetching episode files..."
TRACKED_FILE="$TMP_DIR/tracked_paths.txt"
> "$TRACKED_FILE"
SERIES_INDEX=0
while IFS= read -r series_id; do
[[ -z "$series_id" ]] && continue
(( SERIES_INDEX++ ))
[[ $(( SERIES_INDEX % 50 )) -eq 0 ]] && \
log "Fetching files: $SERIES_INDEX/$SERIES_COUNT series..."
SERIES_FILES=$(sonarr_api "episodefile?seriesId=${series_id}" 2>/dev/null)
if [[ -n "$SERIES_FILES" ]]; then
while IFS= read -r api_path; do
[[ -z "$api_path" ]] && continue
translate_path "$api_path" >> "$TRACKED_FILE"
done < <(echo "$SERIES_FILES" | jq -r '.[].path' 2>/dev/null)
fi
done <<< "$SERIES_IDS"
sort -u "$TRACKED_FILE" -o "$TRACKED_FILE"
# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file
declare -A TRACKED_MAP
while IFS= read -r _tracked_path; do
[[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1
done < "$TRACKED_FILE"
unset _tracked_path
info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths"
TRACKED_COUNT=$(wc -l < "$TRACKED_FILE")
# Safety Layer 5 — tracked count > 0
if [[ "$TRACKED_COUNT" -eq 0 ]]; then
error "API returned 0 tracked files — aborting to prevent mass deletion"
notify "Sonarr cleanup aborted on $(hostname) — 0 tracked files returned" \
"Sonarr Cleanup" "warning"
exit 1
fi
info "$SERIES_COUNT series | $TRACKED_COUNT tracked episode files"
# ==============================================================================================
# ━━━ Scan TV Root ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning TV Root ━━━"
info "Root: $SONARR_TV_ROOT | Orphan age: ${SONARR_ORPHAN_AGE} days"
START=$(date +%s)
ORPHAN_COUNT=0
JUNK_COUNT=0
RECENT_COUNT=0
PROTECTED_COUNT=0
ORPHAN_BYTES=0
JUNK_BYTES=0
AGE_SECONDS=$(( SONARR_ORPHAN_AGE * 86400 ))
NOW=$(date +%s)
MAX_DELETE_BYTES=$(awk "BEGIN {printf \"%d\", $SONARR_MAX_DELETE_GB * 1073741824}")
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
if [[ -n "${TRACKED_MAP[$filepath]:-}" ]]; then
log "TRACKED: $filepath"
continue
fi
if is_protected_file "$filepath"; then
log "$ICON_PROTECTED PROTECTED: $filepath"
(( PROTECTED_COUNT++ ))
continue
fi
FILE_SIZE=$(stat -c%s "$filepath" 2>/dev/null || echo 0)
if is_video_file "$filepath"; then
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if [[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && [[ "$SKIP_AGE_CHECK" != true ]]; then
log "RECENT (skipping): $filepath"
(( RECENT_COUNT++ ))
continue
fi
warn "$ICON_TRASH ORPHAN: $filepath"
(( ORPHAN_COUNT++ ))
ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))
else
log "JUNK: $filepath"
(( JUNK_COUNT++ ))
JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))
fi
done < <(
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && find "$host_path" -type f 2>/dev/null
done | sort -u
)
TOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))
TOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))
# ==============================================================================================
# ━━━ Safety Layer 6 — Deletion Size Threshold ━━━
# ==============================================================================================
if [[ "$TOTAL_DELETE_BYTES" -gt "$MAX_DELETE_BYTES" ]]; then
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $TOTAL_DELETE_BYTES / 1073741824}")
if [[ "$I_KNOW" != true ]]; then
echo ""
error "Deletion would exceed ${SONARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted"
error "Review ORPHAN lines above carefully before proceeding"
error "Rerun with: --i-know-what-im-doing"
error "To also bypass age check: add --skip-age-check"
notify "Sonarr cleanup halted on $(hostname)${TOTAL_HUMAN} requires --i-know-what-im-doing" \
"Sonarr Cleanup" "warning"
exit 1
else
warn "OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing"
fi
fi
# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
[[ -n "${TRACKED_MAP[$filepath]:-}" ]] && continue
is_protected_file "$filepath" && continue
FILE_MTIME=$(stat -c %Y "$filepath" 2>/dev/null || echo 0)
FILE_AGE=$(( NOW - FILE_MTIME ))
if is_video_file "$filepath"; then
[[ "$FILE_AGE" -lt "$AGE_SECONDS" ]] && \
[[ "$SKIP_AGE_CHECK" != true ]] && continue
fi
rm -f "$filepath" 2>/dev/null || error "Failed to delete: $filepath"
done < <(
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && find "$host_path" -type f 2>/dev/null
done | sort -u
)
info "Cleaning up empty folders..."
for host_path in "${SCAN_ROOTS[@]}"; do
[[ -d "$host_path" ]] && \
find "$host_path" -mindepth 1 -type d -empty -delete 2>/dev/null
done
info "Empty folders removed"
fi
END=$(date +%s)
ORPHAN_HUMAN=$(format_bytes "$ORPHAN_BYTES")
JUNK_HUMAN=$(format_bytes "$JUNK_BYTES")
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY SONARR CLEANUP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Tracked: $TRACKED_COUNT files ($SERIES_COUNT series)"
echo "$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles, metadata)"
echo "$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)"
echo "$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)"
echo "$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${SONARR_ORPHAN_AGE} days)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no files deleted"
elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then
echo "$ICON_DONE Clean — nothing to remove"
else
warn "$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)"
notify "Sonarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)" \
"Sonarr Cleanup" "warning"
# Notify Emby to clean missing files — removes ghost entries immediately
notify_emby_scan
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Write stats for sunday_morning_coffee_report.sh
if [[ "$DRY_RUN" == false ]] && [[ -n "${ARR_CLEANUP_STATS:-}" ]]; then
echo "$(date '+%Y-%m-%d')|sonarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}" \
>> "$ARR_CLEANUP_STATS" 2>/dev/null || true
fi
exit 0
-271
View File
@@ -1,271 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ============================= Sonarr — TVDB Removed ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Remove series from Sonarr that TVDB has dropped. Sonarr marks these with
# status="deleted" — they generate health errors and can never be monitored
# or downloaded.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Queries Sonarr API for series with status="deleted" (TVDB removal marker)
# Reports each entry with file count and total size
# Removes the series record from Sonarr
# Optionally deletes associated files (disabled by default)
# Optionally adds to Sonarr's import exclusion list (default: true — prevents re-add)
#
# Files are NOT deleted by default — use --delete-files to also remove from disk.
# Per-deletion output is always visible — deletions are never silently swallowed.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Health Error Hygiene
# status="deleted" series can never be monitored or downloaded — they only
# generate persistent health errors. Removing them is maintenance, not
# data loss: most have no associated files.
#
# Conservative File Handling
# Files are not deleted by default. A TVDB-removed series may still have
# episodes on disk that the user wants to keep. The --delete-files flag
# is an explicit opt-in, not the default path.
#
# Exclusion List Prevents Re-add
# Removed entries are added to Sonarr's import exclusion list by default.
# Without this, the same deleted series can be re-added by lists or searches
# and immediately generate the same health error again.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# detect_hosts() — exits cleanly if SONARR_URL empty (Sonarr not on this host)
# API pre-flight — verifies Sonarr reachable before querying
# Dry-run mode — full preview without removing anything
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# SONARR_DROPPED_ADD_EXCLUSION — add removed series to import exclusion (default: true)
#
# host*.conf
#
# HOST1_SONARR_URL / HOST1_SONARR_API_KEY
# Aliased by detect_hosts() — script uses SONARR_URL / SONARR_API_KEY
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# sonarr_tvdb_removed.sh — remove records, keep files, add exclusion
# sonarr_tvdb_removed.sh --delete-files — also delete files from disk
# sonarr_tvdb_removed.sh --dry-run — preview without removing anything
# sonarr_tvdb_removed.sh --log — verbose output
# sonarr_tvdb_removed.sh --status — show config and exit
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Parse --delete-files before standard parse_args ───────────────────────────────────────────
DELETE_FILES=false
FILTERED_ARGS=()
for arg in "$@"; do
if [[ "$arg" == "--delete-files" ]]; then
DELETE_FILES=true
else
FILTERED_ARGS+=("$arg")
fi
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found"
exit 1
fi
acquire_lock
detect_hosts
if [[ -z "$SONARR_URL" ]]; then
echo "Sonarr not configured for $MY_ID — skipping"
exit 0
fi
ADD_EXCLUSION="${SONARR_DROPPED_ADD_EXCLUSION:-true}"
log "$ICON_GEAR Config: url=${SONARR_URL} add-exclusion=${ADD_EXCLUSION} delete-files=${DELETE_FILES:-false}"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_URL"
[[ "$DELETE_FILES" == true ]] && warn "DELETE FILES MODE — files will be removed from disk"
[[ "$DELETE_FILES" == false ]] && echo " Files: records only (use --delete-files to also remove from disk)"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Sonarr URL: $SONARR_URL"
echo "$ICON_GEAR Add exclusion: $ADD_EXCLUSION"
echo "$ICON_GEAR Delete files: $DELETE_FILES"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Query Sonarr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Querying Sonarr ━━━"
if ! curl -sf --connect-timeout 5 --max-time 10 \
"$SONARR_URL/api/v3/system/status?apikey=$SONARR_API_KEY" | jq -e '.version' >/dev/null 2>&1; then
error "Sonarr API unreachable at $SONARR_URL"
exit 1
fi
SERIES=$(curl -sf --connect-timeout 5 --max-time 30 \
"$SONARR_URL/api/v3/series?apikey=$SONARR_API_KEY" 2>/dev/null)
if [[ -z "$SERIES" || "$SERIES" == "null" ]]; then
error "Sonarr series API returned empty"
exit 1
fi
TOTAL=$(echo "$SERIES" | jq '. | length')
DROPPED=$(echo "$SERIES" | jq '[.[] | select(.status == "deleted")] | length')
echo " $TOTAL series total — $DROPPED dropped from TVDB"
if [[ "$DROPPED" -eq 0 ]]; then
echo "No TVDB-removed series found — nothing to do"
echo ""
echo "━━━━━ $ICON_SUMMARY SONARR TVDB REMOVED SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_DONE Status: nothing to remove"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Remove Dropped Series ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_TRASH Remove TVDB-Dropped Series ━━━"
START=$(date +%s)
REMOVED=()
FAILED=()
FILES_DELETED=0
FILES_SKIPPED=0
while IFS=$'\t' read -r id title year tvdb_id episode_file_count size_on_disk; do
[[ -z "$id" ]] && continue
SIZE_HUMAN=""
if [[ "$size_on_disk" -gt 0 ]]; then
SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $size_on_disk / 1073741824}")
fi
if [[ "$episode_file_count" -gt 0 ]]; then
log "$ICON_WARN $title ($year) [tvdbid $tvdb_id] — $episode_file_count episode files${SIZE_HUMAN:+, $SIZE_HUMAN}"
else
log "$ICON_TRASH $title ($year) [tvdbid $tvdb_id] — no files"
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove: $title"
[[ "$DELETE_FILES" == true && "$episode_file_count" -gt 0 ]] && \
warn "DRY RUN — would delete $episode_file_count file(s)${SIZE_HUMAN:+, $SIZE_HUMAN}"
REMOVED+=("$title")
continue
fi
DELETE_PARAM="false"
if [[ "$DELETE_FILES" == true && "$episode_file_count" -gt 0 ]]; then
DELETE_PARAM="true"
fi
RESP=$(curl -sf -X DELETE --connect-timeout 5 --max-time 15 \
"$SONARR_URL/api/v3/series/${id}?deleteFiles=${DELETE_PARAM}&addImportListExclusion=${ADD_EXCLUSION}&apikey=$SONARR_API_KEY" \
2>/dev/null)
CURL_EXIT=$?
if [[ "$CURL_EXIT" -eq 0 ]]; then
echo " Removed from Sonarr ✅"
REMOVED+=("$title")
if [[ "$DELETE_PARAM" == "true" ]]; then
(( FILES_DELETED++ ))
elif [[ "$episode_file_count" -gt 0 ]]; then
(( FILES_SKIPPED++ ))
fi
else
warn " Failed to remove $title (curl exit $CURL_EXIT)"
FAILED+=("$title")
fi
done < <(echo "$SERIES" | jq -r '
.[] | select(.status == "deleted") |
[
(.id | tostring),
.title,
(.year | tostring),
(.tvdbId | tostring),
(if .episodeFileCount? then (.episodeFileCount | tostring) else "0" end),
(if .sizeOnDisk? then (.sizeOnDisk | tostring) else "0" end)
] | @tsv
')
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY SONARR TVDB REMOVED SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "$ICON_TRASH Removed: ${#REMOVED[@]} of $DROPPED"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
[[ "$FILES_DELETED" -gt 0 ]] && echo "$ICON_TRASH Files deleted: $FILES_DELETED series worth"
[[ "$FILES_SKIPPED" -gt 0 ]] && echo "$ICON_WARN Files kept: $FILES_SKIPPED series (had files — use --delete-files to remove)"
[[ "$ADD_EXCLUSION" == "true" ]] && echo "$ICON_GEAR Import exclusion added for removed entries"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: done ✅"
else
warn "Status: ${#FAILED[@]} removal(s) failed"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#FAILED[@]} -gt 0 ]] && exit 1
exit 0
-113
View File
@@ -1,113 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ======================= Upgrade Webhook Listener (continuous) ================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Starts a standalone Node.js HTTP server that receives Sonarr/Radarr/Lidarr
# OnUpgrade webhooks and dispatches upgrade_webhook_handler.sh.
#
# Runs outside Unraid nginx — no session auth required. The shared secret in
# the webhook URL is the only gate. Arrs on this host call:
#
# http://<HOST_LAN_IP>:<WEBHOOK_PORT>/webhook?key=<WEBHOOK_SECRET>
#
# Runs as a continuous script started by array_started.sh. Execs node which
# replaces this process — the PID stays the same for array_started.sh's check.
#
# Uses Node.js instead of php -S: php -S on Unraid PHP 8.4 silently drops
# POST request bodies, making webhook payloads arrive empty.
#
# If WEBHOOK_SECRET is empty in master.conf: generates and saves one, then starts.
# If WEBHOOK_PORT is 0: exits cleanly (disables the listener).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Check WEBHOOK_PORT — exit cleanly if 0 (listener disabled)
# 2. Check WEBHOOK_SECRET — generate and persist one if empty
# 3. exec node webhook_listener.js — replaces this process; PID stays the same
#
# exec is intentional: array_started.sh tracks the PID of this script to check
# whether the listener is running. exec preserves that PID across the hand-off
# to node.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Node.js Over php -S
# php -S on Unraid PHP 8.4 silently drops POST request bodies — webhooks arrive
# empty and the handler has no payload to act on. Node.js handles POST bodies
# correctly and has no equivalent silent-drop behaviour.
#
# Runs Outside nginx
# The listener binds directly to WEBHOOK_PORT — no nginx proxy, no session auth.
# The shared secret in the URL query string is the only gate. This keeps the
# webhook path independent of the auth stack.
#
# exec Preserves PID
# The script execs into node rather than forking it. array_started.sh stores the
# PID of this script to check liveness — exec ensures that PID continues to
# refer to the running node process after the hand-off.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# WEBHOOK_PORT=0 gate — exits cleanly before any setup if the listener is disabled
# Secret auto-generate — WEBHOOK_SECRET generated via openssl rand if empty;
# persisted to master.conf immediately so restarts reuse it
# Shared secret gate — webhook URL must include ?key=<WEBHOOK_SECRET>;
# requests without a valid key are rejected by the Node.js server
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# WEBHOOK_PORT — port the listener binds to; 0 = disabled
# WEBHOOK_SECRET — shared secret for URL auth; auto-generated if empty
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# start_webhook_listener.sh
# Started automatically at array start via ARRAY_START_SCRIPTS.
# Exits immediately if WEBHOOK_PORT=0.
#
# To stop:
# pkill -f webhook_listener.js
#
# ==============================================================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ECOSYSTEM_ROOT/load_config.sh"
[[ "${WEBHOOK_PORT:-0}" -eq 0 ]] && {
echo "[webhook] WEBHOOK_PORT=0 — listener disabled"
exit 0
}
# ── Auto-generate secret if not yet set ─────────────────────────────────────
if [[ -z "${WEBHOOK_SECRET:-}" ]]; then
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"
echo "[webhook] Generated WEBHOOK_SECRET — run Tools/webhook_setup.sh to register in arrs"
fi
mkdir -p /var/log/varaverk
exec node "$ECOSYSTEM_ROOT/Media/webhook_listener.js" \
"$WEBHOOK_PORT" "$WEBHOOK_SECRET" "$ECOSYSTEM_ROOT" \
>> /var/log/varaverk/upgrade_webhook.log 2>&1
-183
View File
@@ -1,183 +0,0 @@
#!/bin/bash
# ==============================================================================================
# ========================= Upgrade Webhook Handler ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Triggered by Sonarr/Radarr/Lidarr OnUpgrade webhook (via webhook_listener.js).
# Pushes the upgraded item folder to every other mesh node immediately, then
# triggers a library rescan on each remote arr so it accepts the new file as
# ground truth without initiating a redundant quality search.
#
# Closes the propagation window: without this, a remote node that already has
# the 720p copy will see the 1080p tagged in arr_sync but not on disk and
# begin searching — a search it will never win because we already have it.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Closes the Propagation Window
# arr_sync runs every 4 hours. Without this handler, a remote node that already
# has the old version sees the upgrade tagged in arr_sync but the new file not
# yet on disk, and initiates a redundant quality search — a search it will never
# win because this host already has the file. Immediate push eliminates that window.
#
# Rescan as Ground Truth
# Pushing the file is not enough — the remote arr must also be told the file
# exists. Triggering a rescan makes the remote accept the pushed file as the
# current version without starting a new search.
#
# Cache-First API Key Lookup
# Remote arr API keys are read from conf if cached; otherwise fetched via SSH
# from the remote's config.xml. This avoids storing secrets redundantly while
# keeping API calls fast on hosts where the key is already known.
#
# ==============================================================================================
# 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
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST* — host names used to discover remote nodes
# HOST*_SONARR_API_KEY — cached API key for direct HTTP rescan (optional)
# HOST*_RADARR_API_KEY — cached API key for direct HTTP rescan (optional)
# HOST*_LIDARR_API_KEY — cached API key for direct HTTP rescan (optional)
#
# master.conf
#
# SSH_KEY — SSH key path for rsync and SSH fallback
# ARR_SYNC_CONNECT_TIMEOUT — SSH connect timeout in seconds
# DOCKER_APPDATA_BASE — base path for reading arr config.xml on remote
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# upgrade_webhook_handler.sh <arr_type> <item_path>
#
# arr_type — sonarr | radarr | lidarr
# item_path — absolute path to the series/movie/artist folder on local disk
# (series.path from Sonarr, movie.folderPath from Radarr,
# artist.path from Lidarr)
#
# Called by webhook_listener.js — not intended for direct invocation outside testing.
#
# ==============================================================================================
set -uo pipefail
ARR_TYPE="${1:-}"
ITEM_PATH="${2:-}"
[[ -z "$ARR_TYPE" || -z "$ITEM_PATH" ]] && {
echo "Usage: upgrade_webhook_handler.sh <arr_type> <item_path>" >&2
exit 1
}
[[ -d "$ITEM_PATH" ]] || { echo "Path not found: $ITEM_PATH" >&2; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
detect_hosts
# ── Arr type → API port / version / rescan command ───────────────────────────────────────────
case "$ARR_TYPE" in
sonarr) PORT=8989; API_VER="v3"; RESCAN_CMD="RefreshSeries" ;;
radarr) PORT=7878; API_VER="v3"; RESCAN_CMD="RefreshMovie" ;;
lidarr) PORT=8686; API_VER="v1"; RESCAN_CMD="RefreshArtist" ;;
*) echo "Unknown arr type: $ARR_TYPE" >&2; exit 1 ;;
esac
# ── Remote node list ──────────────────────────────────────────────────────────────────────────
declare -a REMOTE_NODES=()
for _hv in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
[[ "$_hv" == "$MY_ID" ]] && continue
[[ -z "${!_hv:-}" ]] && continue
REMOTE_NODES+=("$_hv")
done
unset _hv
if [[ "${#REMOTE_NODES[@]}" -eq 0 ]]; then
echo "No remote nodes configured — nothing to push"
exit 0
fi
ENCODED=$(printf '{"name":"%s"}' "$RESCAN_CMD" | base64 -w0)
ITEM_NAME=$(basename "$ITEM_PATH")
echo "[$(date '+%H:%M:%S')] Upgrade push: ${ARR_TYPE}${ITEM_NAME}"
echo " Path: $ITEM_PATH"
echo " Targets: ${REMOTE_NODES[*]}"
# ── Push and rescan each remote ───────────────────────────────────────────────────────────────
for node_id in "${REMOTE_NODES[@]}"; do
node_name="${!node_id}"
node_ip=$(resolve_tailscale_ip "$node_name") || {
echo " [${node_name}] Cannot resolve Tailscale IP — skipping"
continue
}
# ── Targeted rsync — push only this item, no delete ──────────────────────────────────────
echo " [${node_name}] rsync ${ITEM_NAME}..."
rsync_out=$(rsync -av --no-delete \
-e "ssh -i $SSH_KEY -T -o Compression=no -o IPQoS=throughput -o StrictHostKeyChecking=no" \
"${ITEM_PATH}/" \
"root@${node_ip}:${ITEM_PATH}/" 2>&1)
rsync_exit=$?
transferred=$(echo "$rsync_out" | awk '/Total transferred file size:/{gsub(/,/,"",$NF); gsub(/[^0-9]/,"",$NF); print $NF+0}')
if [[ "$rsync_exit" -ne 0 ]]; then
echo " [${node_name}] rsync failed (exit ${rsync_exit}) — skipping rescan"
continue
fi
echo " [${node_name}] rsync done (${transferred:-0} bytes)"
# ── Trigger arr rescan on remote — cache-first, SSH fallback ─────────────────────────────
_kvar="${node_id}_${ARR_TYPE^^}_API_KEY"
cached_key="${!_kvar:-}"
if [[ -n "$cached_key" ]]; then
body=$(printf '%s' "$ENCODED" | base64 -d)
http_code=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "X-Api-Key: $cached_key" \
-H "Content-Type: application/json" \
-d "$body" \
"http://${node_ip}:${PORT}/api/${API_VER}/command" 2>/dev/null)
else
config_xml="${DOCKER_APPDATA_BASE}/${ARR_TYPE^}/config.xml"
http_code=$(ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
root@"$node_ip" bash <<REMOTE 2>/dev/null
KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)
[[ -z "\$KEY" ]] && exit 1
BODY=\$(printf '%s' '${ENCODED}' | base64 -d)
curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "X-Api-Key: \$KEY" \
-H 'Content-Type: application/json' \
-d "\$BODY" \
"http://localhost:${PORT}/api/${API_VER}/command"
REMOTE
)
fi
if [[ "$http_code" == "201" || "$http_code" == "200" ]]; then
echo " [${node_name}] ${RESCAN_CMD} triggered ✅"
else
echo " [${node_name}] ${RESCAN_CMD} failed (HTTP ${http_code:-timeout})"
fi
done
echo "[$(date '+%H:%M:%S')] Done — ${ITEM_NAME}"
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env node
// Standalone arr upgrade webhook listener.
// Started by start_webhook_listener.sh via `node webhook_listener.js`.
// Lives outside Unraid nginx — no session auth. Secret in URL is the only gate.
//
// Usage: node webhook_listener.js <port> <secret> <scripts_dir>
'use strict';
const http = require('http');
const { exec } = require('child_process');
const fs = require('fs');
const path = require('path');
const [,, port, secret, scriptsDir] = process.argv;
if (!port || !secret || !scriptsDir) {
process.stderr.write('Usage: webhook_listener.js <port> <secret> <scripts_dir>\n');
process.exit(1);
}
const LOG_FILE = '/var/log/varaverk/upgrade_webhook.log';
const HANDLER = path.join(scriptsDir, 'Media', 'upgrade_webhook_handler.sh');
function log(msg) {
const ts = new Date().toTimeString().slice(0, 8);
const line = `[${ts}] ${msg}\n`;
fs.appendFile(LOG_FILE, line, () => {});
}
function send(res, code, obj) {
const body = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost`);
const key = url.searchParams.get('key');
if (key !== secret) {
send(res, 403, { ok: false, error: 'Forbidden' });
return;
}
if (req.method !== 'POST') {
send(res, 405, { ok: false, error: 'POST only' });
return;
}
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
let payload;
try { payload = JSON.parse(body); }
catch (_) { send(res, 400, { ok: false, error: 'Invalid JSON' }); return; }
const event = payload.eventType ?? '';
const isUpgrade = payload.isUpgrade ?? false;
if (event === 'Test') {
send(res, 200, { ok: true, message: 'Webhook connected — upgrade propagation active' });
return;
}
if (event !== 'Download' || !isUpgrade) {
send(res, 200, { ok: true, skipped: event });
return;
}
let arrType, itemPath;
if (payload.series?.path) {
arrType = 'sonarr';
itemPath = payload.series.path;
} else if (payload.movie?.folderPath) {
arrType = 'radarr';
itemPath = payload.movie.folderPath;
} else if (payload.artist?.path) {
arrType = 'lidarr';
itemPath = payload.artist.path;
} else {
send(res, 400, { ok: false, error: 'Unrecognised payload structure' });
return;
}
if (!itemPath || !itemPath.startsWith('/') || itemPath.includes('..') || /[\x00\n\r]/.test(itemPath)) {
send(res, 400, { ok: false, error: 'Unsafe path' });
return;
}
log(`Upgrade: ${arrType}${path.basename(itemPath)}`);
send(res, 200, { ok: true, arr: arrType, path: itemPath });
const cmd = `bash ${JSON.stringify(HANDLER)} ${JSON.stringify(arrType)} ${JSON.stringify(itemPath)}`;
const out = fs.createWriteStream(LOG_FILE, { flags: 'a' });
exec(cmd, { stdio: ['ignore', out, out] });
});
});
server.listen(parseInt(port, 10), '0.0.0.0', () => {
log(`Webhook listener started on port ${port}`);
process.stdout.write(`[webhook] Listening on port ${port}\n`);
});
server.on('error', err => {
process.stderr.write(`[webhook] Server error: ${err.message}\n`);
process.exit(1);
});