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:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: 0–5 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
|
||||
# 0–5 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
|
||||
@@ -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: 0–5 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
|
||||
# 0–5 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
|
||||
@@ -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: 0–3 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
|
||||
# 0–3 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user