34 lines
402 KiB
JSON
34 lines
402 KiB
JSON
{"parentUuid":null,"isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":"Audit every bash script in these directories against its own header documentation. For each script, read the full file and check:\n1. Does PURPOSE match what the code actually does?\n2. Does CONFIGURATION list all config vars the script actually uses (missing vars, or vars listed but never referenced)?\n3. Does RUNTIME MODES / flags match what's actually parsed (missing flags, or flags documented but not implemented)?\n4. Are there OPERATIONAL SAFEGUARDS documented that aren't in the code?\n5. Anything the code does that the header doesn't mention?\n\nScripts to audit:\n- /boot/config/plugins/varaverk/Media/arr_sync.sh\n- /boot/config/plugins/varaverk/Media/arrs_failed_stalled_recovery.sh\n- /boot/config/plugins/varaverk/Media/lidarr_cleanup.sh\n- /boot/config/plugins/varaverk/Media/lidarr_missing_art.sh\n- /boot/config/plugins/varaverk/Media/media_cleaner.sh\n- /boot/config/plugins/varaverk/Media/media_shares_permissions.sh\n- /boot/config/plugins/varaverk/Media/play_state_sync.sh\n- /boot/config/plugins/varaverk/Media/playback_aware_lidarr_discovery.sh\n- /boot/config/plugins/varaverk/Media/playback_aware_radarr_discovery.sh\n- /boot/config/plugins/varaverk/Media/playback_aware_sonarr_discovery.sh\n- /boot/config/plugins/varaverk/Media/radarr_cleanup.sh\n- /boot/config/plugins/varaverk/Media/radarr_tmdb_removed.sh\n- /boot/config/plugins/varaverk/Media/sonarr_cleanup.sh\n- /boot/config/plugins/varaverk/Media/sonarr_tvdb_removed.sh\n\nReport ONLY real discrepancies — not style opinions. Format: filename, then a short bullet list of issues. If a script's header matches its code exactly, just write \"✅ filename\". Be specific: quote the header claim and what the code actually does differently."},"uuid":"6bd0c246-0b36-45df-bf74-7a88e0c0a50e","timestamp":"2026-06-14T05:32:11.985Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"6bd0c246-0b36-45df-bf74-7a88e0c0a50e","isSidechain":true,"agentId":"aa134018ac187b545","attachment":{"type":"deferred_tools_delta","addedNames":["EnterWorktree","ExitWorktree","Monitor","TaskStop","WebFetch","WebSearch","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Google_Drive__complete_authentication"],"addedLines":["EnterWorktree","ExitWorktree","Monitor","TaskStop","WebFetch","WebSearch","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Google_Drive__complete_authentication"],"removedNames":[],"readdedNames":[]},"type":"attachment","uuid":"d2f88e02-2861-4bbd-8fb7-7b3d65cc35d8","timestamp":"2026-06-14T05:32:11.988Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"d2f88e02-2861-4bbd-8fb7-7b3d65cc35d8","isSidechain":true,"agentId":"aa134018ac187b545","attachment":{"type":"skill_listing","content":"- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, suggest the /config command.\n- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: \"rebind ctrl+s\", \"add a chord shortcut\", \"change the submit key\", \"customize keybindings\".\n- verify: Verify that a code change actually does what it's supposed to by running the app and observing behavior. Use when asked to verify a PR, confirm a fix works, test a change manually, check that a feature works, or validate local changes before pushing.\n- code-review: Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud). Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review.\n- simplify: Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.\n- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.\n- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. \"check the deploy every 5 minutes\", \"keep running /babysit-prs\"). Do NOT invoke for one-off tasks.\n- schedule: Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring cloud agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run (\"run this once at 3pm\", \"remind me to check X tomorrow\").\n- claude-api: Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.\nTRIGGER — read BEFORE opening the target file; don't skip because it \"looks like a one-liner\" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).\nSKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).\n- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).\n- init: Initialize a new CLAUDE.md file with codebase documentation\n- review: Review a pull request\n- security-review: Complete a security review of the pending changes on the current branch","skillCount":13,"isInitial":true,"names":["update-config","keybindings-help","verify","code-review","simplify","fewer-permission-prompts","loop","schedule","claude-api","run","init","review","security-review"]},"type":"attachment","uuid":"811ed9d9-987e-4b78-83d9-8f2339d93524","timestamp":"2026-06-14T05:32:11.990Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"811ed9d9-987e-4b78-83d9-8f2339d93524","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"text","text":"I'll audit each bash script against its header documentation. Let me start by reading all the scripts."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"dfe13f13-56c5-41fe-921d-cb4a50d57cff","timestamp":"2026-06-14T05:32:13.452Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"dfe13f13-56c5-41fe-921d-cb4a50d57cff","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01StfVwyKCYsLCQPhRrYZiHM","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/arr_sync.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"a5ad89f9-9537-42a2-a13c-12ff56d9e348","timestamp":"2026-06-14T05:32:13.682Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"a5ad89f9-9537-42a2-a13c-12ff56d9e348","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FG8uHDNR9qtz8nbvXeyxRG","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/arrs_failed_stalled_recovery.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"ceba4dac-2950-47b1-8fe8-4d0649c251a6","timestamp":"2026-06-14T05:32:13.855Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"a5ad89f9-9537-42a2-a13c-12ff56d9e348","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01StfVwyKCYsLCQPhRrYZiHM","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ================================= ARR Sync ===================================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Full-mesh arr library sync across all nodes — Lidarr, Sonarr, and Radarr.\n9\t# Every node syncs with every other, union model, no hierarchy. Run before\n10\t# rsync in the weekly sync window: once arrs agree on what to track, rsync\n11\t# spreads the actual files.\n12\t#\n13\t# ==============================================================================================\n14\t# OPERATIONAL MODEL\n15\t# ==============================================================================================\n16\t#\n17\t# Full mesh: every node syncs with every other — no primary, no hierarchy.\n18\t# Union model: if any node tracks an item, all nodes get it (unless blocklisted).\n19\t# Convergence: any node can add content; after one full cycle all nodes agree.\n20\t# Upgrade-aware: a file upgrade on one node → arr tracks new path → rsync spreads\n21\t# it → arr_cleanup removes old path on all nodes (arr no longer tracks it).\n22\t#\n23\t# Node discovery: reads HOST* vars from master.conf. Add HOST3= and it joins the\n24\t# sync automatically — no script changes needed for a new node.\n25\t#\n26\t# Graceful skip: arr not configured locally → skip cleanly. Arr not reachable on\n27\t# a remote → skip that node for that arr type, continue with others.\n28\t#\n29\t# What gets synced — library items keyed on stable external IDs:\n30\t# Lidarr — MusicBrainz artist ID (foreignArtistId)\n31\t# Sonarr — TVDB series ID (tvdbId)\n32\t# Radarr — TMDB movie ID (tmdbId)\n33\t# When adding to a remote, that node's own quality profile, metadata profile,\n34\t# and root folder path are used — settings are never copied from source.\n35\t#\n36\t# ==============================================================================================\n37\t# DESIGN PRINCIPLES\n38\t# ==============================================================================================\n39\t#\n40\t# Remote API Access — Cache-First, SSH Fallback\n41\t# If conf_sync.sh has populated /tmp/.vv/config/cached/.confs/ and\n42\t# load_config.sh has sourced it, HOST*_<ARR>_API_KEY vars are available\n43\t# in the environment. Remote functions use them to call the arr API\n44\t# directly over Tailscale (no SSH, no remote shell). If the cached key\n45\t# is absent (first boot, cache not yet populated) the functions fall back\n46\t# to SSHing in and reading the key from config.xml on the remote node.\n47\t#\n48\t# Blocklist TSV\n49\t# ARR_SYNC_BLOCKLIST in DATA_DIR tombstones IDs that must never be re-added\n50\t# anywhere. Read from ALL nodes via SSH at run start — immediate effect with\n51\t# no rsync delay.\n52\t#\n53\t# --blocklist-add does three things atomically:\n54\t# 1. Writes the TSV tombstone entry (prevents future re-adds by arr_sync)\n55\t# 2. Deletes the item from the local arr API (deleteFiles=false)\n56\t# 3. SSHes each remote node and deletes from their arr API (deleteFiles=false)\n57\t# Files become orphans on all nodes — arr_cleanup removes them on next run.\n58\t#\n59\t# ==============================================================================================\n60\t# OPERATIONAL SAFEGUARDS\n61\t# ==============================================================================================\n62\t#\n63\t# acquire_lock — prevents two sync instances running simultaneously\n64\t# ARR_SYNC_ENABLED — global gate, exits cleanly when false\n65\t# SSH connect timeout — ARR_SYNC_CONNECT_TIMEOUT — does not hang on unreachable node\n66\t# API call timeout — ARR_SYNC_API_TIMEOUT — does not hang on slow arr\n67\t# Graceful skip — unreachable node/arr → skip and continue, never abort\n68\t# Blocklist gate — item in blocklist → never added to any node\n69\t# Silent by default — only additions produce output, clean runs stay silent\n70\t#\n71\t# ==============================================================================================\n72\t# STATE FILES\n73\t# ==============================================================================================\n74\t#\n75\t# ARR_SYNC_BLOCKLIST — TSV file in DATA_DIR (default: DATA_DIR/arr_sync_blocklist.tsv)\n76\t# Columns: arr_type, id, reason, date_added\n77\t# Read from all nodes via SSH at the start of each run.\n78\t#\n79\t# ==============================================================================================\n80\t# CONFIGURATION\n81\t# ==============================================================================================\n82\t#\n83\t# host*.conf\n84\t#\n85\t# HOST*_LIDARR_URL / HOST*_LIDARR_API_KEY — local Lidarr (aliased by detect_hosts)\n86\t# HOST*_SONARR_URL / HOST*_SONARR_API_KEY — local Sonarr\n87\t# HOST*_RADARR_URL / HOST*_RADARR_API_KEY — local Radarr\n88\t#\n89\t# master.conf\n90\t#\n91\t# ARR_SYNC_ENABLED — global on/off toggle (default: true)\n92\t# ARR_SYNC_BLOCKLIST — path to TSV blocklist file\n93\t# ARR_SYNC_CONNECT_TIMEOUT — SSH connect timeout in seconds (default: 10)\n94\t# ARR_SYNC_API_TIMEOUT — curl API call timeout in seconds (default: 60)\n95\t# DOCKER_APPDATA_BASE — base path for arr appdata dirs (default: /mnt/user/appdata)\n96\t# ARR_SYNC_LIDARR_PORT — Lidarr port on all nodes (default: 8686)\n97\t# ARR_SYNC_SONARR_PORT — Sonarr port on all nodes (default: 8989)\n98\t# ARR_SYNC_RADARR_PORT — Radarr port on all nodes (default: 7878)\n99\t#\n100\t# ==============================================================================================\n101\t# RUNTIME MODES\n102\t# ==============================================================================================\n103\t#\n104\t# arr_sync.sh — sync all arr types, all nodes\n105\t# arr_sync.sh --dry-run — preview only, no changes\n106\t# arr_sync.sh --log — verbose output\n107\t# arr_sync.sh --status — show config and exit\n108\t#\n109\t# Blocklist management:\n110\t# arr_sync.sh --blocklist-add lidarr <mbid> \"reason\" — remove from all arrs + tombstone\n111\t# arr_sync.sh --blocklist-add sonarr <tvdbId> \"reason\" — remove from all arrs + tombstone\n112\t# arr_sync.sh --blocklist-add radarr <tmdbId> \"reason\" — remove from all arrs + tombstone\n113\t# arr_sync.sh --blocklist-remove lidarr <id> — un-tombstone (does NOT re-add)\n114\t# arr_sync.sh --blocklist-list — show all blocklisted IDs\n115\t#\n116\t# ==============================================================================================\n117\t\n118\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n119\tsource \"$SCRIPT_DIR/../load_config.sh\"\n120\t\n121\t# ── Blocklist flag pre-processing ─────────────────────────────────────────────────────────────\n122\t# Handle before parse_args — these are action flags, not standard options\n123\tBLOCKLIST_ACTION=\"\"\n124\tBLOCKLIST_ARR=\"\"\n125\tBLOCKLIST_ID=\"\"\n126\tBLOCKLIST_REASON=\"\"\n127\tFILTERED_ARGS=()\n128\t_skip_next=false\n129\tfor _arg in \"$@\"; do\n130\t if [[ \"$_skip_next\" == true ]]; then _skip_next=false; continue; fi\n131\t case \"$_arg\" in\n132\t --blocklist-add) BLOCKLIST_ACTION=\"add\" ;;\n133\t --blocklist-remove) BLOCKLIST_ACTION=\"remove\" ;;\n134\t --blocklist-list) BLOCKLIST_ACTION=\"list\" ;;\n135\t *) FILTERED_ARGS+=(\"$_arg\") ;;\n136\t esac\n137\tdone\n138\tunset _arg _skip_next\n139\t\n140\tparse_args \"${FILTERED_ARGS[@]}\"\n141\t\n142\t# Consume blocklist positional args from remaining FILTERED_ARGS\n143\t# --blocklist-add lidarr <id> \"reason\"\n144\t# --blocklist-remove lidarr <id>\n145\tif [[ -n \"$BLOCKLIST_ACTION\" ]] && [[ \"$BLOCKLIST_ACTION\" != \"list\" ]]; then\n146\t for _a in \"${FILTERED_ARGS[@]}\"; do\n147\t [[ \"$_a\" == --* ]] && continue\n148\t if [[ -z \"$BLOCKLIST_ARR\" ]]; then BLOCKLIST_ARR=\"$_a\"\n149\t elif [[ -z \"$BLOCKLIST_ID\" ]]; then BLOCKLIST_ID=\"$_a\"\n150\t elif [[ -z \"$BLOCKLIST_REASON\" ]]; then BLOCKLIST_REASON=\"$_a\"\n151\t fi\n152\t done\n153\t unset _a\n154\tfi\n155\t\n156\t# ==============================================================================================\n157\t# ━━━ Setup ━━━\n158\t# ==============================================================================================\n159\tif [[ \"$EUID\" -ne 0 ]]; then\n160\t error \"Must be run as root\"\n161\t exit 1\n162\tfi\n163\t\n164\tacquire_lock\n165\t\n166\tfor _tool in curl jq; do\n167\t if ! command -v \"$_tool\" >/dev/null 2>&1; then\n168\t error \"$_tool not found — required for arr API calls\"\n169\t exit 1\n170\t fi\n171\tdone\n172\tunset _tool\n173\t\n174\tdetect_hosts\n175\t\n176\t# ── Runtime config with defaults ──────────────────────────────────────────────────────────────\n177\tARR_SYNC_ENABLED=\"${ARR_SYNC_ENABLED:-true}\"\n178\tARR_SYNC_BLOCKLIST=\"${ARR_SYNC_BLOCKLIST:-${DATA_DIR}/arr_sync_blocklist.tsv}\"\n179\tARR_SYNC_CONNECT_TIMEOUT=\"${ARR_SYNC_CONNECT_TIMEOUT:-10}\"\n180\tARR_SYNC_API_TIMEOUT=\"${ARR_SYNC_API_TIMEOUT:-60}\"\n181\t\n182\tARR_SYNC_LIDARR_PORT=\"${ARR_SYNC_LIDARR_PORT:-8686}\"\n183\tARR_SYNC_SONARR_PORT=\"${ARR_SYNC_SONARR_PORT:-8989}\"\n184\tARR_SYNC_RADARR_PORT=\"${ARR_SYNC_RADARR_PORT:-7878}\"\n185\t\n186\tif [[ \"$ARR_SYNC_ENABLED\" != \"true\" ]]; then\n187\t log \"ARR_SYNC_ENABLED=false — exiting\"\n188\t exit 0\n189\tfi\n190\t\n191\tlog \"$ICON_GEAR Config: api-timeout=${ARR_SYNC_API_TIMEOUT}s connect-timeout=${ARR_SYNC_CONNECT_TIMEOUT}s blocklist=${ARR_SYNC_BLOCKLIST}\"\n192\tlog \"$ICON_GEAR Ports: lidarr=${ARR_SYNC_LIDARR_PORT} sonarr=${ARR_SYNC_SONARR_PORT} radarr=${ARR_SYNC_RADARR_PORT}\"\n193\t\n194\t# ── Arr type definitions ───────────────────────────────────────────────────────────────────────\n195\t# Each arr type maps to its port, API version, endpoint, stable ID field, and display name field\n196\tdeclare -A _PORT=([lidarr]=\"$ARR_SYNC_LIDARR_PORT\" [sonarr]=\"$ARR_SYNC_SONARR_PORT\" [radarr]=\"$ARR_SYNC_RADARR_PORT\")\n197\tdeclare -A _VER=( [lidarr]=\"v1\" [sonarr]=\"v3\" [radarr]=\"v3\")\n198\tdeclare -A _EP=( [lidarr]=\"artist\" [sonarr]=\"series\" [radarr]=\"movie\")\n199\tdeclare -A _ID=( [lidarr]=\"foreignArtistId\" [sonarr]=\"tvdbId\" [radarr]=\"tmdbId\")\n200\tdeclare -A _NAME=([lidarr]=\"artistName\" [sonarr]=\"title\" [radarr]=\"title\")\n201\t# ID type: \"string\" for MusicBrainz UUID, \"int\" for TVDB/TMDB numeric IDs\n202\tdeclare -A _ID_TYPE=([lidarr]=\"string\" [sonarr]=\"int\" [radarr]=\"int\")\n203\tARR_TYPES=(lidarr sonarr radarr)\n204\t\n205\t# ── Remote node discovery ──────────────────────────────────────────────────────────────────────\n206\tREMOTE_NODES=()\n207\tfor _hv in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do\n208\t [[ \"$_hv\" == \"$MY_ID\" ]] && continue\n209\t [[ -z \"${!_hv:-}\" ]] && continue\n210\t REMOTE_NODES+=(\"$_hv\")\n211\tdone\n212\tunset _hv\n213\t\n214\tif [[ \"${#REMOTE_NODES[@]}\" -eq 0 ]]; then\n215\t warn \"No remote nodes defined in master.conf — nothing to sync\"\n216\t exit 0\n217\tfi\n218\t\n219\techo \" Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n220\techo \" Remote nodes: ${REMOTE_NODES[*]}\"\n221\techo \" Blocklist: $ARR_SYNC_BLOCKLIST\"\n222\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes will be made\"\n223\t\n224\t# ==============================================================================================\n225\t# ── BLOCKLIST ─────────────────────────────────────────────────────────────────────────────────\n226\t# ==============================================================================================\n227\t\n228\tdeclare -A BLOCKLIST_MAP # key: \"arr_type:stable_id\" → display name\n229\t\n230\t_load_blocklist() {\n231\t BLOCKLIST_MAP=()\n232\t local count=0\n233\t\n234\t _parse_blocklist_lines() {\n235\t while IFS=$'\\t' read -r arr_type stable_id display_name rest; do\n236\t [[ -z \"$arr_type\" ]] || [[ \"$arr_type\" == \\#* ]] && continue\n237\t BLOCKLIST_MAP[\"${arr_type}:${stable_id}\"]=\"$display_name\"\n238\t (( count++ ))\n239\t done\n240\t }\n241\t\n242\t # Local blocklist\n243\t [[ -f \"$ARR_SYNC_BLOCKLIST\" ]] && _parse_blocklist_lines < \"$ARR_SYNC_BLOCKLIST\"\n244\t\n245\t # Remote blocklists — read via SSH so tombstones are effective immediately\n246\t for _node_id in \"${REMOTE_NODES[@]}\"; do\n247\t local _node_ip\n248\t _node_ip=$(_resolve_node_ip \"$_node_id\") || continue\n249\t local _remote_lines\n250\t _remote_lines=$(ssh -i \"$SSH_KEY\" -o ConnectTimeout=\"$ARR_SYNC_CONNECT_TIMEOUT\" \\\n251\t root@\"$_node_ip\" \"cat '$ARR_SYNC_BLOCKLIST' 2>/dev/null\" 2>/dev/null) || continue\n252\t _parse_blocklist_lines <<< \"$_remote_lines\"\n253\t done\n254\t unset _node_id _node_ip _remote_lines\n255\t\n256\t log \"Loaded blocklist: $count entries from $(( ${#REMOTE_NODES[@]} + 1 )) nodes\"\n257\t unset -f _parse_blocklist_lines\n258\t}\n259\t\n260\t_is_blocklisted() {\n261\t [[ -n \"${BLOCKLIST_MAP[\"${1}:${2}\"]:-}\" ]]\n262\t}\n263\t\n264\t_blocklist_add() {\n265\t local arr_type=\"$1\" stable_id=\"$2\" display_name=\"$3\" reason=\"${4:-manually excluded}\"\n266\t mkdir -p \"$(dirname \"$ARR_SYNC_BLOCKLIST\")\"\n267\t if ! grep -qP \"^${arr_type}\\t${stable_id}\\t\" \"$ARR_SYNC_BLOCKLIST\" 2>/dev/null; then\n268\t printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\\n269\t \"$arr_type\" \"$stable_id\" \"$display_name\" \\\n270\t \"$MY_ID\" \"$(date -Iseconds)\" \"$reason\" \\\n271\t >> \"$ARR_SYNC_BLOCKLIST\"\n272\t BLOCKLIST_MAP[\"${arr_type}:${stable_id}\"]=\"$display_name\"\n273\t log \"Blocklisted: [$arr_type] $display_name ($stable_id)\"\n274\t else\n275\t log \"Already blocklisted: [$arr_type] $stable_id\"\n276\t fi\n277\t}\n278\t\n279\t_blocklist_remove() {\n280\t local arr_type=\"$1\" stable_id=\"$2\"\n281\t if [[ -f \"$ARR_SYNC_BLOCKLIST\" ]]; then\n282\t local tmp\n283\t tmp=$(mktemp)\n284\t grep -vP \"^${arr_type}\\t${stable_id}\\t\" \"$ARR_SYNC_BLOCKLIST\" > \"$tmp\" && \\\n285\t mv \"$tmp\" \"$ARR_SYNC_BLOCKLIST\" || rm -f \"$tmp\"\n286\t unset \"BLOCKLIST_MAP[${arr_type}:${stable_id}]\"\n287\t log \"Removed from blocklist: [$arr_type] $stable_id\"\n288\t fi\n289\t}\n290\t\n291\t# Look up item in local arr by stable_id — returns \"internal_id\\tdisplay_name\" or empty\n292\t_lookup_local_item() {\n293\t local url=\"$1\" api_key=\"$2\" api_ver=\"$3\" endpoint=\"$4\"\n294\t local id_field=\"$5\" id_type=\"$6\" name_field=\"$7\" stable_id=\"$8\"\n295\t local raw select_expr\n296\t raw=$(curl -sf --max-time \"$ARR_SYNC_API_TIMEOUT\" \\\n297\t -H \"X-Api-Key: $api_key\" \\\n298\t \"${url}/api/${api_ver}/${endpoint}\" 2>/dev/null)\n299\t [[ -z \"$raw\" ]] && return 1\n300\t if [[ \"$id_type\" == \"string\" ]]; then\n301\t select_expr=\".[] | select(.${id_field} == \\\"${stable_id}\\\")\"\n302\t else\n303\t select_expr=\".[] | select(.${id_field} == ${stable_id})\"\n304\t fi\n305\t echo \"$raw\" | jq -r \"${select_expr} | [(.id | tostring), .${name_field}] | @tsv\" 2>/dev/null | head -1\n306\t}\n307\t\n308\t# Delete item from local arr by internal integer id — returns HTTP status code\n309\t_delete_local_item() {\n310\t local url=\"$1\" api_key=\"$2\" api_ver=\"$3\" endpoint=\"$4\" internal_id=\"$5\"\n311\t curl -sf -o /dev/null -w '%{http_code}' -X DELETE \\\n312\t -H \"X-Api-Key: $api_key\" \\\n313\t \"${url}/api/${api_ver}/${endpoint}/${internal_id}?deleteFiles=false\" 2>/dev/null\n314\t}\n315\t\n316\t# Delete item from remote arr by stable_id.\n317\t# Outputs: HTTP code on success | \"not_found\" if item absent | empty on failure.\n318\t# deleteFiles=false — files become orphans for arr_cleanup to handle with its safety checks.\n319\t# Args: node_id port api_ver arr_type endpoint id_field id_type stable_id\n320\t_delete_remote_item() {\n321\t local node_id=\"$1\" port=\"$2\" api_ver=\"$3\" arr_type=\"$4\" endpoint=\"$5\"\n322\t local id_field=\"$6\" id_type=\"$7\" stable_id=\"$8\"\n323\t local node_name=\"${!node_id}\"\n324\t local node_ip\n325\t node_ip=$(resolve_tailscale_ip \"$node_name\") || return 1\n326\t\n327\t local select_expr\n328\t if [[ \"$id_type\" == \"string\" ]]; then\n329\t select_expr=\".[] | select(.${id_field} == \\\"${stable_id}\\\") | .id\"\n330\t else\n331\t select_expr=\".[] | select(.${id_field} == ${stable_id}) | .id\"\n332\t fi\n333\t\n334\t local _kvar=\"${node_id}_${arr_type^^}_API_KEY\"; local cached_key=\"${!_kvar:-}\"\n335\t if [[ -n \"$cached_key\" ]]; then\n336\t local library internal_id\n337\t library=$(curl -sf --max-time \"$ARR_SYNC_API_TIMEOUT\" \\\n338\t -H \"X-Api-Key: $cached_key\" \\\n339\t \"http://${node_ip}:${port}/api/${api_ver}/${endpoint}\" 2>/dev/null)\n340\t [[ -z \"$library\" ]] && return 1\n341\t internal_id=$(echo \"$library\" | jq -r \"${select_expr}\" 2>/dev/null | head -1)\n342\t [[ -z \"$internal_id\" ]] && echo \"not_found\" && return 0\n343\t curl -sf -o /dev/null -w '%{http_code}' -X DELETE \\\n344\t -H \"X-Api-Key: $cached_key\" \\\n345\t \"http://${node_ip}:${port}/api/${api_ver}/${endpoint}/${internal_id}?deleteFiles=false\" 2>/dev/null\n346\t return\n347\t fi\n348\t local config_xml=\"${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml\"\n349\t ssh -i \"$SSH_KEY\" -o ConnectTimeout=\"$ARR_SYNC_CONNECT_TIMEOUT\" \\\n350\t root@\"$node_ip\" bash <<REMOTE 2>/dev/null\n351\tKEY=\\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)\n352\t[[ -z \"\\$KEY\" ]] && exit 1\n353\tLIBRARY=\\$(curl -sf --max-time ${ARR_SYNC_API_TIMEOUT} \\\n354\t -H \"X-Api-Key: \\$KEY\" \\\n355\t \"http://localhost:${port}/api/${api_ver}/${endpoint}\" 2>/dev/null)\n356\t[[ -z \"\\$LIBRARY\" ]] && exit 1\n357\tINTERNAL_ID=\\$(echo \"\\$LIBRARY\" | jq -r '${select_expr}' 2>/dev/null | head -1)\n358\t[[ -z \"\\$INTERNAL_ID\" ]] && echo \"not_found\" && exit 0\n359\tcurl -sf -o /dev/null -w '%{http_code}' -X DELETE \\\n360\t -H \"X-Api-Key: \\$KEY\" \\\n361\t \"http://localhost:${port}/api/${api_ver}/${endpoint}/\\${INTERNAL_ID}?deleteFiles=false\"\n362\tREMOTE\n363\t}\n364\t\n365\t# ── Blocklist management mode ──────────────────────────────────────────────────────────────────\n366\tif [[ -n \"$BLOCKLIST_ACTION\" ]]; then\n367\t case \"$BLOCKLIST_ACTION\" in\n368\t list)\n369\t echo \"\"\n370\t echo \"━━━━━ ARR SYNC BLOCKLIST ━━━━━\"\n371\t if [[ ! -f \"$ARR_SYNC_BLOCKLIST\" ]] || [[ ! -s \"$ARR_SYNC_BLOCKLIST\" ]]; then\n372\t echo \" (empty)\"\n373\t else\n374\t echo \"\"\n375\t printf '%-8s %-40s %-30s %s\\n' \"Arr\" \"Stable ID\" \"Name\" \"Reason\"\n376\t printf '%-8s %-40s %-30s %s\\n' \"---\" \"---------\" \"----\" \"------\"\n377\t while IFS=$'\\t' read -r arr_type stable_id display_name tombstoned_by ts reason; do\n378\t [[ -z \"$arr_type\" ]] || [[ \"$arr_type\" == \\#* ]] && continue\n379\t printf '%-8s %-40s %-30s %s\\n' \"$arr_type\" \"$stable_id\" \"$display_name\" \"$reason\"\n380\t done < \"$ARR_SYNC_BLOCKLIST\"\n381\t fi\n382\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n383\t exit 0\n384\t ;;\n385\t add)\n386\t if [[ -z \"$BLOCKLIST_ARR\" ]] || [[ -z \"$BLOCKLIST_ID\" ]]; then\n387\t error \"Usage: arr_sync.sh --blocklist-add <arr_type> <stable_id> [reason]\"\n388\t error \" arr_type: lidarr | sonarr | radarr\"\n389\t exit 1\n390\t fi\n391\t if [[ -z \"${_PORT[$BLOCKLIST_ARR]:-}\" ]]; then\n392\t error \"Unknown arr type: $BLOCKLIST_ARR — use lidarr, sonarr, or radarr\"\n393\t exit 1\n394\t fi\n395\t\n396\t _bl_port=\"${_PORT[$BLOCKLIST_ARR]}\"\n397\t _bl_ver=\"${_VER[$BLOCKLIST_ARR]}\"\n398\t _bl_ep=\"${_EP[$BLOCKLIST_ARR]}\"\n399\t _bl_id_field=\"${_ID[$BLOCKLIST_ARR]}\"\n400\t _bl_id_type=\"${_ID_TYPE[$BLOCKLIST_ARR]}\"\n401\t _bl_name_field=\"${_NAME[$BLOCKLIST_ARR]}\"\n402\t _bl_url=\"\" _bl_key=\"\"\n403\t case \"$BLOCKLIST_ARR\" in\n404\t lidarr) _bl_url=\"${LIDARR_URL:-}\"; _bl_key=\"${LIDARR_API_KEY:-}\" ;;\n405\t sonarr) _bl_url=\"${SONARR_URL:-}\"; _bl_key=\"${SONARR_API_KEY:-}\" ;;\n406\t radarr) _bl_url=\"${RADARR_URL:-}\"; _bl_key=\"${RADARR_API_KEY:-}\" ;;\n407\t esac\n408\t\n409\t # Look up display name and internal id from local arr\n410\t _bl_display_name=\"$BLOCKLIST_ID\"\n411\t _bl_internal_id=\"\"\n412\t if [[ -n \"$_bl_url\" ]] && [[ -n \"$_bl_key\" ]]; then\n413\t _bl_lookup=$(_lookup_local_item \"$_bl_url\" \"$_bl_key\" \"$_bl_ver\" \"$_bl_ep\" \\\n414\t \"$_bl_id_field\" \"$_bl_id_type\" \"$_bl_name_field\" \"$BLOCKLIST_ID\")\n415\t if [[ -n \"$_bl_lookup\" ]]; then\n416\t IFS=$'\\t' read -r _bl_internal_id _bl_display_name <<< \"$_bl_lookup\"\n417\t fi\n418\t fi\n419\t\n420\t _blocklist_add \"$BLOCKLIST_ARR\" \"$BLOCKLIST_ID\" \"$_bl_display_name\" \"${BLOCKLIST_REASON:-manually excluded}\"\n421\t echo \" Blocklisted [$BLOCKLIST_ARR] $_bl_display_name ($BLOCKLIST_ID)\"\n422\t\n423\t # Remove from local arr (deleteFiles=false — arr_cleanup handles file removal)\n424\t if [[ -n \"$_bl_internal_id\" ]]; then\n425\t _bl_http=$(_delete_local_item \"$_bl_url\" \"$_bl_key\" \"$_bl_ver\" \"$_bl_ep\" \"$_bl_internal_id\")\n426\t if [[ \"$_bl_http\" == \"200\" ]]; then\n427\t log \"Removed from local ${BLOCKLIST_ARR^}: $_bl_display_name\"\n428\t else\n429\t warn \"Failed to remove from local ${BLOCKLIST_ARR^} (HTTP ${_bl_http:-no response}) — remove manually via UI\"\n430\t fi\n431\t else\n432\t log \"Not found in local ${BLOCKLIST_ARR^} — already removed or not tracked locally\"\n433\t fi\n434\t\n435\t # Remove from all remote arrs\n436\t for _bl_node_id in \"${REMOTE_NODES[@]}\"; do\n437\t _bl_node_name=\"${!_bl_node_id}\"\n438\t _bl_result=$(_delete_remote_item \"$_bl_node_id\" \"$_bl_port\" \"$_bl_ver\" \\\n439\t \"$BLOCKLIST_ARR\" \"$_bl_ep\" \"$_bl_id_field\" \"$_bl_id_type\" \"$BLOCKLIST_ID\")\n440\t case \"$_bl_result\" in\n441\t 200) log \"Removed from $_bl_node_name ${BLOCKLIST_ARR^}: $_bl_display_name\" ;;\n442\t not_found) log \"Not found on $_bl_node_name ${BLOCKLIST_ARR^} — already removed or not tracked\" ;;\n443\t *) warn \"Failed to remove from $_bl_node_name ${BLOCKLIST_ARR^} (${_bl_result:-SSH error}) — remove manually via UI\" ;;\n444\t esac\n445\t done\n446\t\n447\t echo \"\"\n448\t echo \" Files are now orphans on all nodes — arr_cleanup.sh will remove them on next run\"\n449\t exit 0\n450\t ;;\n451\t remove)\n452\t if [[ -z \"$BLOCKLIST_ARR\" ]] || [[ -z \"$BLOCKLIST_ID\" ]]; then\n453\t error \"Usage: arr_sync.sh --blocklist-remove <arr_type> <stable_id>\"\n454\t exit 1\n455\t fi\n456\t _blocklist_remove \"$BLOCKLIST_ARR\" \"$BLOCKLIST_ID\"\n457\t echo \" Removed [$BLOCKLIST_ARR] $BLOCKLIST_ID from blocklist\"\n458\t exit 0\n459\t ;;\n460\t esac\n461\tfi\n462\t\n463\t# ==============================================================================================\n464\t# ── STATUS ────────────────────────────────────────────────────────────────────────────────────\n465\t# ==============================================================================================\n466\tif [[ \"$SHOW_STATUS\" == true ]]; then\n467\t echo \"\"\n468\t echo \"━━━━━ $ICON_SUMMARY ARR SYNC STATUS ━━━━━\"\n469\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n470\t echo \"$ICON_HOST Remote nodes: ${REMOTE_NODES[*]}\"\n471\t echo \"$ICON_GEAR Appdata base: $DOCKER_APPDATA_BASE\"\n472\t echo \"$ICON_GEAR Blocklist: $ARR_SYNC_BLOCKLIST\"\n473\t echo \"$ICON_GEAR Connect timeout: ${ARR_SYNC_CONNECT_TIMEOUT}s\"\n474\t echo \"$ICON_GEAR API timeout: ${ARR_SYNC_API_TIMEOUT}s\"\n475\t echo \"\"\n476\t echo \" Arr Port URL\"\n477\t for _arr in \"${ARR_TYPES[@]}\"; do\n478\t local _url=\"\" _key=\"\"\n479\t case \"$_arr\" in\n480\t lidarr) _url=\"${LIDARR_URL:-not configured}\" ;;\n481\t sonarr) _url=\"${SONARR_URL:-not configured}\" ;;\n482\t radarr) _url=\"${RADARR_URL:-not configured}\" ;;\n483\t esac\n484\t printf ' %-8s %-6s %s\\n' \"$_arr\" \"${_PORT[$_arr]}\" \"$_url\"\n485\t done\n486\t unset _arr _url\n487\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n488\t exit 0\n489\tfi\n490\t\n491\t# ==============================================================================================\n492\t# ── REMOTE HELPERS ────────────────────────────────────────────────────────────────────────────\n493\t# ==============================================================================================\n494\t\n495\t_resolve_node_ip() {\n496\t local node_id=\"$1\"\n497\t resolve_tailscale_ip \"${!node_id}\"\n498\t}\n499\t\n500\t# Check if arr is reachable on remote node\n501\t# Args: node_id node_ip port api_ver arr_type\n502\t_remote_arr_up() {\n503\t local node_id=\"$1\" node_ip=\"$2\" port=\"$3\" api_ver=\"$4\" arr_type=\"$5\"\n504\t local _kvar=\"${node_id}_${arr_type^^}_API_KEY\"; local cached_key=\"${!_kvar:-}\"\n505\t if [[ -n \"$cached_key\" ]]; then\n506\t curl -sf --max-time 5 \\\n507\t -H \"X-Api-Key: $cached_key\" \\\n508\t \"http://${node_ip}:${port}/api/${api_ver}/system/status\" >/dev/null 2>/dev/null\n509\t return\n510\t fi\n511\t local config_xml=\"${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml\"\n512\t ssh -i \"$SSH_KEY\" -o ConnectTimeout=\"$ARR_SYNC_CONNECT_TIMEOUT\" \\\n513\t root@\"$node_ip\" \\\n514\t \"KEY=\\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)\n515\t [[ -z \\\"\\$KEY\\\" ]] && exit 1\n516\t curl -sf --max-time 5 -H \\\"X-Api-Key: \\$KEY\\\" \\\n517\t 'http://localhost:${port}/api/${api_ver}/system/status' >/dev/null\" 2>/dev/null\n518\t}\n519\t\n520\t# Fetch full library from remote arr — returns raw JSON array\n521\t# Args: node_id node_ip port api_ver arr_type endpoint\n522\t_remote_library() {\n523\t local node_id=\"$1\" node_ip=\"$2\" port=\"$3\" api_ver=\"$4\" arr_type=\"$5\" endpoint=\"$6\"\n524\t local _kvar=\"${node_id}_${arr_type^^}_API_KEY\"; local cached_key=\"${!_kvar:-}\"\n525\t if [[ -n \"$cached_key\" ]]; then\n526\t curl -sf --max-time \"$ARR_SYNC_API_TIMEOUT\" \\\n527\t -H \"X-Api-Key: $cached_key\" \\\n528\t \"http://${node_ip}:${port}/api/${api_ver}/${endpoint}\" 2>/dev/null\n529\t return\n530\t fi\n531\t local config_xml=\"${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml\"\n532\t ssh -i \"$SSH_KEY\" -o ConnectTimeout=\"$ARR_SYNC_CONNECT_TIMEOUT\" \\\n533\t root@\"$node_ip\" \\\n534\t \"KEY=\\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)\n535\t [[ -z \\\"\\$KEY\\\" ]] && exit 1\n536\t curl -sf --max-time ${ARR_SYNC_API_TIMEOUT} \\\n537\t -H \\\"X-Api-Key: \\$KEY\\\" \\\n538\t 'http://localhost:${port}/api/${api_ver}/${endpoint}'\" 2>/dev/null\n539\t}\n540\t\n541\t# Fetch remote arr defaults: qualityProfileId, rootFolderPath, metadataProfileId (Lidarr)\n542\t# Args: node_id node_ip port api_ver arr_type\n543\t_remote_defaults() {\n544\t local node_id=\"$1\" node_ip=\"$2\" port=\"$3\" api_ver=\"$4\" arr_type=\"$5\"\n545\t local _kvar=\"${node_id}_${arr_type^^}_API_KEY\"; local cached_key=\"${!_kvar:-}\"\n546\t if [[ -n \"$cached_key\" ]]; then\n547\t local base_url=\"http://${node_ip}:${port}/api/${api_ver}\"\n548\t local qp rf mp\n549\t qp=$(curl -sf -H \"X-Api-Key: $cached_key\" \"${base_url}/qualityprofile\" 2>/dev/null | jq '.[0].id // 1')\n550\t rf=$(curl -sf -H \"X-Api-Key: $cached_key\" \"${base_url}/rootfolder\" 2>/dev/null | jq -r '.[0].path // \"\"')\n551\t [[ -z \"$qp\" ]] && return 1\n552\t if [[ \"$arr_type\" == \"lidarr\" ]]; then\n553\t mp=$(curl -sf -H \"X-Api-Key: $cached_key\" \"${base_url}/metadataprofile\" 2>/dev/null | \\\n554\t jq 'map(select(.name == \"Standard\")) | .[0].id // .[0].id // 1')\n555\t jq -n --argjson qp \"$qp\" --arg rf \"$rf\" --argjson mp \"$mp\" \\\n556\t '{qualityProfileId: $qp, rootFolderPath: $rf, metadataProfileId: $mp}'\n557\t else\n558\t jq -n --argjson qp \"$qp\" --arg rf \"$rf\" \\\n559\t '{qualityProfileId: $qp, rootFolderPath: $rf}'\n560\t fi\n561\t return\n562\t fi\n563\t local config_xml=\"${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml\"\n564\t local meta_field=\"\"\n565\t [[ \"$arr_type\" == \"lidarr\" ]] && \\\n566\t meta_field=', metadataProfileId: ($mp | map(select(.name == \"Standard\")) | .[0].id // .[0].id // 1)'\n567\t ssh -i \"$SSH_KEY\" -o ConnectTimeout=\"$ARR_SYNC_CONNECT_TIMEOUT\" \\\n568\t root@\"$node_ip\" bash <<REMOTE 2>/dev/null\n569\tKEY=\\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)\n570\t[[ -z \"\\$KEY\" ]] && exit 1\n571\tQP=\\$(curl -sf -H \"X-Api-Key: \\$KEY\" \"http://localhost:${port}/api/${api_ver}/qualityprofile\")\n572\tRF=\\$(curl -sf -H \"X-Api-Key: \\$KEY\" \"http://localhost:${port}/api/${api_ver}/rootfolder\")\n573\tMP=\\$(curl -sf -H \"X-Api-Key: \\$KEY\" \"http://localhost:${port}/api/${api_ver}/metadataprofile\" 2>/dev/null || echo '[]')\n574\tjq -n --argjson qp \"\\$QP\" --argjson rf \"\\$RF\" --argjson mp \"\\$MP\" \\\n575\t '{qualityProfileId: (\\$qp | .[0].id // 1), rootFolderPath: (\\$rf | .[0].path // \"\")${meta_field}}'\n576\tREMOTE\n577\t}\n578\t\n579\t# Add item to remote arr — payload is base64-encoded to avoid quoting issues\n580\t# Args: node_id node_ip port api_ver arr_type endpoint encoded_payload\n581\t_remote_add() {\n582\t local node_id=\"$1\" node_ip=\"$2\" port=\"$3\" api_ver=\"$4\" arr_type=\"$5\" endpoint=\"$6\"\n583\t local encoded=\"$7\"\n584\t local _kvar=\"${node_id}_${arr_type^^}_API_KEY\"; local cached_key=\"${!_kvar:-}\"\n585\t if [[ -n \"$cached_key\" ]]; then\n586\t local body\n587\t body=$(printf '%s' \"$encoded\" | base64 -d)\n588\t curl -sf -o /dev/null -w '%{http_code}' -X POST \\\n589\t -H \"X-Api-Key: $cached_key\" \\\n590\t -H \"Content-Type: application/json\" \\\n591\t -d \"$body\" \\\n592\t \"http://${node_ip}:${port}/api/${api_ver}/${endpoint}\" 2>/dev/null\n593\t return\n594\t fi\n595\t local config_xml=\"${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml\"\n596\t ssh -i \"$SSH_KEY\" -o ConnectTimeout=\"$ARR_SYNC_CONNECT_TIMEOUT\" \\\n597\t root@\"$node_ip\" bash <<REMOTE 2>/dev/null\n598\tKEY=\\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)\n599\t[[ -z \"\\$KEY\" ]] && exit 1\n600\tBODY=\\$(printf '%s' '${encoded}' | base64 -d)\n601\tcurl -sf -o /dev/null -w '%{http_code}' -X POST \\\n602\t -H \"X-Api-Key: \\$KEY\" \\\n603\t -H 'Content-Type: application/json' \\\n604\t -d \"\\$BODY\" \\\n605\t \"http://localhost:${port}/api/${api_ver}/${endpoint}\"\n606\tREMOTE\n607\t}\n608\t\n609\t# ==============================================================================================\n610\t# ── LOCAL HELPERS ─────────────────────────────────────────────────────────────────────────────\n611\t# ==============================================================================================\n612\t\n613\t_local_library() {\n614\t local url=\"$1\" api_key=\"$2\" api_ver=\"$3\" endpoint=\"$4\"\n615\t curl -sf --max-time \"$ARR_SYNC_API_TIMEOUT\" \\\n616\t -H \"X-Api-Key: $api_key\" \\\n617\t \"${url}/api/${api_ver}/${endpoint}\" 2>/dev/null\n618\t}\n619\t\n620\t_local_defaults() {\n621\t local url=\"$1\" api_key=\"$2\" api_ver=\"$3\" arr_type=\"$4\"\n622\t local qp rf mp meta_field=\"\"\n623\t qp=$(curl -sf -H \"X-Api-Key: $api_key\" \"${url}/api/${api_ver}/qualityprofile\" | jq '.[0].id // 1')\n624\t rf=$(curl -sf -H \"X-Api-Key: $api_key\" \"${url}/api/${api_ver}/rootfolder\" | jq -r '.[0].path // \"\"')\n625\t if [[ \"$arr_type\" == \"lidarr\" ]]; then\n626\t mp=$(curl -sf -H \"X-Api-Key: $api_key\" \"${url}/api/${api_ver}/metadataprofile\" | \\\n627\t jq 'map(select(.name == \"Standard\")) | .[0].id // .[0].id // 1')\n628\t jq -n --argjson qp \"$qp\" --arg rf \"$rf\" --argjson mp \"$mp\" \\\n629\t '{qualityProfileId: $qp, rootFolderPath: $rf, metadataProfileId: $mp}'\n630\t else\n631\t jq -n --argjson qp \"$qp\" --arg rf \"$rf\" \\\n632\t '{qualityProfileId: $qp, rootFolderPath: $rf}'\n633\t fi\n634\t}\n635\t\n636\t_local_add() {\n637\t local url=\"$1\" api_key=\"$2\" api_ver=\"$3\" endpoint=\"$4\" payload=\"$5\"\n638\t curl -sf -o /dev/null -w '%{http_code}' -X POST \\\n639\t -H \"X-Api-Key: $api_key\" \\\n640\t -H \"Content-Type: application/json\" \\\n641\t -d \"$payload\" \\\n642\t \"${url}/api/${api_ver}/${endpoint}\" 2>/dev/null\n643\t}\n644\t\n645\t# ==============================================================================================\n646\t# ── PAYLOAD BUILDER ───────────────────────────────────────────────────────────────────────────\n647\t# ==============================================================================================\n648\t# Builds the minimal POST body to add an item to an arr.\n649\t# Uses the TARGET node's own defaults — never copies source node settings.\n650\t\n651\t_build_payload() {\n652\t local arr_type=\"$1\" stable_id=\"$2\" display_name=\"$3\" monitored=\"$4\" defaults_json=\"$5\"\n653\t case \"$arr_type\" in\n654\t lidarr)\n655\t jq -n \\\n656\t --arg id \"$stable_id\" \\\n657\t --arg nm \"$display_name\" \\\n658\t --argjson mn \"$monitored\" \\\n659\t --argjson df \"$defaults_json\" \\\n660\t '{\n661\t foreignArtistId: $id,\n662\t artistName: $nm,\n663\t monitored: $mn,\n664\t qualityProfileId: $df.qualityProfileId,\n665\t metadataProfileId: ($df.metadataProfileId // 1),\n666\t rootFolderPath: $df.rootFolderPath,\n667\t addOptions: {monitor: \"all\", searchForMissingAlbums: false}\n668\t }'\n669\t ;;\n670\t sonarr)\n671\t jq -n \\\n672\t --argjson id \"$stable_id\" \\\n673\t --arg nm \"$display_name\" \\\n674\t --argjson mn \"$monitored\" \\\n675\t --argjson df \"$defaults_json\" \\\n676\t '{\n677\t tvdbId: $id,\n678\t title: $nm,\n679\t monitored: $mn,\n680\t qualityProfileId: $df.qualityProfileId,\n681\t rootFolderPath: $df.rootFolderPath,\n682\t seasons: [],\n683\t addOptions: {searchForMissingEpisodes: false, monitor: \"all\"}\n684\t }'\n685\t ;;\n686\t radarr)\n687\t jq -n \\\n688\t --argjson id \"$stable_id\" \\\n689\t --arg nm \"$display_name\" \\\n690\t --argjson mn \"$monitored\" \\\n691\t --argjson df \"$defaults_json\" \\\n692\t '{\n693\t tmdbId: $id,\n694\t title: $nm,\n695\t monitored: $mn,\n696\t qualityProfileId: $df.qualityProfileId,\n697\t rootFolderPath: $df.rootFolderPath,\n698\t addOptions: {searchForMovie: false}\n699\t }'\n700\t ;;\n701\t esac\n702\t}\n703\t\n704\t# ==============================================================================================\n705\t# ── MONITORED ENFORCEMENT — re-monitor any unmonitored items via bulk editor ──────────────────\n706\t# ==============================================================================================\n707\t# All library members must be monitored. Unmonitored items won't be searched and eventually\n708\t# lose their files when cleanup runs after the series is removed from the arr.\n709\t\n710\t_enforce_monitored() {\n711\t local arr_type=\"$1\" url=\"$2\" api_key=\"$3\" api_ver=\"$4\" local_json=\"$5\"\n712\t\n713\t local bulk_endpoint ids_key\n714\t case \"$arr_type\" in\n715\t sonarr) bulk_endpoint=\"series/editor\"; ids_key=\"seriesIds\" ;;\n716\t radarr) bulk_endpoint=\"movie/editor\"; ids_key=\"movieIds\" ;;\n717\t lidarr) bulk_endpoint=\"artist/editor\"; ids_key=\"artistIds\" ;;\n718\t *) return 0 ;;\n719\t esac\n720\t\n721\t local ids_json count\n722\t ids_json=$(echo \"$local_json\" | jq '[.[] | select(.monitored == false) | .id]' 2>/dev/null)\n723\t count=$(echo \"$ids_json\" | jq 'length' 2>/dev/null || echo 0)\n724\t [[ \"$count\" -eq 0 ]] && return 0\n725\t\n726\t warn \"${arr_type^}: $count unmonitored items — re-monitoring\"\n727\t\n728\t if [[ \"$DRY_RUN\" == true ]]; then\n729\t warn \"DRY RUN: would re-monitor $count ${arr_type^} items\"\n730\t return 0\n731\t fi\n732\t\n733\t local body http_code\n734\t body=$(jq -n --arg k \"$ids_key\" --argjson ids \"$ids_json\" '{($k): $ids, monitored: true}')\n735\t http_code=$(curl -sf -o /dev/null -w '%{http_code}' -X PUT \\\n736\t -H \"X-Api-Key: $api_key\" \\\n737\t -H \"Content-Type: application/json\" \\\n738\t -d \"$body\" \\\n739\t \"${url}/api/${api_ver}/${bulk_endpoint}\" 2>/dev/null)\n740\t\n741\t if [[ \"$http_code\" == \"200\" || \"$http_code\" == \"202\" ]]; then\n742\t log \"${arr_type^}: re-monitored $count items ✅\"\n743\t else\n744\t warn \"${arr_type^}: bulk re-monitor failed (HTTP $http_code)\"\n745\t fi\n746\t}\n747\t\n748\t# ==============================================================================================\n749\t# ── CORE SYNC — one arr type across all remote nodes ──────────────────────────────────────────\n750\t# ==============================================================================================\n751\t\n752\t_sync_arr() {\n753\t local arr_type=\"$1\"\n754\t local port=\"${_PORT[$arr_type]}\"\n755\t local api_ver=\"${_VER[$arr_type]}\"\n756\t local endpoint=\"${_EP[$arr_type]}\"\n757\t local id_field=\"${_ID[$arr_type]}\"\n758\t local name_field=\"${_NAME[$arr_type]}\"\n759\t local id_type=\"${_ID_TYPE[$arr_type]}\"\n760\t\n761\t # Resolve local credentials\n762\t local local_url local_key\n763\t case \"$arr_type\" in\n764\t lidarr) local_url=\"${LIDARR_URL:-}\"; local_key=\"${LIDARR_API_KEY:-}\" ;;\n765\t sonarr) local_url=\"${SONARR_URL:-}\"; local_key=\"${SONARR_API_KEY:-}\" ;;\n766\t radarr) local_url=\"${RADARR_URL:-}\"; local_key=\"${RADARR_API_KEY:-}\" ;;\n767\t esac\n768\t\n769\t if [[ -z \"$local_url\" ]] || [[ -z \"$local_key\" ]]; then\n770\t log \"${arr_type^}: not configured on $MY_ID — skipping\"\n771\t return 0\n772\t fi\n773\t\n774\t echo \"\"\n775\t echo \"━━━ ${arr_type^} ━━━\"\n776\t\n777\t # ── Fetch local library ────────────────────────────────────────────────────────────────────\n778\t local local_json\n779\t local_json=$(_local_library \"$local_url\" \"$local_key\" \"$api_ver\" \"$endpoint\")\n780\t if [[ -z \"$local_json\" ]] || ! echo \"$local_json\" | jq -e '.' >/dev/null 2>&1; then\n781\t warn \"${arr_type^}: could not fetch local library — skipping\"\n782\t return 0\n783\t fi\n784\t\n785\t # Build local ID map: stable_id → \"display_name|monitored\"\n786\t declare -A local_ids\n787\t local local_count=0\n788\t local _jq_id\n789\t [[ \"$id_type\" == \"string\" ]] && _jq_id=\".${id_field}\" || _jq_id=\"(.${id_field} | tostring)\"\n790\t while IFS=$'\\t' read -r stable_id display_name monitored; do\n791\t [[ -z \"$stable_id\" ]] && continue\n792\t local_ids[\"$stable_id\"]=\"${display_name}|${monitored}\"\n793\t (( local_count++ ))\n794\t done < <(echo \"$local_json\" | jq -r \\\n795\t \".[] | [${_jq_id}, .${name_field}, (.monitored | tostring)] | @tsv\" 2>/dev/null)\n796\t unset _jq_id\n797\t\n798\t log \"${arr_type^}: $local_count items in local library\"\n799\t\n800\t # ── Enforce monitored — fix any unmonitored items before sync ─────────────────────────────\n801\t _enforce_monitored \"$arr_type\" \"$local_url\" \"$local_key\" \"$api_ver\" \"$local_json\"\n802\t\n803\t local total_added_local=0 total_added_remote=0 total_skipped=0\n804\t\n805\t # ── Sync with each remote node ─────────────────────────────────────────────────────────────\n806\t for node_id in \"${REMOTE_NODES[@]}\"; do\n807\t local node_name=\"${!node_id}\"\n808\t log \"${arr_type^}: syncing with $node_name...\"\n809\t\n810\t local node_ip\n811\t node_ip=$(_resolve_node_ip \"$node_id\") || {\n812\t warn \"${arr_type^}: cannot resolve Tailscale IP for $node_name — skipping\"\n813\t continue\n814\t }\n815\t\n816\t if ! _remote_arr_up \"$node_id\" \"$node_ip\" \"$port\" \"$api_ver\" \"$arr_type\"; then\n817\t log \"${arr_type^}: not reachable on $node_name — skipping\"\n818\t continue\n819\t fi\n820\t\n821\t local remote_json\n822\t remote_json=$(_remote_library \"$node_id\" \"$node_ip\" \"$port\" \"$api_ver\" \"$arr_type\" \"$endpoint\")\n823\t if [[ -z \"$remote_json\" ]] || ! echo \"$remote_json\" | jq -e '.' >/dev/null 2>&1; then\n824\t warn \"${arr_type^}: could not fetch library from $node_name — skipping\"\n825\t continue\n826\t fi\n827\t\n828\t # Build remote ID map\n829\t declare -A remote_ids\n830\t local remote_count=0\n831\t local _jq_id\n832\t [[ \"$id_type\" == \"string\" ]] && _jq_id=\".${id_field}\" || _jq_id=\"(.${id_field} | tostring)\"\n833\t while IFS=$'\\t' read -r stable_id display_name monitored; do\n834\t [[ -z \"$stable_id\" ]] && continue\n835\t remote_ids[\"$stable_id\"]=\"${display_name}|${monitored}\"\n836\t (( remote_count++ ))\n837\t done < <(echo \"$remote_json\" | jq -r \\\n838\t \".[] | [${_jq_id}, .${name_field}, (.monitored | tostring)] | @tsv\" 2>/dev/null)\n839\t unset _jq_id\n840\t\n841\t log \"${arr_type^}: $remote_count items on $node_name\"\n842\t\n843\t # ── Remote → Local: items on remote not in local ───────────────────────────────────────\n844\t local to_add_local=()\n845\t for stable_id in \"${!remote_ids[@]}\"; do\n846\t [[ -n \"${local_ids[$stable_id]:-}\" ]] && continue\n847\t if _is_blocklisted \"$arr_type\" \"$stable_id\"; then\n848\t log \"BLOCKLISTED [$arr_type] $stable_id — skipping\"\n849\t (( total_skipped++ ))\n850\t continue\n851\t fi\n852\t to_add_local+=(\"$stable_id\")\n853\t done\n854\t\n855\t if [[ \"${#to_add_local[@]}\" -gt 0 ]]; then\n856\t local local_defs\n857\t local_defs=$(_local_defaults \"$local_url\" \"$local_key\" \"$api_ver\" \"$arr_type\")\n858\t if [[ -z \"$local_defs\" ]]; then\n859\t warn \"${arr_type^}: could not fetch local defaults — skipping adds from $node_name\"\n860\t else\n861\t for stable_id in \"${to_add_local[@]}\"; do\n862\t IFS='|' read -r display_name _ <<< \"${remote_ids[$stable_id]}\"\n863\t local payload\n864\t payload=$(_build_payload \"$arr_type\" \"$stable_id\" \"$display_name\" \\\n865\t \"true\" \"$local_defs\")\n866\t if [[ \"$DRY_RUN\" == true ]]; then\n867\t log \"DRY RUN: would add to local ${arr_type^}: $display_name ($stable_id)\"\n868\t (( total_added_local++ ))\n869\t else\n870\t local http_code\n871\t http_code=$(_local_add \"$local_url\" \"$local_key\" \"$api_ver\" \\\n872\t \"$endpoint\" \"$payload\")\n873\t if [[ \"$http_code\" == \"201\" ]] || [[ \"$http_code\" == \"200\" ]]; then\n874\t log \"Added to local ${arr_type^}: $display_name\"\n875\t local_ids[\"$stable_id\"]=\"${display_name}|true\"\n876\t (( total_added_local++ ))\n877\t else\n878\t warn \"Failed to add to local ${arr_type^}: $display_name (HTTP $http_code)\"\n879\t fi\n880\t fi\n881\t done\n882\t fi\n883\t fi\n884\t\n885\t # ── Local → Remote: items on local not on remote ───────────────────────────────────────\n886\t local to_add_remote=()\n887\t for stable_id in \"${!local_ids[@]}\"; do\n888\t [[ -n \"${remote_ids[$stable_id]:-}\" ]] && continue\n889\t if _is_blocklisted \"$arr_type\" \"$stable_id\"; then\n890\t (( total_skipped++ ))\n891\t continue\n892\t fi\n893\t to_add_remote+=(\"$stable_id\")\n894\t done\n895\t\n896\t if [[ \"${#to_add_remote[@]}\" -gt 0 ]]; then\n897\t local remote_defs\n898\t remote_defs=$(_remote_defaults \"$node_id\" \"$node_ip\" \"$port\" \"$api_ver\" \"$arr_type\")\n899\t if [[ -z \"$remote_defs\" ]]; then\n900\t warn \"${arr_type^}: could not fetch defaults from $node_name — skipping remote adds\"\n901\t else\n902\t for stable_id in \"${to_add_remote[@]}\"; do\n903\t IFS='|' read -r display_name _ <<< \"${local_ids[$stable_id]}\"\n904\t local payload\n905\t payload=$(_build_payload \"$arr_type\" \"$stable_id\" \"$display_name\" \\\n906\t \"true\" \"$remote_defs\")\n907\t if [[ \"$DRY_RUN\" == true ]]; then\n908\t log \"DRY RUN: would add to $node_name ${arr_type^}: $display_name ($stable_id)\"\n909\t (( total_added_remote++ ))\n910\t else\n911\t local encoded http_code\n912\t encoded=$(printf '%s' \"$payload\" | base64 -w0)\n913\t http_code=$(_remote_add \"$node_id\" \"$node_ip\" \"$port\" \"$api_ver\" \\\n914\t \"$arr_type\" \"$endpoint\" \"$encoded\")\n915\t if [[ \"$http_code\" == \"201\" ]] || [[ \"$http_code\" == \"200\" ]]; then\n916\t log \"Added to $node_name ${arr_type^}: $display_name\"\n917\t (( total_added_remote++ ))\n918\t else\n919\t warn \"Failed to add to $node_name ${arr_type^}: $display_name (HTTP $http_code)\"\n920\t fi\n921\t fi\n922\t done\n923\t fi\n924\t fi\n925\t\n926\t log \" $node_name: +${#to_add_local[@]} local | +${#to_add_remote[@]} remote | $total_skipped blocklisted\"\n927\t\n928\t unset remote_ids\n929\t declare -A remote_ids\n930\t done\n931\t\n932\t echo \" Total added to local: $total_added_local | to remotes: $total_added_remote | blocklisted: $total_skipped\"\n933\t unset local_ids\n934\t declare -A local_ids\n935\t}\n936\t\n937\t# ==============================================================================================\n938\t# ━━━ Main ━━━\n939\t# ==============================================================================================\n940\techo \"\"\n941\techo \"━━━ $ICON_SYNC Loading Blocklist ━━━\"\n942\t_load_blocklist\n943\t\n944\tSTART=$(date +%s)\n945\t\n946\tfor arr_type in \"${ARR_TYPES[@]}\"; do\n947\t _sync_arr \"$arr_type\"\n948\tdone\n949\t\n950\tEND=$(date +%s)\n951\t\n952\techo \"\"\n953\techo \"━━━━━ $ICON_SUMMARY ARR SYNC SUMMARY ━━━━━\"\n954\techo \"$ICON_HOST Node: $MY_ID ($LOCAL_SERVER_NAME)\"\n955\techo \"$ICON_SYNC Peers: ${REMOTE_NODES[*]}\"\n956\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n957\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes were made\"\n958\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n959\t\n960\texit 0\n961\t"}]},"uuid":"bb362d26-9f6f-4f09-a326-c011be2defc1","timestamp":"2026-06-14T05:32:13.898Z","sourceToolAssistantUUID":"a5ad89f9-9537-42a2-a13c-12ff56d9e348","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"ceba4dac-2950-47b1-8fe8-4d0649c251a6","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FG8uHDNR9qtz8nbvXeyxRG","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ========================= Arrs Failed / Stalled Recovery =====================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Detect and recover failed imports and stalled downloads across Sonarr, Radarr,\n9\t# and Lidarr. Blocklists the bad release and triggers a re-search — hands-free\n10\t# overnight recovery.\n11\t#\n12\t# ==============================================================================================\n13\t# OPERATIONAL MODEL\n14\t# ==============================================================================================\n15\t#\n16\t# Four problem types detected from the arr queue API:\n17\t# importFailed — downloaded but arr couldn't import the file\n18\t# importPending — downloaded, stuck waiting to import (will not self-resolve)\n19\t# error status — serious failure not covered by the above two states\n20\t# stalled — download stuck with no connections or no progress\n21\t#\n22\t# Never touches items with state \"downloading\" or \"imported\" — safe to run anytime.\n23\t# Items newer than ARR_IMPORT_RECOVERY_AGE are skipped — gives arr time to retry first.\n24\t#\n25\t# Per problem item (3-step response):\n26\t# 1. Blocklist the release — prevents re-grabbing the same bad release\n27\t# 2. Remove from queue — cleans up the failed item\n28\t# 3. Trigger new search — finds a different release automatically\n29\t#\n30\t# ==============================================================================================\n31\t# OPERATIONAL SAFEGUARDS\n32\t# ==============================================================================================\n33\t#\n34\t# acquire_lock — prevents concurrent runs overlapping\n35\t# jq validation — exits if jq not installed (required for JSON parsing)\n36\t# API pre-flight — checks each arr is reachable before querying queue\n37\t# Version check — check_arr_version() verifies running arr matches master.conf major\n38\t# version; exits rather than silently misoperating after upgrade\n39\t# Age threshold — skips items newer than ARR_IMPORT_RECOVERY_AGE (default 6hr)\n40\t# Silent by default — only problems produce output, clean arrs stay silent\n41\t#\n42\t# API version mapping (endpoint paths differ from major version labels):\n43\t# Sonarr v4 → /api/v3/ (v3 endpoint retained in v4)\n44\t# Radarr v6 → /api/v3/ (v3 endpoint retained in v6)\n45\t# Lidarr v3 → /api/v1/ (different from Sonarr/Radarr)\n46\t#\n47\t# ==============================================================================================\n48\t# STATE FILES\n49\t# ==============================================================================================\n50\t#\n51\t# ARR_RECOVERY_STATS — stats file written after each run (read by coffee report)\n52\t#\n53\t# ==============================================================================================\n54\t# CONFIGURATION\n55\t# ==============================================================================================\n56\t#\n57\t# host*.conf\n58\t#\n59\t# HOST*_SONARR_URL / HOST*_SONARR_API_KEY / HOST*_SONARR_RECOVERY\n60\t# HOST*_RADARR_URL / HOST*_RADARR_API_KEY / HOST*_RADARR_RECOVERY\n61\t# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY / HOST1_LIDARR_RECOVERY\n62\t# All aliased by detect_hosts() — script uses unprefixed names\n63\t#\n64\t# master.conf\n65\t#\n66\t# ARR_IMPORT_RECOVERY_AGE — hours before item is eligible for recovery (default: 6)\n67\t# SONARR_VERSION_MAJOR — expected Sonarr major version (e.g. 4)\n68\t# RADARR_VERSION_MAJOR — expected Radarr major version (e.g. 6)\n69\t# LIDARR_VERSION_MAJOR — expected Lidarr major version (e.g. 3)\n70\t# ARR_RECOVERY_STATS — stats file path (read by coffee report)\n71\t#\n72\t# ==============================================================================================\n73\t# RUNTIME MODES\n74\t# ==============================================================================================\n75\t#\n76\t# arrs_failed_stalled_recovery.sh — normal run\n77\t# arrs_failed_stalled_recovery.sh --dry-run — show what would be actioned, no changes\n78\t# arrs_failed_stalled_recovery.sh --log — verbose output\n79\t# arrs_failed_stalled_recovery.sh --status — show config and exit\n80\t#\n81\t# Recommended schedule: 0 5 * * * (5am daily)\n82\t# Or every 6hr: 0 */6 * * * (matches ARR_IMPORT_RECOVERY_AGE default)\n83\t#\n84\t# ==============================================================================================\n85\t\n86\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n87\t\n88\tsource \"$SCRIPT_DIR/../load_config.sh\"\n89\t\n90\tparse_args \"$@\"\n91\t\n92\t# ==============================================================================================\n93\t# ━━━ Setup ━━━\n94\t# ==============================================================================================\n95\tif [[ \"$EUID\" -ne 0 ]]; then\n96\t error \"Must be run as root\"\n97\t exit 1\n98\tfi\n99\t\n100\tacquire_lock\n101\t\n102\t# detect_hosts() sets MY_ID and aliases SONARR_*, RADARR_*, LIDARR_* vars\n103\tdetect_hosts\n104\t\n105\t# jq is required — not optional — for JSON parsing\n106\tif ! command -v jq >/dev/null 2>&1; then\n107\t error \"jq is not installed — required for arr API JSON parsing\"\n108\t error \"Install: apt-get install jq or brew install jq\"\n109\t notify \"arrs_failed_stalled_recovery failed on $(hostname) — jq not installed\" \\\n110\t \"Arr Recovery\" \"warning\"\n111\t exit 1\n112\tfi\n113\tlog \"jq found\"\n114\t\n115\t\n116\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no items will be blocklisted or searched\"\n117\t\n118\t# Age threshold in seconds\n119\tAGE_THRESHOLD_SECONDS=$(( ARR_IMPORT_RECOVERY_AGE * 3600 ))\n120\t\n121\tlog \"$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:-?}\"\n122\t\n123\tTOTAL_ACTIONED=0\n124\tTOTAL_SKIPPED=0\n125\tARR_SUMMARIES=()\n126\t\n127\t# ==============================================================================================\n128\t# ━━━ Status ━━━\n129\t# ==============================================================================================\n130\tif [[ \"$SHOW_STATUS\" == true ]]; then\n131\t echo \"\"\n132\t echo \"━━━━━ $ICON_SUMMARY STATUS ━━━━━\"\n133\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n134\t echo \"$ICON_SYNC Sonarr: ${SONARR_URL:-not configured} (recovery: ${SONARR_RECOVERY:-true})\"\n135\t echo \"$ICON_SYNC Radarr: ${RADARR_URL:-not configured} (recovery: ${RADARR_RECOVERY:-true})\"\n136\t echo \"$ICON_SYNC Lidarr: ${LIDARR_URL:-not configured on this host} (recovery: ${LIDARR_RECOVERY:-false})\"\n137\t echo \"$ICON_TIME Age thresh: ${ARR_IMPORT_RECOVERY_AGE}hr\"\n138\t echo \"$ICON_GEAR Sonarr ver: v${SONARR_VERSION_MAJOR} expected\"\n139\t echo \"$ICON_GEAR Radarr ver: v${RADARR_VERSION_MAJOR} expected\"\n140\t echo \"$ICON_GEAR Lidarr ver: v${LIDARR_VERSION_MAJOR} expected\"\n141\t echo \"$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n \"${MY_DISCORD_WEBHOOK:-}\" ]] && echo enabled || echo disabled)\"\n142\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n143\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n144\t exit 0\n145\tfi\n146\t\n147\t# ==============================================================================================\n148\t# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────\n149\t# ==============================================================================================\n150\t\n151\t# Check if a queue item is older than ARR_IMPORT_RECOVERY_AGE\n152\t# Returns 0 (old enough) or 1 (too new — skip)\n153\titem_is_old_enough() {\n154\t local added=\"$1\"\n155\t [[ -z \"$added\" ]] && return 0 # no date = treat as old enough, safe to act\n156\t local added_epoch\n157\t added_epoch=$(date -d \"$added\" +%s 2>/dev/null) || return 0\n158\t local age_seconds=$(( $(date +%s) - added_epoch ))\n159\t [[ \"$age_seconds\" -ge \"$AGE_THRESHOLD_SECONDS\" ]]\n160\t}\n161\t\n162\t# Query the arr queue API and return all records\n163\t# Args: url, api_key, api_version\n164\tget_queue_data() {\n165\t local url=\"$1\" api_key=\"$2\" api_version=\"$3\"\n166\t curl -sf --max-time 15 \\\n167\t -H \"X-Api-Key: $api_key\" \\\n168\t \"${url}/api/${api_version}/queue?page=1&pageSize=200&includeUnknownSeriesItems=true&includeUnknownArtistItems=true\" \\\n169\t 2>/dev/null\n170\t}\n171\t\n172\t# Blocklist and remove a queue item\n173\t# Args: url, api_key, api_version, queue_id\n174\tblocklist_item() {\n175\t local url=\"$1\" api_key=\"$2\" api_version=\"$3\" queue_id=\"$4\"\n176\t if [[ \"$DRY_RUN\" == true ]]; then\n177\t warn \"DRY RUN — would blocklist queue item $queue_id\"\n178\t return 0\n179\t fi\n180\t curl -sf --max-time 15 \\\n181\t -X DELETE \\\n182\t -H \"X-Api-Key: $api_key\" \\\n183\t \"${url}/api/${api_version}/queue/${queue_id}?removeFromClient=true&blocklist=true&skipRedownload=false\" \\\n184\t >/dev/null 2>&1\n185\t}\n186\t\n187\t# Trigger a new search for the media item\n188\t# Args: url, api_key, api_version, arr_type, media_id\n189\ttrigger_search() {\n190\t local url=\"$1\" api_key=\"$2\" api_version=\"$3\" arr_type=\"$4\" media_id=\"$5\"\n191\t local command body\n192\t case \"$arr_type\" in\n193\t sonarr) command=\"EpisodeSearch\"; body=\"{\\\"name\\\":\\\"EpisodeSearch\\\",\\\"episodeIds\\\":[$media_id]}\" ;;\n194\t radarr) command=\"MoviesSearch\"; body=\"{\\\"name\\\":\\\"MoviesSearch\\\",\\\"movieIds\\\":[$media_id]}\" ;;\n195\t lidarr) command=\"AlbumSearch\"; body=\"{\\\"name\\\":\\\"AlbumSearch\\\",\\\"albumIds\\\":[$media_id]}\" ;;\n196\t esac\n197\t if [[ \"$DRY_RUN\" == true ]]; then\n198\t warn \"DRY RUN — would trigger $command for media ID $media_id\"\n199\t return 0\n200\t fi\n201\t curl -sf --max-time 15 \\\n202\t -X POST \\\n203\t -H \"X-Api-Key: $api_key\" \\\n204\t -H \"Content-Type: application/json\" \\\n205\t -d \"$body\" \\\n206\t \"${url}/api/${api_version}/command\" \\\n207\t >/dev/null 2>&1\n208\t}\n209\t\n210\t# ==============================================================================================\n211\t# ── PROCESS AN ARR ────────────────────────────────────────────────────────────────────────────\n212\t# ==============================================================================================\n213\t# Args: display_name, arr_type, url, api_key, api_version, enabled,\n214\t# version_major, version_api_prefix\n215\t#\n216\t# Exits cleanly if disabled.\n217\t# Checks API reachability and version before touching queue.\n218\t# Processes each problem item: blocklist + trigger new search.\n219\t# Silent when clean — only warns when problems found or actioned.\n220\t\n221\tprocess_arr() {\n222\t local arr_name=\"$1\"\n223\t local arr_type=\"$2\"\n224\t local url=\"$3\"\n225\t local api_key=\"$4\"\n226\t local api_version=\"$5\"\n227\t local enabled=\"$6\"\n228\t local version_major=\"$7\"\n229\t local version_api_prefix=\"$8\"\n230\t\n231\t local actioned=0 skipped_new=0\n232\t\n233\t echo \"\"\n234\t echo \"━━━ $ICON_SYNC $arr_name ━━━\"\n235\t\n236\t # Disabled — skip cleanly\n237\t if [[ \"$enabled\" != \"true\" ]]; then\n238\t log \"$arr_name recovery disabled — skipping\"\n239\t ARR_SUMMARIES+=(\"$arr_name: disabled\")\n240\t return\n241\t fi\n242\t\n243\t # URL not configured on this host — skip cleanly\n244\t if [[ -z \"$url\" ]]; then\n245\t log \"$arr_name not configured on $MY_ID — skipping\"\n246\t ARR_SUMMARIES+=(\"$arr_name: not configured on $MY_ID\")\n247\t return\n248\t fi\n249\t\n250\t # API reachability\n251\t if ! check_api \"$url\" \"$arr_name\" 10; then\n252\t warn \"$arr_name API unreachable — skipping\"\n253\t ARR_SUMMARIES+=(\"$arr_name: unreachable\")\n254\t return\n255\t fi\n256\t\n257\t # Version check — exit if API structure may have changed\n258\t if ! check_arr_version \"$url\" \"$api_key\" \"$version_api_prefix\" \\\n259\t \"$version_major\" \"$arr_name\"; then\n260\t ARR_SUMMARIES+=(\"$arr_name: version mismatch — skipped\")\n261\t return\n262\t fi\n263\t\n264\t # Fetch queue\n265\t local queue_data\n266\t queue_data=$(get_queue_data \"$url\" \"$api_key\" \"$api_version\")\n267\t if [[ -z \"$queue_data\" ]]; then\n268\t warn \"$arr_name — could not retrieve queue data\"\n269\t ARR_SUMMARIES+=(\"$arr_name: queue fetch failed\")\n270\t return\n271\t fi\n272\t\n273\t local total_records\n274\t total_records=$(echo \"$queue_data\" | jq '.totalRecords // 0' 2>/dev/null)\n275\t log \"$arr_name queue: $total_records total items\"\n276\t\n277\t # Filter for problem items — never touch downloading or imported\n278\t local problem_items\n279\t problem_items=$(echo \"$queue_data\" | jq -c '\n280\t .records // [] |\n281\t .[] |\n282\t select(\n283\t .trackedDownloadState != \"downloading\" and\n284\t .trackedDownloadState != \"imported\" and\n285\t (\n286\t .trackedDownloadState == \"importFailed\" or\n287\t .trackedDownloadState == \"importPending\" or\n288\t .trackedDownloadStatus == \"error\" or\n289\t (.status == \"warning\" and (\n290\t (.errorMessage // \"\" | ascii_downcase | contains(\"stalled\")) or\n291\t (.statusMessages // [] | .[] | .messages // [] | .[] |\n292\t ascii_downcase | contains(\"stalled\"))\n293\t ))\n294\t )\n295\t )\n296\t ' 2>/dev/null)\n297\t\n298\t if [[ -z \"$problem_items\" ]]; then\n299\t log \"$arr_name — clean ✅ no failed imports or stalled downloads\"\n300\t ARR_SUMMARIES+=(\"$arr_name: clean ✅\")\n301\t return\n302\t fi\n303\t\n304\t local problem_count\n305\t problem_count=$(echo \"$problem_items\" | wc -l)\n306\t warn \"$arr_name — found $problem_count problem item(s)\"\n307\t\n308\t # Process each problem item\n309\t while IFS= read -r item; do\n310\t [[ -z \"$item\" ]] && continue\n311\t\n312\t local queue_id title added tracked_state tracked_status problem_type media_id\n313\t\n314\t queue_id=$(echo \"$item\" | jq -r '.id // empty' 2>/dev/null)\n315\t title=$(echo \"$item\" | jq -r '.title // \"Unknown\"' 2>/dev/null)\n316\t added=$(echo \"$item\" | jq -r '.added // empty' 2>/dev/null)\n317\t tracked_state=$(echo \"$item\" | jq -r '.trackedDownloadState // \"\"' 2>/dev/null)\n318\t tracked_status=$(echo \"$item\" | jq -r '.trackedDownloadStatus // \"\"' 2>/dev/null)\n319\t\n320\t # Human-readable problem type\n321\t case \"$tracked_state\" in\n322\t importFailed) problem_type=\"import failed\" ;;\n323\t importPending) problem_type=\"import pending/stuck\" ;;\n324\t *)\n325\t [[ \"$tracked_status\" == \"error\" ]] && \\\n326\t problem_type=\"error\" || problem_type=\"stalled\"\n327\t ;;\n328\t esac\n329\t\n330\t # Media ID for search trigger\n331\t case \"$arr_type\" in\n332\t sonarr) media_id=$(echo \"$item\" | jq -r '.episodeId // .episode.id // empty' 2>/dev/null) ;;\n333\t radarr) media_id=$(echo \"$item\" | jq -r '.movieId // .movie.id // empty' 2>/dev/null) ;;\n334\t lidarr) media_id=$(echo \"$item\" | jq -r '.albumId // .album.id // empty' 2>/dev/null) ;;\n335\t esac\n336\t\n337\t [[ -z \"$queue_id\" ]] && continue\n338\t\n339\t # Age check — skip items that are too new to have self-resolved\n340\t if ! item_is_old_enough \"$added\"; then\n341\t log \" Skipping (too new < ${ARR_IMPORT_RECOVERY_AGE}hr): $title\"\n342\t (( skipped_new++ ))\n343\t (( TOTAL_SKIPPED++ ))\n344\t continue\n345\t fi\n346\t\n347\t warn \" $ICON_TRASH $problem_type — $title\"\n348\t\n349\t # Step 1: Blocklist + remove from queue\n350\t if ! blocklist_item \"$url\" \"$api_key\" \"$api_version\" \"$queue_id\"; then\n351\t warn \" Failed to blocklist: $title\"\n352\t (( TOTAL_SKIPPED++ ))\n353\t continue\n354\t fi\n355\t log \" Blocklisted: $queue_id\"\n356\t\n357\t # Step 2: Trigger new search\n358\t if [[ -n \"$media_id\" ]]; then\n359\t if trigger_search \"$url\" \"$api_key\" \"$api_version\" \"$arr_type\" \"$media_id\"; then\n360\t log \" New search triggered: $title\"\n361\t else\n362\t warn \" Blocklisted but search trigger failed: $title\"\n363\t fi\n364\t else\n365\t warn \" Blocklisted but no media ID found — search not triggered: $title\"\n366\t fi\n367\t\n368\t (( actioned++ ))\n369\t (( TOTAL_ACTIONED++ ))\n370\t\n371\t done <<< \"$problem_items\"\n372\t\n373\t if [[ \"$actioned\" -gt 0 ]]; then\n374\t warn \"$arr_name — actioned: $actioned | skipped (too new): $skipped_new\"\n375\t else\n376\t log \"$arr_name — nothing actioned | skipped (too new): $skipped_new\"\n377\t fi\n378\t\n379\t ARR_SUMMARIES+=(\"$arr_name: actioned $actioned | too new $skipped_new\")\n380\t}\n381\t\n382\t# ==============================================================================================\n383\t# ━━━ Process Each Arr ━━━\n384\t# ==============================================================================================\n385\techo \"\"\n386\techo \"━━━ $ICON_SYNC Arrs Failed/Stalled Recovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━\"\n387\techo \"$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)\"\n388\tlog \"Age threshold: ${ARR_IMPORT_RECOVERY_AGE}hr\"\n389\t\n390\tSTART=$(date +%s)\n391\t\n392\t# Sonarr — uses aliased vars set by detect_hosts()\n393\tprocess_arr \\\n394\t \"Sonarr\" \\\n395\t \"sonarr\" \\\n396\t \"${SONARR_URL:-}\" \\\n397\t \"${SONARR_API_KEY:-}\" \\\n398\t \"v3\" \\\n399\t \"${SONARR_RECOVERY:-true}\" \\\n400\t \"${SONARR_VERSION_MAJOR:-4}\" \\\n401\t \"v3\"\n402\t\n403\t# Radarr — uses aliased vars set by detect_hosts()\n404\tprocess_arr \\\n405\t \"Radarr\" \\\n406\t \"radarr\" \\\n407\t \"${RADARR_URL:-}\" \\\n408\t \"${RADARR_API_KEY:-}\" \\\n409\t \"v3\" \\\n410\t \"${RADARR_RECOVERY:-true}\" \\\n411\t \"${RADARR_VERSION_MAJOR:-6}\" \\\n412\t \"v3\"\n413\t\n414\t# Lidarr — HOST1 only, LIDARR_URL empty on HOST2 → exits cleanly via \"not configured\" guard\n415\tprocess_arr \\\n416\t \"Lidarr\" \\\n417\t \"lidarr\" \\\n418\t \"${LIDARR_URL:-}\" \\\n419\t \"${LIDARR_API_KEY:-}\" \\\n420\t \"v1\" \\\n421\t \"${LIDARR_RECOVERY:-false}\" \\\n422\t \"${LIDARR_VERSION_MAJOR:-3}\" \\\n423\t \"v1\"\n424\t\n425\tEND=$(date +%s)\n426\t\n427\t# ==============================================================================================\n428\t# ━━━ Summary ━━━\n429\t# ==============================================================================================\n430\techo \"\"\n431\techo \"━━━━━ $ICON_SUMMARY ARR RECOVERY SUMMARY ━━━━━\"\n432\techo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n433\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n434\techo \"$ICON_TRASH Actioned: $TOTAL_ACTIONED items blocklisted + searched\"\n435\techo \"$ICON_SKIP Skipped: $TOTAL_SKIPPED items (too new)\"\n436\techo \"\"\n437\tfor summary in \"${ARR_SUMMARIES[@]}\"; do\n438\t echo \" $ICON_SUMMARY $summary\"\n439\tdone\n440\techo \"\"\n441\t\n442\tif [[ \"$DRY_RUN\" == true ]]; then\n443\t warn \"DRY RUN — no changes made\"\n444\telif [[ \"$TOTAL_ACTIONED\" -gt 0 ]]; then\n445\t warn \"$ICON_DONE Done — $TOTAL_ACTIONED item(s) blocklisted and re-searched\"\n446\t notify \"Arr recovery on $(hostname) — $TOTAL_ACTIONED item(s) blocklisted and re-searched\" \\\n447\t \"Arr Recovery\" \"warning\"\n448\telse\n449\t echo \"$ICON_DONE Done — nothing to recover (all arrs clean)\"\n450\tfi\n451\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n452\t\n453\t# Write stats for sunday_morning_coffee_report.sh\n454\tif [[ \"$DRY_RUN\" == false ]] && [[ -n \"${ARR_RECOVERY_STATS:-}\" ]]; then\n455\t echo \"$(date '+%Y-%m-%d')|$(date '+%H:%M')|${TOTAL_ACTIONED}|${TOTAL_SKIPPED}\" \\\n456\t >> \"$ARR_RECOVERY_STATS\" 2>/dev/null || true\n457\tfi\n458\t\n459\texit 0"}]},"uuid":"ca9839da-c55a-450c-ad38-31e3a6806681","timestamp":"2026-06-14T05:32:13.876Z","sourceToolAssistantUUID":"ceba4dac-2950-47b1-8fe8-4d0649c251a6","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"ca9839da-c55a-450c-ad38-31e3a6806681","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EYWEnGmkXkoRidaE5sFMep","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/lidarr_cleanup.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"8a792f86-8ed0-4b4b-b582-16125042a476","timestamp":"2026-06-14T05:32:14.103Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"8a792f86-8ed0-4b4b-b582-16125042a476","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EYWEnGmkXkoRidaE5sFMep","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ================================= Lidarr Cleanup =============================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Delete orphaned music files not tracked by Lidarr. Queries the API for all\n9\t# tracked file paths, walks the library on disk, and removes anything untracked\n10\t# that is old enough to be past the import window. Triggers an Emby library\n11\t# clean after each deletion run so ghost entries disappear immediately.\n12\t#\n13\t# ==============================================================================================\n14\t# OPERATIONAL MODEL\n15\t# ==============================================================================================\n16\t#\n17\t# Every file encountered on disk is classified into one of five categories:\n18\t#\n19\t# TRACKED — Lidarr API knows this exact path → leave it alone\n20\t# PROTECTED — matches LIDARR_PROTECTED_PATTERNS → never delete\n21\t# ORPHAN — music file, not tracked, older than LIDARR_ORPHAN_AGE → delete\n22\t# JUNK — not a music extension, not protected → delete regardless of age\n23\t# RECENT — not tracked, under LIDARR_ORPHAN_AGE → skip (may be mid-import)\n24\t#\n25\t# Lidarr generates cover art (*.jpg), metadata (*.nfo), and lyrics (*.lrc) but\n26\t# does NOT include these in its tracked file API response. Without PROTECTED\n27\t# classification these would be deleted — breaking Lidarr and Emby display.\n28\t#\n29\t# ==============================================================================================\n30\t# OPERATIONAL SAFEGUARDS\n31\t# ==============================================================================================\n32\t#\n33\t# Seven gates — ALL must pass before any file is touched:\n34\t# 1. Container running and not starting/unhealthy\n35\t# 2. API reachable\n36\t# 3. API version matches LIDARR_VERSION_MAJOR in master.conf\n37\t# 4. Artist count > 0\n38\t# 5. Tracked file count > 0\n39\t# 6. Tracked count >= LIDARR_MIN_TRACKED_PCT % of last known count\n40\t# 7. Deletion size < LIDARR_MAX_DELETE_GB — or --i-know-what-im-doing required\n41\t#\n42\t# acquire_lock \"wait\" — large scans take time, wait for previous run to finish\n43\t# jq + curl validation — exits if either tool missing\n44\t# DOCKER_TIMEOUT — container checks protected against daemon hangs\n45\t# Duplicate detection — temp file of tracked paths, grep before delete\n46\t# platform_require_cmd — notify script validated before use\n47\t# Silent by default — orphans/junk warn(), clean library logs silently\n48\t#\n49\t# ==============================================================================================\n50\t# STATE FILES\n51\t# ==============================================================================================\n52\t#\n53\t# LIDARR_TRACKED_COUNT_FILE — persistent baseline for the tracked % safety check (gate 6)\n54\t# Updated after each successful run. Protects against misconfigured root path\n55\t# returning an empty API response and deleting the entire library.\n56\t#\n57\t# ==============================================================================================\n58\t# CONFIGURATION\n59\t# ==============================================================================================\n60\t#\n61\t# host*.conf\n62\t#\n63\t# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY / HOST1_LIDARR_MUSIC_ROOT\n64\t# HOST1_LIDARR_PATH_MAP — container path → host path translation\n65\t# All aliased by detect_hosts() — script uses unprefixed names\n66\t#\n67\t# master.conf\n68\t#\n69\t# LIDARR_ORPHAN_AGE — days before untracked file is eligible for deletion\n70\t# LIDARR_MAX_DELETE_GB — require --i-know-what-im-doing above this\n71\t# LIDARR_MIN_TRACKED_PCT — abort if tracked count drops below this % of last run\n72\t# LIDARR_TRACKED_COUNT_FILE — persistent baseline file path\n73\t# LIDARR_EXTENSIONS — music file extensions for orphan classification\n74\t# LIDARR_PROTECTED_PATTERNS — file patterns that are never deleted\n75\t# LIDARR_VERSION_MAJOR — expected Lidarr major version for API safety check\n76\t# LIDARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)\n77\t# LIDARR_LOCK_WARN_AGE — override default lock warning age (large libraries)\n78\t# ARR_CLEANUP_STATS — stats file path (read by coffee report)\n79\t#\n80\t# ==============================================================================================\n81\t# RUNTIME MODES\n82\t# ==============================================================================================\n83\t#\n84\t# lidarr_cleanup.sh — normal run\n85\t# lidarr_cleanup.sh --dry-run — preview, no deletions\n86\t# lidarr_cleanup.sh --log — verbose output\n87\t# lidarr_cleanup.sh --status — show config and exit\n88\t# lidarr_cleanup.sh --i-know-what-im-doing — bypass size threshold\n89\t# lidarr_cleanup.sh --i-know-what-im-doing --skip-strike-list — NUCLEAR MODE\n90\t#\n91\t# NUCLEAR MODE: both flags bypass age check AND size threshold. Use when Soularr\n92\t# has filled the gaps and you want a clean one-pass wipe. User accepts full\n93\t# responsibility — the flag name is long and annoying by design.\n94\t#\n95\t# ==============================================================================================\n96\t\n97\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n98\t\n99\tsource \"$SCRIPT_DIR/../load_config.sh\"\n100\t\n101\t# ── Special flag pre-processing ───────────────────────────────────────────────────────────────\n102\t# Filter --i-know-what-im-doing and --skip-strike-list before parse_args\n103\t# to avoid unknown flag errors — these are handled separately below.\n104\tI_KNOW=false\n105\tSKIP_STRIKES=false\n106\tFILTERED_ARGS=()\n107\tfor arg in \"$@\"; do\n108\t case \"$arg\" in\n109\t --i-know-what-im-doing) I_KNOW=true ;;\n110\t --skip-strike-list) SKIP_STRIKES=true ;;\n111\t *) FILTERED_ARGS+=(\"$arg\") ;;\n112\t esac\n113\tdone\n114\t\n115\tparse_args \"${FILTERED_ARGS[@]}\"\n116\t\n117\t# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────\n118\tif [[ \"$I_KNOW\" == true ]] && [[ \"$SKIP_STRIKES\" == true ]] && [[ \"$DRY_RUN\" != true ]]; then\n119\t echo \"\"\n120\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n121\t echo \"⚠️ WARNING — NUCLEAR MODE ACTIVE\"\n122\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n123\t echo \" Flags: --i-know-what-im-doing --skip-strike-list\"\n124\t echo \" Strike system: BYPASSED — deletes on first pass\"\n125\t echo \" Size threshold: BYPASSED — no GB limit\"\n126\t echo \" Data recovery: NOT POSSIBLE after deletion\"\n127\t echo \"\"\n128\t echo \" Review --dry-run output before proceeding.\"\n129\t echo \" You have 10 seconds to cancel (Ctrl+C)...\"\n130\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n131\t sleep 10\n132\t echo \" Proceeding...\"\n133\t echo \"\"\n134\tfi\n135\t\n136\t# ==============================================================================================\n137\t# ━━━ Setup ━━━\n138\t# ==============================================================================================\n139\tif [[ \"$EUID\" -ne 0 ]]; then\n140\t error \"Must be run as root\"\n141\t exit 1\n142\tfi\n143\t\n144\t# Tool validation — both required, fail fast\n145\tif ! command -v curl >/dev/null 2>&1; then\n146\t error \"curl not found — required for Lidarr API calls\"\n147\t exit 1\n148\tfi\n149\t\n150\tif ! command -v jq >/dev/null 2>&1; then\n151\t error \"jq not found — required for JSON parsing\"\n152\t notify \"Lidarr cleanup failed on $(hostname) — jq not installed\" \"Lidarr Cleanup\" \"warning\"\n153\t exit 1\n154\tfi\n155\t\n156\t\n157\t# Lock before detect_hosts — large library scans take time, wait mode appropriate\n158\t[[ -n \"${LIDARR_LOCK_WARN_AGE:-}\" ]] && LOCK_WARN_AGE=\"$LIDARR_LOCK_WARN_AGE\"\n159\tacquire_lock \"wait\"\n160\tTMP_DIR=\"/tmp/lidarr_cleanup_$$\"\n161\tmkdir -p \"$TMP_DIR\"\n162\ttrap \"_release_all_locks; rm -rf $TMP_DIR\" EXIT\n163\t\n164\tif ! command -v docker &>/dev/null; then\n165\t error \"Docker command not found\"\n166\t exit 1\n167\tfi\n168\t\n169\t# detect_hosts() sets MY_ID and aliases LIDARR_URL, LIDARR_API_KEY, LIDARR_MUSIC_ROOT\n170\tdetect_hosts\n171\t\n172\t# Skip if Lidarr is not configured on this host\n173\tif [[ -z \"${LIDARR_URL:-}\" ]] || [[ -z \"${LIDARR_API_KEY:-}\" ]]; then\n174\t info \"Lidarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping\"\n175\t exit 0\n176\tfi\n177\t\n178\tDOCKER_TIMEOUT=15\n179\tLIDARR_CONTAINER=\"Lidarr\" # container name on HOST1\n180\t\n181\t# Build path map from HOST1_LIDARR_PATH_MAP for translate_path()\n182\tdeclare -A ARR_PATH_MAP\n183\tfor key in \"${!HOST1_LIDARR_PATH_MAP[@]}\"; do\n184\t ARR_PATH_MAP[\"$key\"]=\"${HOST1_LIDARR_PATH_MAP[$key]}\"\n185\tdone\n186\t\n187\t# Validate required vars — detect_hosts() should have set these\n188\trequire_var LIDARR_URL\n189\trequire_var LIDARR_API_KEY\n190\trequire_var LIDARR_MUSIC_ROOT\n191\t\n192\tif [[ ! -d \"$LIDARR_MUSIC_ROOT\" ]]; then\n193\t error \"Music root not found: $LIDARR_MUSIC_ROOT\"\n194\t notify \"Lidarr cleanup failed on $(hostname) — music root not found: $LIDARR_MUSIC_ROOT\" \\\n195\t \"Lidarr Cleanup\" \"warning\"\n196\t exit 1\n197\tfi\n198\t\n199\tlog \"$ICON_GEAR Config: url=${LIDARR_URL} root=${LIDARR_MUSIC_ROOT}\"\n200\techo \" $MY_ID ($LOCAL_SERVER_NAME) — $LIDARR_URL\"\n201\t\n202\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no files will be deleted\"\n203\t[[ \"$I_KNOW\" == true ]] && warn \"OVERRIDE — --i-know-what-im-doing active\"\n204\t[[ \"$SKIP_STRIKES\" == true ]] && warn \"OVERRIDE — --skip-strike-list active — age check bypassed\"\n205\t\n206\t# ==============================================================================================\n207\t# ━━━ Status ━━━\n208\t# ==============================================================================================\n209\tif [[ \"$SHOW_STATUS\" == true ]]; then\n210\t echo \"\"\n211\t echo \"━━━━━ $ICON_SUMMARY STATUS ━━━━━\"\n212\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n213\t echo \"$ICON_GEAR Lidarr URL: $LIDARR_URL\"\n214\t echo \"$ICON_GEAR Music root: $LIDARR_MUSIC_ROOT\"\n215\t echo \"$ICON_TIME Orphan age: ${LIDARR_ORPHAN_AGE} days\"\n216\t echo \"$ICON_GEAR Max delete: ${LIDARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)\"\n217\t echo \"$ICON_GEAR Min tracked %: ${LIDARR_MIN_TRACKED_PCT}%\"\n218\t echo \"$ICON_GEAR Lidarr ver: v${LIDARR_VERSION_MAJOR} expected\"\n219\t echo \"$ICON_GEAR Extensions: ${LIDARR_EXTENSIONS[*]}\"\n220\t echo \"$ICON_GEAR Protected patterns: ${LIDARR_PROTECTED_PATTERNS[*]}\"\n221\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n222\t echo \"$ICON_GEAR I know: $I_KNOW\"\n223\t echo \"$ICON_GEAR Skip strikes: $SKIP_STRIKES\"\n224\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n225\t exit 0\n226\tfi\n227\t\n228\t# ==============================================================================================\n229\t# ━━━ Safety Layer 1 — Container Health ━━━\n230\t# ==============================================================================================\n231\techo \"\"\n232\techo \"━━━ $ICON_SHIELD Safety Checks ━━━\"\n233\t\n234\tCONTAINER_RUNNING=$(timeout \"$DOCKER_TIMEOUT\" docker inspect -f \\\n235\t '{{.State.Running}}' \"$LIDARR_CONTAINER\" 2>/dev/null)\n236\tif [[ \"$CONTAINER_RUNNING\" != \"true\" ]]; then\n237\t error \"$LIDARR_CONTAINER is not running — aborting\"\n238\t notify \"Lidarr cleanup aborted on $(hostname) — container not running\" \\\n239\t \"Lidarr Cleanup\" \"warning\"\n240\t exit 1\n241\tfi\n242\t\n243\tCONTAINER_HEALTH=$(timeout \"$DOCKER_TIMEOUT\" docker inspect -f \\\n244\t '{{.State.Health.Status}}' \"$LIDARR_CONTAINER\" 2>/dev/null)\n245\tcase \"$CONTAINER_HEALTH\" in\n246\t healthy) info \"$LIDARR_CONTAINER is healthy\" ;;\n247\t \"\") info \"$LIDARR_CONTAINER has no health check — proceeding\" ;;\n248\t starting)\n249\t error \"$LIDARR_CONTAINER is still starting — aborting\"\n250\t notify \"Lidarr cleanup aborted on $(hostname) — container still starting\" \\\n251\t \"Lidarr Cleanup\" \"warning\"\n252\t exit 1 ;;\n253\t unhealthy)\n254\t error \"$LIDARR_CONTAINER is unhealthy — aborting\"\n255\t notify \"Lidarr cleanup aborted on $(hostname) — container unhealthy\" \\\n256\t \"Lidarr Cleanup\" \"warning\"\n257\t exit 1 ;;\n258\t *) warn \"$LIDARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution\" ;;\n259\tesac\n260\t\n261\tinfo \"Safety layer 1 passed — container healthy\"\n262\t\n263\t# ==============================================================================================\n264\t# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────\n265\t# ==============================================================================================\n266\t\n267\t# Lidarr API call with HTTP status check\n268\t# Usage: lidarr_api \"artist\" | lidarr_api \"trackFile?artistId=123\"\n269\tlidarr_api() {\n270\t local endpoint=\"$1\"\n271\t local response http_code body\n272\t\n273\t response=$(curl -sf \\\n274\t --max-time 30 \\\n275\t -H \"X-Api-Key: $LIDARR_API_KEY\" \\\n276\t -w \"\\n%{http_code}\" \\\n277\t \"${LIDARR_URL}/api/v1/${endpoint}\" 2>/dev/null)\n278\t\n279\t http_code=$(echo \"$response\" | tail -1)\n280\t body=$(echo \"$response\" | head -n -1)\n281\t\n282\t if [[ \"$http_code\" != \"200\" ]]; then\n283\t error \"Lidarr API HTTP $http_code for: $endpoint\"\n284\t return 1\n285\t fi\n286\t echo \"$body\"\n287\t}\n288\t\n289\t# Check if a file extension is a tracked music format\n290\tis_music_file() {\n291\t local ext=\"${1##*.}\"\n292\t ext=\"${ext,,}\"\n293\t for valid_ext in \"${LIDARR_EXTENSIONS[@]}\"; do\n294\t [[ \"$ext\" == \"$valid_ext\" ]] && return 0\n295\t done\n296\t return 1\n297\t}\n298\t\n299\t# Check if a file matches any protected pattern\n300\tis_protected_file() {\n301\t local filename\n302\t filename=$(basename \"$1\")\n303\t for pattern in \"${LIDARR_PROTECTED_PATTERNS[@]}\"; do\n304\t # shellcheck disable=SC2254\n305\t case \"$filename\" in\n306\t $pattern) return 0 ;;\n307\t esac\n308\t done\n309\t return 1\n310\t}\n311\t\n312\t# ==============================================================================================\n313\t# ━━━ Pre-flight: Lidarr Import Scan ━━━\n314\t# ==============================================================================================\n315\techo \"\"\n316\techo \"━━━ $ICON_SYNC Pre-flight: Lidarr Import Scan ━━━\"\n317\t\n318\t# Reverse-lookup container path from path map so Lidarr gets its own path, not the host path\n319\tLIDARR_CONTAINER_ROOT=\"\"\n320\tfor _cp in \"${!ARR_PATH_MAP[@]}\"; do\n321\t if [[ \"${ARR_PATH_MAP[$_cp]}\" == \"$LIDARR_MUSIC_ROOT\" ]]; then\n322\t LIDARR_CONTAINER_ROOT=\"$_cp\"\n323\t break\n324\t fi\n325\tdone\n326\tunset _cp\n327\t\n328\tif [[ -n \"$LIDARR_CONTAINER_ROOT\" ]]; then\n329\t info \"Triggering DownloadedAlbumsScan on: $LIDARR_CONTAINER_ROOT\"\n330\t SCAN_PAYLOAD=\"{\\\"name\\\": \\\"DownloadedAlbumsScan\\\", \\\"path\\\": \\\"$LIDARR_CONTAINER_ROOT\\\"}\"\n331\telse\n332\t info \"No path map match — triggering DownloadedAlbumsScan (all root folders)\"\n333\t SCAN_PAYLOAD='{\"name\": \"DownloadedAlbumsScan\"}'\n334\tfi\n335\t\n336\tSCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \\\n337\t -H \"X-Api-Key: $LIDARR_API_KEY\" \\\n338\t -H \"Content-Type: application/json\" \\\n339\t -d \"$SCAN_PAYLOAD\" \\\n340\t \"${LIDARR_URL}/api/v1/command\" 2>/dev/null)\n341\t\n342\tSCAN_CMD_ID=$(echo \"$SCAN_RESPONSE\" | jq -r '.id // empty' 2>/dev/null)\n343\t\n344\tif [[ -z \"$SCAN_CMD_ID\" ]]; then\n345\t warn \"Could not trigger import scan — proceeding without pre-flight\"\n346\telse\n347\t info \"Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion...\"\n348\t POLL_TIMEOUT=${LIDARR_IMPORT_SCAN_TIMEOUT:-600}\n349\t POLLED=0\n350\t while [[ \"$POLLED\" -lt \"$POLL_TIMEOUT\" ]]; do\n351\t SCAN_STATUS=$(curl -sf --max-time 10 \\\n352\t -H \"X-Api-Key: $LIDARR_API_KEY\" \\\n353\t \"${LIDARR_URL}/api/v1/command/${SCAN_CMD_ID}\" 2>/dev/null | \\\n354\t jq -r '.status // empty' 2>/dev/null)\n355\t case \"$SCAN_STATUS\" in\n356\t completed) info \"Import scan complete ✅\"; break ;;\n357\t failed) warn \"Import scan reported failed — proceeding anyway\"; break ;;\n358\t esac\n359\t sleep 10\n360\t (( POLLED += 10 ))\n361\t [[ $(( POLLED % 60 )) -eq 0 ]] && log \" Still scanning... (${POLLED}s elapsed)\"\n362\t done\n363\t [[ \"$POLLED\" -ge \"$POLL_TIMEOUT\" ]] && \\\n364\t warn \"Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway\"\n365\tfi\n366\t\n367\t# ==============================================================================================\n368\t# ━━━ Fetch Lidarr Tracked Files ━━━\n369\t# ==============================================================================================\n370\techo \"\"\n371\techo \"━━━ $ICON_SYNC Fetching Lidarr Tracked Files ━━━\"\n372\t\n373\t# Safety Layer 2 — API reachability\n374\tif ! check_api \"$LIDARR_URL\" \"Lidarr\" 10; then\n375\t notify \"Lidarr cleanup aborted on $(hostname) — API unreachable\" \"Lidarr Cleanup\" \"warning\"\n376\t exit 1\n377\tfi\n378\t\n379\t# Safety Layer 3 — API version check\n380\tcheck_arr_version \"$LIDARR_URL\" \"$LIDARR_API_KEY\" \"v1\" \"$LIDARR_VERSION_MAJOR\" \"Lidarr\" || exit 1\n381\t\n382\tinfo \"Querying Lidarr API...\"\n383\t\n384\t# Fetch all artists\n385\tARTIST_RESPONSE=$(lidarr_api \"artist\") || {\n386\t error \"Failed to fetch artists from Lidarr\"\n387\t notify \"Lidarr cleanup failed on $(hostname) — could not fetch artists\" \\\n388\t \"Lidarr Cleanup\" \"warning\"\n389\t exit 1\n390\t}\n391\t\n392\tARTIST_IDS=$(echo \"$ARTIST_RESPONSE\" | jq -r '.[].id' 2>/dev/null)\n393\tARTIST_COUNT=$(echo \"$ARTIST_IDS\" | grep -c \"[0-9]\" 2>/dev/null || echo 0)\n394\t\n395\t# Safety Layer 4 — artist count > 0\n396\tif [[ \"$ARTIST_COUNT\" -eq 0 ]]; then\n397\t error \"API returned 0 artists — aborting to prevent mass deletion\"\n398\t notify \"Lidarr cleanup aborted on $(hostname) — 0 artists returned\" \\\n399\t \"Lidarr Cleanup\" \"warning\"\n400\t exit 1\n401\tfi\n402\t\n403\tinfo \"Found $ARTIST_COUNT artists — fetching track files...\"\n404\t\n405\tTRACKED_FILE=\"$TMP_DIR/tracked_paths.txt\"\n406\t> \"$TRACKED_FILE\"\n407\t\n408\twhile IFS= read -r artist_id; do\n409\t [[ -z \"$artist_id\" ]] && continue\n410\t ARTIST_TRACKS=$(lidarr_api \"trackFile?artistId=${artist_id}\" 2>/dev/null)\n411\t if [[ -n \"$ARTIST_TRACKS\" ]]; then\n412\t while IFS= read -r api_path; do\n413\t [[ -z \"$api_path\" ]] && continue\n414\t translate_path \"$api_path\" >> \"$TRACKED_FILE\"\n415\t done < <(echo \"$ARTIST_TRACKS\" | jq -r '.[].path' 2>/dev/null)\n416\t fi\n417\tdone <<< \"$ARTIST_IDS\"\n418\t\n419\tsort -u \"$TRACKED_FILE\" -o \"$TRACKED_FILE\"\n420\t\n421\t# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file\n422\t# Eliminates the main performance bottleneck for large libraries\n423\tdeclare -A TRACKED_MAP\n424\twhile IFS= read -r _tracked_path; do\n425\t [[ -n \"$_tracked_path\" ]] && TRACKED_MAP[\"$_tracked_path\"]=1\n426\tdone < \"$TRACKED_FILE\"\n427\tunset _tracked_path\n428\tinfo \"Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths\"\n429\tTRACKED_COUNT=$(wc -l < \"$TRACKED_FILE\")\n430\t\n431\t# Safety Layer 5 — tracked count > 0\n432\tif [[ \"$TRACKED_COUNT\" -eq 0 ]]; then\n433\t error \"API returned 0 tracked files — aborting to prevent mass deletion\"\n434\t notify \"Lidarr cleanup aborted on $(hostname) — 0 tracked files returned\" \\\n435\t \"Lidarr Cleanup\" \"warning\"\n436\t exit 1\n437\tfi\n438\t\n439\tinfo \"$ARTIST_COUNT artists | $TRACKED_COUNT tracked files\"\n440\t\n441\t# Safety Layer 6 — percentage drop vs last known count\n442\tif [[ -f \"$LIDARR_TRACKED_COUNT_FILE\" ]]; then\n443\t LAST_COUNT=$(cat \"$LIDARR_TRACKED_COUNT_FILE\" 2>/dev/null || echo 0)\n444\t if [[ \"$LAST_COUNT\" -gt 0 ]]; then\n445\t PCT=$(awk \"BEGIN {printf \\\"%d\\\", ($TRACKED_COUNT / $LAST_COUNT) * 100}\")\n446\t if [[ \"$PCT\" -lt \"$LIDARR_MIN_TRACKED_PCT\" ]]; then\n447\t error \"Tracked count dropped to ${PCT}% of last run ($TRACKED_COUNT vs $LAST_COUNT)\"\n448\t error \"Suggests API issue — aborting to prevent mass deletion\"\n449\t error \"If expected (large library removal) delete: $LIDARR_TRACKED_COUNT_FILE\"\n450\t notify \"Lidarr cleanup aborted on $(hostname) — tracked count dropped to ${PCT}%\" \\\n451\t \"Lidarr Cleanup\" \"warning\"\n452\t exit 1\n453\t fi\n454\t info \"Tracked count: ${PCT}% of last run ($TRACKED_COUNT vs $LAST_COUNT) ✅\"\n455\t fi\n456\telse\n457\t info \"No previous count on record — first run, saving baseline\"\n458\tfi\n459\t\n460\techo \"$TRACKED_COUNT\" > \"$LIDARR_TRACKED_COUNT_FILE\"\n461\t\n462\t# ==============================================================================================\n463\t# ━━━ Scan Music Root ━━━\n464\t# ==============================================================================================\n465\techo \"\"\n466\techo \"━━━ $ICON_CLEAN Scanning Music Root ━━━\"\n467\tinfo \"Root: $LIDARR_MUSIC_ROOT | Orphan age: ${LIDARR_ORPHAN_AGE} days\"\n468\t\n469\tSTART=$(date +%s)\n470\tORPHAN_COUNT=0\n471\tJUNK_COUNT=0\n472\tRECENT_COUNT=0\n473\tPROTECTED_COUNT=0\n474\tORPHAN_BYTES=0\n475\tJUNK_BYTES=0\n476\t\n477\tAGE_SECONDS=$(( LIDARR_ORPHAN_AGE * 86400 ))\n478\tNOW=$(date +%s)\n479\tMAX_DELETE_BYTES=$(awk \"BEGIN {printf \\\"%d\\\", $LIDARR_MAX_DELETE_GB * 1073741824}\")\n480\t\n481\twhile IFS= read -r filepath; do\n482\t [[ -z \"$filepath\" ]] && continue\n483\t\n484\t # Tracked — leave alone\n485\t if [[ -n \"${TRACKED_MAP[$filepath]:-}\" ]]; then\n486\t log \"TRACKED: $filepath\"\n487\t continue\n488\t fi\n489\t\n490\t # Protected — never delete\n491\t if is_protected_file \"$filepath\"; then\n492\t log \"$ICON_PROTECTED PROTECTED: $filepath\"\n493\t (( PROTECTED_COUNT++ ))\n494\t continue\n495\t fi\n496\t\n497\t FILE_SIZE=$(stat -c%s \"$filepath\" 2>/dev/null || echo 0)\n498\t\n499\t if is_music_file \"$filepath\"; then\n500\t FILE_MTIME=$(stat -c %Y \"$filepath\" 2>/dev/null || echo 0)\n501\t FILE_AGE=$(( NOW - FILE_MTIME ))\n502\t\n503\t if [[ \"$FILE_AGE\" -lt \"$AGE_SECONDS\" ]] && [[ \"$SKIP_STRIKES\" != true ]]; then\n504\t log \"RECENT (skipping): $filepath\"\n505\t (( RECENT_COUNT++ ))\n506\t continue\n507\t fi\n508\t\n509\t warn \"$ICON_TRASH ORPHAN: $filepath\"\n510\t (( ORPHAN_COUNT++ ))\n511\t ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))\n512\t else\n513\t log \"JUNK: $filepath\"\n514\t (( JUNK_COUNT++ ))\n515\t JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))\n516\t fi\n517\t\n518\tdone < <(find \"$LIDARR_MUSIC_ROOT\" -type f 2>/dev/null)\n519\t\n520\tTOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))\n521\tTOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))\n522\t\n523\t# ==============================================================================================\n524\t# ━━━ Safety Layer 7 — Deletion Size Threshold ━━━\n525\t# ==============================================================================================\n526\tif [[ \"$TOTAL_DELETE_BYTES\" -gt \"$MAX_DELETE_BYTES\" ]]; then\n527\t TOTAL_HUMAN=$(awk \"BEGIN {printf \\\"%.1fGB\\\", $TOTAL_DELETE_BYTES / 1073741824}\")\n528\t if [[ \"$I_KNOW\" != true ]]; then\n529\t echo \"\"\n530\t error \"Deletion would exceed ${LIDARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted\"\n531\t error \"Review ORPHAN lines above carefully before proceeding\"\n532\t error \"Rerun with: --i-know-what-im-doing\"\n533\t error \"To also bypass age check: add --skip-strike-list\"\n534\t notify \"Lidarr cleanup halted on $(hostname) — ${TOTAL_HUMAN} requires --i-know-what-im-doing\" \\\n535\t \"Lidarr Cleanup\" \"warning\"\n536\t exit 1\n537\t else\n538\t warn \"OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing\"\n539\t fi\n540\tfi\n541\t\n542\t# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────\n543\t# All safety layers passed — delete orphans and junk\n544\tif [[ \"$DRY_RUN\" == false ]]; then\n545\t while IFS= read -r filepath; do\n546\t [[ -z \"$filepath\" ]] && continue\n547\t [[ -n \"${TRACKED_MAP[$filepath]:-}\" ]] && continue\n548\t is_protected_file \"$filepath\" && continue\n549\t\n550\t FILE_MTIME=$(stat -c %Y \"$filepath\" 2>/dev/null || echo 0)\n551\t FILE_AGE=$(( NOW - FILE_MTIME ))\n552\t\n553\t if is_music_file \"$filepath\"; then\n554\t [[ \"$FILE_AGE\" -lt \"$AGE_SECONDS\" ]] && \\\n555\t [[ \"$SKIP_STRIKES\" != true ]] && continue\n556\t fi\n557\t\n558\t rm -f \"$filepath\" 2>/dev/null || error \"Failed to delete: $filepath\"\n559\t\n560\t done < <(find \"$LIDARR_MUSIC_ROOT\" -type f 2>/dev/null)\n561\t\n562\t info \"Cleaning up empty folders...\"\n563\t find \"$LIDARR_MUSIC_ROOT\" -mindepth 1 -type d -empty -delete 2>/dev/null\n564\t info \"Empty folders removed\"\n565\tfi\n566\t\n567\tEND=$(date +%s)\n568\t\n569\tformat_bytes() {\n570\t local bytes=$1\n571\t if (( bytes > 1073741824 )); then\n572\t awk \"BEGIN {printf \\\"%.1fGB\\\", $bytes / 1073741824}\"\n573\t elif (( bytes > 1048576 )); then\n574\t awk \"BEGIN {printf \\\"%.1fMB\\\", $bytes / 1048576}\"\n575\t else\n576\t echo \"${bytes}B\"\n577\t fi\n578\t}\n579\t\n580\tORPHAN_HUMAN=$(format_bytes \"$ORPHAN_BYTES\")\n581\tJUNK_HUMAN=$(format_bytes \"$JUNK_BYTES\")\n582\t\n583\t# ==============================================================================================\n584\t# ━━━ Summary ━━━\n585\t# ==============================================================================================\n586\techo \"\"\n587\techo \"━━━━━ $ICON_SUMMARY LIDARR CLEANUP SUMMARY ━━━━━\"\n588\techo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n589\techo \"$ICON_SYNC Tracked: $TRACKED_COUNT files ($ARTIST_COUNT artists)\"\n590\techo \"$ICON_SHIELD Protected: $PROTECTED_COUNT files (cover art, metadata)\"\n591\techo \"$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)\"\n592\techo \"$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)\"\n593\techo \"$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${LIDARR_ORPHAN_AGE} days)\"\n594\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n595\techo \"\"\n596\t\n597\tif [[ \"$DRY_RUN\" == true ]]; then\n598\t warn \"DRY RUN — no files deleted\"\n599\telif [[ \"$TOTAL_REMOVED\" -eq 0 ]]; then\n600\t echo \"$ICON_DONE Clean — nothing to remove\"\n601\telse\n602\t warn \"$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)\"\n603\t notify \"Lidarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)\" \"Lidarr Cleanup\" \"warning\"\n604\t # Notify Emby to clean missing files — removes ghost entries immediately\n605\t notify_emby_scan\n606\tfi\n607\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n608\t\n609\t# Write stats for sunday_morning_coffee_report.sh\n610\tif [[ \"$DRY_RUN\" == false ]] && [[ -n \"${ARR_CLEANUP_STATS:-}\" ]]; then\n611\t echo \"$(date '+%Y-%m-%d')|lidarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}\" \\\n612\t >> \"$ARR_CLEANUP_STATS\" 2>/dev/null || true\n613\tfi\n614\t\n615\texit 0"}]},"uuid":"0179f7a7-1c00-4989-a344-df56fc6fd6ea","timestamp":"2026-06-14T05:32:14.252Z","sourceToolAssistantUUID":"8a792f86-8ed0-4b4b-b582-16125042a476","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"0179f7a7-1c00-4989-a344-df56fc6fd6ea","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015gHi3ASktYtNiXFaGDrCJf","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/lidarr_missing_art.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"f5014234-b412-4d7f-bffe-6b19f38a2907","timestamp":"2026-06-14T05:32:14.338Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"f5014234-b412-4d7f-bffe-6b19f38a2907","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015gHi3ASktYtNiXFaGDrCJf","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ================================= Lidarr Missing Art =========================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Fetch missing album and artist artwork for the Lidarr music library. Downloads\n9\t# only what is absent — never overwrites existing files. Idempotent re-runs are\n10\t# safe.\n11\t#\n12\t# ==============================================================================================\n13\t# OPERATIONAL MODEL\n14\t# ==============================================================================================\n15\t#\n16\t# Artwork targets per album folder: cover.jpg cdart.png back.jpg\n17\t# Artwork targets per artist folder: folder.jpg fanart.jpg logo.png banner.jpg\n18\t#\n19\t# Sources (tried in order, first success wins):\n20\t# Album covers: fanart.tv → iTunes fallback\n21\t# Artist art: fanart.tv → Deezer fallback → Last.fm fallback\n22\t#\n23\t# Reads from Lidarr API only — no writes back to Lidarr. Never modifies audio\n24\t# tags or renames media files. Only writes missing artwork files to existing\n25\t# album/artist directories.\n26\t#\n27\t# ==============================================================================================\n28\t# OPERATIONAL SAFEGUARDS\n29\t# ==============================================================================================\n30\t#\n31\t# acquire_lock — prevents concurrent runs during large library scans\n32\t# curl + jq check — fail fast if tools missing\n33\t# API reachability — verified before processing begins\n34\t# detect_hosts() — exits cleanly if LIDARR_URL empty (HOST2, no Lidarr)\n35\t# Skip existing — never overwrites, idempotent re-runs are safe\n36\t# Min file size check — rejects corrupt/placeholder downloads (LIDARR_ART_MIN_SIZE)\n37\t# Parallel job cap — LIDARR_ART_MAX_PARALLEL — avoids hammering external APIs\n38\t# Download retries — LIDARR_ART_RETRIES attempts per image before giving up\n39\t# Rate limiting — LIDARR_ART_SLEEP_BETWEEN between fanart.tv API calls\n40\t#\n41\t# ==============================================================================================\n42\t# CONFIGURATION\n43\t# ==============================================================================================\n44\t#\n45\t# host*.conf\n46\t#\n47\t# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY\n48\t# Aliased by detect_hosts() — script uses LIDARR_URL / LIDARR_API_KEY\n49\t#\n50\t# master.conf\n51\t#\n52\t# FANART_API_KEY — fanart.tv API key\n53\t# LASTFM_API_KEY — last.fm API key\n54\t# LIDARR_ART_MIN_SIZE — minimum valid download size in bytes\n55\t# LIDARR_ART_MAX_PARALLEL — concurrent background download jobs\n56\t# LIDARR_ART_RETRIES — download retry attempts per image\n57\t# LIDARR_ART_SLEEP_BETWEEN — seconds between fanart.tv API calls\n58\t#\n59\t# ==============================================================================================\n60\t# RUNTIME MODES\n61\t# ==============================================================================================\n62\t#\n63\t# lidarr_missing_art.sh — fetch all missing artwork\n64\t# lidarr_missing_art.sh --dry-run — preview without downloading\n65\t# lidarr_missing_art.sh --log — verbose per-item output\n66\t# lidarr_missing_art.sh --status — show config and exit\n67\t#\n68\t# ==============================================================================================\n69\t\n70\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n71\t\n72\tsource \"$SCRIPT_DIR/../load_config.sh\"\n73\t\n74\tparse_args \"$@\"\n75\t\n76\t# ==============================================================================================\n77\t# ━━━ Setup ━━━\n78\t# ==============================================================================================\n79\tif [[ \"$EUID\" -ne 0 ]]; then\n80\t error \"Must be run as root\"\n81\t exit 1\n82\tfi\n83\t\n84\tif ! command -v curl >/dev/null 2>&1; then\n85\t error \"curl not found — required for API calls\"\n86\t exit 1\n87\tfi\n88\tif ! command -v jq >/dev/null 2>&1; then\n89\t error \"jq not found — required for JSON parsing\"\n90\t exit 1\n91\tfi\n92\t\n93\tacquire_lock\n94\t\n95\tdetect_hosts\n96\t\n97\t# Build path map for translate_path() — container path → host path\n98\tdeclare -A ARR_PATH_MAP\n99\tfor _key in \"${!HOST1_LIDARR_PATH_MAP[@]}\"; do\n100\t ARR_PATH_MAP[\"$_key\"]=\"${HOST1_LIDARR_PATH_MAP[$_key]}\"\n101\tdone\n102\tunset _key\n103\t\n104\t# HOST guard — Lidarr runs on HOST1 only\n105\tif [[ -z \"$LIDARR_URL\" ]]; then\n106\t echo \"Lidarr not configured for $MY_ID — skipping\"\n107\t exit 0\n108\tfi\n109\t\n110\tinfo \"$MY_ID ($LOCAL_SERVER_NAME) — tools OK\"\n111\tlog \"$ICON_GEAR Config: url=${LIDARR_URL}\"\n112\t\n113\t# ==============================================================================================\n114\t# ━━━ Status ━━━\n115\t# ==============================================================================================\n116\tif [[ \"$SHOW_STATUS\" == true ]]; then\n117\t echo \"\"\n118\t echo \"━━━━━ $ICON_SUMMARY STATUS ━━━━━\"\n119\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n120\t echo \"$ICON_GEAR Lidarr URL: $LIDARR_URL\"\n121\t echo \"$ICON_NET Fanart key: $([[ -n \"${FANART_API_KEY:-}\" ]] && echo \"set\" || echo \"not set\")\"\n122\t echo \"$ICON_NET LastFM key: $([[ -n \"${LASTFM_API_KEY:-}\" ]] && echo \"set\" || echo \"not set\")\"\n123\t echo \"$ICON_GEAR Min size: ${LIDARR_ART_MIN_SIZE} bytes\"\n124\t echo \"$ICON_GEAR Parallel: $LIDARR_ART_MAX_PARALLEL jobs\"\n125\t echo \"$ICON_RETRY Retries: $LIDARR_ART_RETRIES\"\n126\t echo \"$ICON_TIME API sleep: ${LIDARR_ART_SLEEP_BETWEEN}s\"\n127\t echo \"$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n \"${MY_DISCORD_WEBHOOK:-}\" ]] && echo enabled || echo disabled)\"\n128\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n129\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n130\t exit 0\n131\tfi\n132\t\n133\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no files will be written\"\n134\t\n135\t# ── Temp dir for subshell fetch/fail counters ─────────────────────────────────────────────────\n136\tLIDARR_TMP=$(mktemp -d)\n137\ttrap '_release_all_locks; rm -rf \"$LIDARR_TMP\"' EXIT\n138\ttouch \"$LIDARR_TMP/album_fetches\" \"$LIDARR_TMP/album_fails\" \\\n139\t \"$LIDARR_TMP/artist_fetches\" \"$LIDARR_TMP/artist_fails\"\n140\t\n141\t# ==============================================================================================\n142\t# ── FUNCTIONS ─────────────────────────────────────────────────────────────────────────────────\n143\t# ==============================================================================================\n144\t\n145\tcurl_json() {\n146\t curl -s --connect-timeout 5 --max-time 20 \"$1\"\n147\t}\n148\t\n149\tjob_count() {\n150\t jobs -rp | wc -l\n151\t}\n152\t\n153\twait_for_slot() {\n154\t while (( $(job_count) >= LIDARR_ART_MAX_PARALLEL )); do\n155\t sleep 0.2\n156\t done\n157\t}\n158\t\n159\t# Downloads URL to dest only if dest doesn't exist and downloaded size >= MIN_SIZE.\n160\t# Returns 0 on success or skip (file already exists), 1 on failure.\n161\tdownload_if_valid() {\n162\t local url=\"$1\"\n163\t local dest=\"$2\"\n164\t\n165\t [[ -z \"$url\" || \"$url\" == \"null\" ]] && return 1\n166\t [[ -f \"$dest\" ]] && return 0\n167\t\n168\t if [[ \"$DRY_RUN\" == true ]]; then\n169\t log \"DRY RUN — would fetch: $(basename \"$dest\")\"\n170\t return 0\n171\t fi\n172\t\n173\t local tmp=\"${dest}.tmp\"\n174\t local i\n175\t for (( i=0; i<=LIDARR_ART_RETRIES; i++ )); do\n176\t curl -s --connect-timeout 5 --max-time 20 -L -o \"$tmp\" \"$url\"\n177\t local size\n178\t size=$(stat -c%s \"$tmp\" 2>/dev/null || echo 0)\n179\t if (( size > LIDARR_ART_MIN_SIZE )); then\n180\t mv \"$tmp\" \"$dest\"\n181\t log \" Fetched: $(basename \"$dest\")\"\n182\t return 0\n183\t fi\n184\t rm -f \"$tmp\"\n185\t sleep 1\n186\t done\n187\t\n188\t warn \"Failed to fetch: $(basename \"$dest\")\"\n189\t return 1\n190\t}\n191\t\n192\tdeezer_artist_image() {\n193\t local artist=\"$1\"\n194\t local query\n195\t query=$(printf \"%s\" \"$artist\" | sed 's/ /+/g')\n196\t curl_json \"https://api.deezer.com/search/artist?q=$query\" |\n197\t jq -r '.data[0].picture_xl // empty' 2>/dev/null\n198\t}\n199\t\n200\tlastfm_artist_image() {\n201\t local artist=\"$1\"\n202\t local encoded\n203\t encoded=$(printf \"%s\" \"$artist\" | sed 's/ /%20/g')\n204\t curl_json \"https://ws.audioscrobbler.com/2.0/?method=artist.getinfo&artist=$encoded&api_key=$LASTFM_API_KEY&format=json\" |\n205\t jq -r '.artist.image[-1][\"#text\"] // empty' 2>/dev/null\n206\t}\n207\t\n208\t# ==============================================================================================\n209\t# ━━━ Verify Lidarr reachable ━━━\n210\t# ==============================================================================================\n211\techo \"\"\n212\techo \"━━━ $ICON_NET Lidarr API ━━━\"\n213\t\n214\tif ! curl_json \"$LIDARR_URL/api/v1/system/status?apikey=$LIDARR_API_KEY\" | jq -e '.version' >/dev/null 2>&1; then\n215\t error \"Lidarr API unreachable at $LIDARR_URL — aborting\"\n216\t notify \"lidarr_missing_art failed — Lidarr API unreachable on $(hostname)\" \"Lidarr Missing Art\" \"warning\"\n217\t exit 1\n218\tfi\n219\tinfo \"Lidarr reachable — $LIDARR_URL\"\n220\t\n221\tSTART=$(date +%s)\n222\t\n223\tALBUMS_CHECKED=0\n224\tALBUMS_COMPLETE=0\n225\t\n226\tARTISTS_CHECKED=0\n227\tARTISTS_COMPLETE=0\n228\t\n229\t# ==============================================================================================\n230\t# ━━━ Build Album Directory Map ━━━\n231\t# ==============================================================================================\n232\t# Lidarr's album API never populates .path — derive album dirs from track file paths instead.\n233\techo \"\"\n234\techo \"━━━ $ICON_SYNC Building Album Directory Map ━━━\"\n235\t\n236\tdeclare -A ALBUM_DIR_MAP\n237\t_artist_list=$(curl_json \"$LIDARR_URL/api/v1/artist?apikey=$LIDARR_API_KEY\")\n238\t_map_artist_count=$(echo \"$_artist_list\" | jq '. | length')\n239\tinfo \"Fetching track files for $_map_artist_count artists...\"\n240\t\n241\twhile IFS= read -r _artist_id; do\n242\t [[ -z \"$_artist_id\" ]] && continue\n243\t while IFS=$'\\t' read -r _album_id _track_path; do\n244\t [[ -z \"$_album_id\" || -z \"$_track_path\" || \"$_track_path\" == \"null\" ]] && continue\n245\t ALBUM_DIR_MAP[\"$_album_id\"]=$(dirname \"$_track_path\")\n246\t done < <(curl_json \"$LIDARR_URL/api/v1/trackFile?artistId=${_artist_id}&apikey=$LIDARR_API_KEY\" | \\\n247\t jq -r '.[] | [(.albumId | tostring), .path] | @tsv' 2>/dev/null)\n248\tdone < <(echo \"$_artist_list\" | jq -r '.[].id')\n249\tunset _artist_list _map_artist_count _artist_id _album_id _track_path\n250\t\n251\tinfo \"Mapped ${#ALBUM_DIR_MAP[@]} albums with local tracks\"\n252\t\n253\t# ==============================================================================================\n254\t# ━━━ Albums ━━━\n255\t# ==============================================================================================\n256\techo \"\"\n257\techo \"━━━ $ICON_EMBY Albums ━━━\"\n258\t\n259\talbums=$(curl_json \"$LIDARR_URL/api/v1/album?apikey=$LIDARR_API_KEY\")\n260\t\n261\tif [[ -z \"$albums\" || \"$albums\" == \"null\" ]]; then\n262\t error \"Lidarr album API returned empty — aborting\"\n263\t notify \"lidarr_missing_art failed — album API empty on $(hostname)\" \"Lidarr Missing Art\" \"warning\"\n264\t exit 1\n265\tfi\n266\t\n267\ttotal_albums=$(echo \"$albums\" | jq '. | length')\n268\tinfo \"Processing $total_albums albums...\"\n269\t\n270\twhile IFS=$'\\t' read -r mbid artist_name album_name album_id; do\n271\t raw_dir=\"${ALBUM_DIR_MAP[$album_id]:-}\"\n272\t [[ -z \"$raw_dir\" ]] && continue # not downloaded, skip\n273\t local_path=$(translate_path \"$raw_dir\")\n274\t (( ALBUMS_CHECKED++ ))\n275\t\n276\t [[ ! -d \"$local_path\" ]] && continue\n277\t\n278\t log \"[$ALBUMS_CHECKED/$total_albums] $artist_name — $album_name\"\n279\t\n280\t if [[ -f \"$local_path/cover.jpg\" &&\n281\t -f \"$local_path/cdart.png\" &&\n282\t -f \"$local_path/back.jpg\" ]]; then\n283\t (( ALBUMS_COMPLETE++ ))\n284\t log \" complete — skipping\"\n285\t continue\n286\t fi\n287\t\n288\t wait_for_slot\n289\t\n290\t (\n291\t _fetches=0 _fails=0\n292\t\n293\t JSON=\"\"\n294\t if [[ -n \"$mbid\" && \"$mbid\" != \"null\" ]]; then\n295\t JSON=$(curl_json \"http://webservice.fanart.tv/v3/music/albums/$mbid?api_key=$FANART_API_KEY\")\n296\t sleep \"$LIDARR_ART_SLEEP_BETWEEN\"\n297\t fi\n298\t\n299\t if [[ ! -f \"$local_path/cover.jpg\" ]]; then\n300\t IMG=$(echo \"$JSON\" | jq -r '.[].albumcover[0].url // empty' 2>/dev/null)\n301\t if download_if_valid \"$IMG\" \"$local_path/cover.jpg\"; then\n302\t (( _fetches++ ))\n303\t else\n304\t query=$(printf \"%s %s\" \"$artist_name\" \"$album_name\" | sed 's/ /+/g')\n305\t itunes=$(curl_json \"https://itunes.apple.com/search?term=$query&entity=album&limit=1\" |\n306\t jq -r '.results[0].artworkUrl100 // empty' 2>/dev/null | sed 's/100x100/600x600/')\n307\t if download_if_valid \"$itunes\" \"$local_path/cover.jpg\"; then\n308\t (( _fetches++ ))\n309\t else\n310\t (( _fails++ ))\n311\t fi\n312\t fi\n313\t fi\n314\t\n315\t if [[ ! -f \"$local_path/cdart.png\" ]]; then\n316\t IMG=$(echo \"$JSON\" | jq -r '.[].cdart[0].url // empty' 2>/dev/null)\n317\t if download_if_valid \"$IMG\" \"$local_path/cdart.png\"; then (( _fetches++ )); else (( _fails++ )); fi\n318\t fi\n319\t\n320\t if [[ ! -f \"$local_path/back.jpg\" ]]; then\n321\t IMG=$(echo \"$JSON\" | jq -r '.[].albumback[0].url // empty' 2>/dev/null)\n322\t if download_if_valid \"$IMG\" \"$local_path/back.jpg\"; then (( _fetches++ )); else (( _fails++ )); fi\n323\t fi\n324\t\n325\t (( _fetches > 0 )) && printf '1\\n' >> \"$LIDARR_TMP/album_fetches\"\n326\t (( _fails > 0 )) && printf '1\\n' >> \"$LIDARR_TMP/album_fails\"\n327\t ) &\n328\t\n329\tdone < <(echo \"$albums\" | jq -r '.[] | [(.foreignAlbumId // \"\"), (.artist.artistName // \"\"), (.title // \"\"), (.id | tostring)] | @tsv')\n330\t\n331\twait\n332\t\n333\tALBUM_FETCHED=$(wc -l < \"$LIDARR_TMP/album_fetches\" 2>/dev/null || echo 0)\n334\tALBUM_FAILED=$(wc -l < \"$LIDARR_TMP/album_fails\" 2>/dev/null || echo 0)\n335\tALBUM_MISSING=$(( ALBUMS_CHECKED - ALBUMS_COMPLETE ))\n336\tinfo \"Checked: $ALBUMS_CHECKED | Complete: $ALBUMS_COMPLETE | Needed art: $ALBUM_MISSING | Fetched: $ALBUM_FETCHED | Failed: $ALBUM_FAILED\"\n337\t\n338\t# ==============================================================================================\n339\t# ━━━ Artists ━━━\n340\t# ==============================================================================================\n341\techo \"\"\n342\techo \"━━━ $ICON_EMBY Artists ━━━\"\n343\t\n344\tartists=$(curl_json \"$LIDARR_URL/api/v1/artist?apikey=$LIDARR_API_KEY\")\n345\t\n346\tif [[ -z \"$artists\" || \"$artists\" == \"null\" ]]; then\n347\t error \"Lidarr artist API returned empty — aborting\"\n348\t notify \"lidarr_missing_art failed — artist API empty on $(hostname)\" \"Lidarr Missing Art\" \"warning\"\n349\t exit 1\n350\tfi\n351\t\n352\ttotal_artists=$(echo \"$artists\" | jq '. | length')\n353\tinfo \"Processing $total_artists artists...\"\n354\t\n355\twhile IFS=$'\\t' read -r local_path mbid name; do\n356\t local_path=$(translate_path \"$local_path\")\n357\t (( ARTISTS_CHECKED++ ))\n358\t\n359\t [[ ! -d \"$local_path\" ]] && continue\n360\t [[ -z \"$mbid\" || \"$mbid\" == \"null\" ]] && continue\n361\t\n362\t log \"[$ARTISTS_CHECKED/$total_artists] $name\"\n363\t\n364\t if [[ -f \"$local_path/folder.jpg\" &&\n365\t -f \"$local_path/fanart.jpg\" &&\n366\t -f \"$local_path/logo.png\" &&\n367\t -f \"$local_path/banner.jpg\" ]]; then\n368\t (( ARTISTS_COMPLETE++ ))\n369\t log \" complete — skipping\"\n370\t continue\n371\t fi\n372\t\n373\t wait_for_slot\n374\t\n375\t (\n376\t _fetches=0 _fails=0\n377\t\n378\t JSON=$(curl_json \"http://webservice.fanart.tv/v3/music/$mbid?api_key=$FANART_API_KEY\")\n379\t sleep \"$LIDARR_ART_SLEEP_BETWEEN\"\n380\t\n381\t if [[ ! -f \"$local_path/folder.jpg\" ]]; then\n382\t IMG=$(echo \"$JSON\" | jq -r '.artistthumb[0].url // empty' 2>/dev/null)\n383\t if download_if_valid \"$IMG\" \"$local_path/folder.jpg\"; then\n384\t (( _fetches++ ))\n385\t else\n386\t IMG=$(deezer_artist_image \"$name\")\n387\t if download_if_valid \"$IMG\" \"$local_path/folder.jpg\"; then\n388\t (( _fetches++ ))\n389\t else\n390\t IMG=$(lastfm_artist_image \"$name\")\n391\t if download_if_valid \"$IMG\" \"$local_path/folder.jpg\"; then\n392\t (( _fetches++ ))\n393\t else\n394\t (( _fails++ ))\n395\t fi\n396\t fi\n397\t fi\n398\t fi\n399\t\n400\t if [[ ! -f \"$local_path/fanart.jpg\" ]]; then\n401\t IMG=$(echo \"$JSON\" | jq -r '.artistbackground[0].url // empty' 2>/dev/null)\n402\t if download_if_valid \"$IMG\" \"$local_path/fanart.jpg\"; then\n403\t (( _fetches++ ))\n404\t else\n405\t IMG=$(deezer_artist_image \"$name\")\n406\t if download_if_valid \"$IMG\" \"$local_path/fanart.jpg\"; then (( _fetches++ )); else (( _fails++ )); fi\n407\t fi\n408\t fi\n409\t\n410\t if [[ ! -f \"$local_path/logo.png\" ]]; then\n411\t IMG=$(echo \"$JSON\" | jq -r '.hdmusiclogo[0].url // empty' 2>/dev/null)\n412\t if download_if_valid \"$IMG\" \"$local_path/logo.png\"; then (( _fetches++ )); else (( _fails++ )); fi\n413\t fi\n414\t\n415\t if [[ ! -f \"$local_path/banner.jpg\" ]]; then\n416\t IMG=$(echo \"$JSON\" | jq -r '.musicbanner[0].url // empty' 2>/dev/null)\n417\t if download_if_valid \"$IMG\" \"$local_path/banner.jpg\"; then (( _fetches++ )); else (( _fails++ )); fi\n418\t fi\n419\t\n420\t (( _fetches > 0 )) && printf '1\\n' >> \"$LIDARR_TMP/artist_fetches\"\n421\t (( _fails > 0 )) && printf '1\\n' >> \"$LIDARR_TMP/artist_fails\"\n422\t ) &\n423\t\n424\tdone < <(echo \"$artists\" | jq -r '.[] | [.path, .foreignArtistId, .artistName] | @tsv')\n425\t\n426\twait\n427\t\n428\tARTIST_FETCHED=$(wc -l < \"$LIDARR_TMP/artist_fetches\" 2>/dev/null || echo 0)\n429\tARTIST_FAILED=$(wc -l < \"$LIDARR_TMP/artist_fails\" 2>/dev/null || echo 0)\n430\tARTIST_MISSING=$(( ARTISTS_CHECKED - ARTISTS_COMPLETE ))\n431\tinfo \"Checked: $ARTISTS_CHECKED | Complete: $ARTISTS_COMPLETE | Needed art: $ARTIST_MISSING | Fetched: $ARTIST_FETCHED | Failed: $ARTIST_FAILED\"\n432\t\n433\tEND=$(date +%s)\n434\t\n435\t# ==============================================================================================\n436\t# ━━━ Summary ━━━\n437\t# ==============================================================================================\n438\techo \"\"\n439\techo \"━━━━━ $ICON_SUMMARY LIDARR MISSING ART SUMMARY ━━━━━\"\n440\techo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n441\techo \"$ICON_TIME Duration: $(format_duration $((END - START)))\"\n442\techo \"$ICON_EMBY Albums: $ALBUMS_CHECKED checked | $ALBUMS_COMPLETE complete | $ALBUM_FETCHED fetched | $ALBUM_FAILED failed\"\n443\techo \"$ICON_EMBY Artists: $ARTISTS_CHECKED checked | $ARTISTS_COMPLETE complete | $ARTIST_FETCHED fetched | $ARTIST_FAILED failed\"\n444\t\n445\tif [[ \"$DRY_RUN\" == true ]]; then\n446\t echo \"$ICON_WARN Status: DRY RUN — no files written\"\n447\telse\n448\t echo \"$ICON_DONE Status: $ICON_SUCCESS ALL DONE\"\n449\t notify \"Lidarr art fetch complete on $(hostname) — ${ALBUMS_CHECKED} albums, ${ARTISTS_CHECKED} artists processed\" \"Lidarr Missing Art\" \"normal\"\n450\tfi\n451\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n452\t\n453\texit 0\n454\t"}]},"uuid":"c3568769-d0dc-40c1-8678-f5e90a32d44f","timestamp":"2026-06-14T05:32:14.367Z","sourceToolAssistantUUID":"f5014234-b412-4d7f-bffe-6b19f38a2907","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"c3568769-d0dc-40c1-8678-f5e90a32d44f","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Fvs5nwKgnrJUFjRpjyp8Um","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/media_cleaner.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"db9d19e2-3b26-4c96-a220-b5d56ef3f66f","timestamp":"2026-06-14T05:32:14.511Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"db9d19e2-3b26-4c96-a220-b5d56ef3f66f","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Fvs5nwKgnrJUFjRpjyp8Um","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ================================= Media Cleaner ==============================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Remove scene debris, tool artifacts, and unsafe files from media shares\n9\t# before the orphan scan — so arr cleanup only encounters actual media files.\n10\t# Runs second in the daily window, after permissions and before arr cleanup.\n11\t# Two profiles — anime and media — each with their own folder list and patterns.\n12\t#\n13\t# ==============================================================================================\n14\t# OPERATIONAL MODEL\n15\t# ==============================================================================================\n16\t#\n17\t# Profiles:\n18\t# anime — cleans ANIME_CLEAN_FOLDERS using ANIME_FILE_PATTERNS\n19\t# media — cleans MEDIA_CLEAN_FOLDERS using MEDIA_FILE_PATTERNS (adds *.iso *.lrc)\n20\t#\n21\t# Scene debris — left by scene releases and download clients:\n22\t# *.sfv *.md5 *.sha1 — checksums — useless post-download\n23\t# *.nzb *.torrent — download files left by clients\n24\t# *.url *.lnk *.info *.diz — scene metadata\n25\t# *.sample* *.proof* — scene samples — never needed in library\n26\t# *sync-conflict* — Syncthing conflict copies\n27\t# *.rar *.zip *.7z *.ace — archives — source not needed after extraction\n28\t# *.r00-*.r09 *.srr — multi-part rar segments and repair files\n29\t# *.001 *.002 *.003 — split archive parts\n30\t# *.gz *.tar *.bz2 — linux archives\n31\t#\n32\t# Tool artifacts — incomplete or stale files from download clients:\n33\t# *.!ut *.!qB — uTorrent / qBittorrent incomplete markers\n34\t# *.crdownload *.opdownload — Chrome / Opera incomplete downloads\n35\t# *.part — partial download files\n36\t#\n37\t# Unsafe files — executables that should never appear in a media folder:\n38\t# *.exe *.scr *.com — Windows executables\n39\t# *.bat *.cmd *.vbs *.ps1 — Windows scripts\n40\t# *.msi *.dll *.sys — Windows system files\n41\t# *.sh — shell scripts in media folders = suspicious\n42\t#\n43\t# Media profile also removes: *.iso *.lrc\n44\t#\n45\t# ==============================================================================================\n46\t# OPERATIONAL SAFEGUARDS\n47\t# ==============================================================================================\n48\t#\n49\t# acquire_lock \"wait\" — wait if previous run still active\n50\t# detect_hosts() — correct folder lists per host via MY_ID aliases\n51\t# Empty array guards — warns and exits cleanly if no folders or patterns configured\n52\t# Folder existence — skips missing folders with warning, continues others\n53\t# platform_require_cmd — notify script validated before use\n54\t#\n55\t# ==============================================================================================\n56\t# CONFIGURATION\n57\t# ==============================================================================================\n58\t#\n59\t# host*.conf\n60\t#\n61\t# HOST*_ANIME_CLEAN_FOLDERS — folders cleaned by the anime profile on this host\n62\t# HOST*_MEDIA_CLEAN_FOLDERS — folders cleaned by the media profile on this host\n63\t# Aliased by detect_hosts() — script uses ANIME_CLEAN_FOLDERS / MEDIA_CLEAN_FOLDERS\n64\t#\n65\t# master.conf\n66\t#\n67\t# ANIME_FILE_PATTERNS — file patterns removed by the anime profile\n68\t# MEDIA_FILE_PATTERNS — file patterns removed by the media profile\n69\t#\n70\t# ==============================================================================================\n71\t# RUNTIME MODES\n72\t# ==============================================================================================\n73\t#\n74\t# media_cleaner.sh anime — clean anime shares\n75\t# media_cleaner.sh media — clean media shares\n76\t# media_cleaner.sh anime --dry-run — preview anime clean, no deletions\n77\t# media_cleaner.sh media --dry-run — preview media clean, no deletions\n78\t# media_cleaner.sh anime --log — verbose output\n79\t# media_cleaner.sh anime --status — show config and exit\n80\t#\n81\t# ==============================================================================================\n82\t\n83\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n84\t\n85\tsource \"$SCRIPT_DIR/../load_config.sh\"\n86\t\n87\t# ── Separate profile argument from flags ──────────────────────────────────────────────────────\n88\t# Profile (anime|media) is a positional arg — separate before parse_args sees flags\n89\tPROFILE=\"\"\n90\tRAW_ARGS=()\n91\t\n92\tfor ARG in \"$@\"; do\n93\t case \"$ARG\" in\n94\t anime|media) PROFILE=\"$ARG\" ;;\n95\t *) RAW_ARGS+=(\"$ARG\") ;;\n96\t esac\n97\tdone\n98\t\n99\tparse_args \"${RAW_ARGS[@]}\"\n100\t\n101\t# ==============================================================================================\n102\t# ━━━ Setup ━━━\n103\t# ==============================================================================================\n104\tif [[ \"$EUID\" -ne 0 ]]; then\n105\t error \"Must be run as root\"\n106\t exit 1\n107\tfi\n108\t\n109\tif [[ -z \"$PROFILE\" ]]; then\n110\t error \"No profile specified\"\n111\t error \"Usage: media_cleaner.sh <anime|media> [--dry-run] [--log] [--status]\"\n112\t exit 1\n113\tfi\n114\t\n115\tacquire_lock \"wait\"\n116\t\n117\t# detect_hosts() sets MY_ID and aliases HOST*_ANIME/MEDIA_CLEAN_FOLDERS\n118\tdetect_hosts\n119\t\n120\t\n121\t# Resolve profile folders and patterns\n122\tcase \"$PROFILE\" in\n123\t anime)\n124\t CLEAN_FOLDERS=(\"${ANIME_CLEAN_FOLDERS[@]}\")\n125\t FILE_PATTERNS=(\"${ANIME_FILE_PATTERNS[@]}\")\n126\t ;;\n127\t media)\n128\t CLEAN_FOLDERS=(\"${MEDIA_CLEAN_FOLDERS[@]}\")\n129\t FILE_PATTERNS=(\"${MEDIA_FILE_PATTERNS[@]}\")\n130\t ;;\n131\t *)\n132\t error \"Unknown profile: $PROFILE — must be anime or media\"\n133\t exit 1\n134\t ;;\n135\tesac\n136\t\n137\t# Empty array guards\n138\tif [[ ${#CLEAN_FOLDERS[@]} -eq 0 ]]; then\n139\t warn \"No folders configured for profile '$PROFILE' on $MY_ID\"\n140\t warn \"Check HOST*_${PROFILE^^}_CLEAN_FOLDERS in host*.conf\"\n141\t exit 0\n142\tfi\n143\t\n144\tif [[ ${#FILE_PATTERNS[@]} -eq 0 ]]; then\n145\t warn \"No file patterns configured for profile '$PROFILE'\"\n146\t warn \"Check ${PROFILE^^}_FILE_PATTERNS in master.conf\"\n147\t exit 0\n148\tfi\n149\t\n150\tlog \"Profile: $PROFILE\"\n151\tlog \"Folders: ${#CLEAN_FOLDERS[@]}\"\n152\tlog \"Patterns: ${#FILE_PATTERNS[@]}\"\n153\t\n154\t# ==============================================================================================\n155\t# ━━━ Status ━━━\n156\t# ==============================================================================================\n157\tif [[ \"$SHOW_STATUS\" == true ]]; then\n158\t echo \"\"\n159\t echo \"━━━━━ $ICON_SUMMARY STATUS ━━━━━\"\n160\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n161\t echo \"$ICON_GEAR Profile: $PROFILE\"\n162\t echo \"$ICON_CLEAN Folders:\"\n163\t for f in \"${CLEAN_FOLDERS[@]}\"; do\n164\t echo \" $f\"\n165\t done\n166\t echo \"$ICON_TRASH Patterns: ${FILE_PATTERNS[*]}\"\n167\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n168\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n169\t exit 0\n170\tfi\n171\t\n172\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no files will be deleted\"\n173\t\n174\t# ==============================================================================================\n175\t# ━━━ Media Cleaner ━━━\n176\t# ==============================================================================================\n177\techo \"\"\n178\techo \"━━━ $ICON_CLEAN Media Cleaner — $PROFILE — $MY_ID ━━━\"\n179\techo \"\"\n180\t\n181\tSTART=$(date +%s)\n182\tTOTAL_REMOVED=0\n183\tFAILED=()\n184\tSKIPPED=()\n185\t\n186\tfor FOLDER in \"${CLEAN_FOLDERS[@]}\"; do\n187\t FOLDER_NAME=$(basename \"$FOLDER\")\n188\t echo \"━━━ $ICON_CLEAN $FOLDER_NAME ━━━\"\n189\t\n190\t if [[ ! -d \"$FOLDER\" ]]; then\n191\t warn \"$FOLDER_NAME not found — skipping\"\n192\t SKIPPED+=(\"$FOLDER_NAME\")\n193\t echo \"\"\n194\t continue\n195\t fi\n196\t\n197\t # Build find command dynamically from FILE_PATTERNS array\n198\t CMD=(find \"$FOLDER\" -type f \\()\n199\t for (( i = 0; i < ${#FILE_PATTERNS[@]}; i++ )); do\n200\t CMD+=(-iname \"${FILE_PATTERNS[i]}\")\n201\t if [[ $i -lt $(( ${#FILE_PATTERNS[@]} - 1 )) ]]; then\n202\t CMD+=(-o)\n203\t fi\n204\t done\n205\t CMD+=(\\))\n206\t\n207\t # Count matching files before acting\n208\t FILE_COUNT=$(\"${CMD[@]}\" 2>/dev/null | wc -l)\n209\t\n210\t if [[ \"$FILE_COUNT\" -eq 0 ]]; then\n211\t log \"$FOLDER_NAME — clean ✅\"\n212\t echo \"\"\n213\t continue\n214\t fi\n215\t\n216\t warn \"$ICON_TRASH $FILE_COUNT file(s) to remove from $FOLDER_NAME\"\n217\t\n218\t if [[ \"$DRY_RUN\" == true ]]; then\n219\t warn \"DRY RUN — files that would be deleted:\"\n220\t \"${CMD[@]}\" 2>/dev/null | while IFS= read -r f; do\n221\t echo \" $ICON_TRASH $f\"\n222\t done\n223\t else\n224\t CLEAN_CMD=(\"${CMD[@]}\" -exec rm -f {} +)\n225\t if \"${CLEAN_CMD[@]}\" 2>/dev/null; then\n226\t log \"$FOLDER_NAME — $FILE_COUNT file(s) removed\"\n227\t TOTAL_REMOVED=$(( TOTAL_REMOVED + FILE_COUNT ))\n228\t else\n229\t error \"$FOLDER_NAME — cleanup failed\"\n230\t FAILED+=(\"$FOLDER_NAME\")\n231\t fi\n232\t fi\n233\t\n234\t echo \"\"\n235\tdone\n236\t\n237\tEND=$(date +%s)\n238\t\n239\t# ==============================================================================================\n240\t# ━━━ Summary ━━━\n241\t# ==============================================================================================\n242\techo \"━━━━━ $ICON_SUMMARY MEDIA CLEANER SUMMARY ━━━━━\"\n243\techo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n244\techo \"$ICON_GEAR Profile: $PROFILE\"\n245\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n246\t[[ ${#SKIPPED[@]} -gt 0 ]] && warn \"Skipped: ${SKIPPED[*]} (folders not found)\"\n247\t[[ ${#FAILED[@]} -gt 0 ]] && echo \"$ICON_ERROR Failed: ${FAILED[*]}\"\n248\t\n249\tif [[ \"$DRY_RUN\" == true ]]; then\n250\t warn \"DRY RUN — no files deleted\"\n251\telif [[ ${#FAILED[@]} -gt 0 ]]; then\n252\t echo \"$ICON_ERROR Status: SOME FOLDERS FAILED\"\n253\t notify \"Media cleaner ($PROFILE) failed on $(hostname) — ${FAILED[*]}\" \\\n254\t \"Media Cleaner\" \"warning\"\n255\telif [[ \"$TOTAL_REMOVED\" -eq 0 ]]; then\n256\t echo \"$ICON_DONE Status: clean — nothing to remove\"\n257\telse\n258\t warn \"$ICON_TRASH Removed: $TOTAL_REMOVED file(s)\"\n259\t notify \"Media cleaner ($PROFILE) on $(hostname) — $TOTAL_REMOVED file(s) removed\" \\\n260\t \"Media Cleaner\" \"warning\"\n261\tfi\n262\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n263\t\n264\t[[ ${#FAILED[@]} -gt 0 ]] && exit 1\n265\texit 0"}]},"uuid":"86962add-eef1-40e9-88f8-75ec29bd0574","timestamp":"2026-06-14T05:32:14.525Z","sourceToolAssistantUUID":"db9d19e2-3b26-4c96-a220-b5d56ef3f66f","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"86962add-eef1-40e9-88f8-75ec29bd0574","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Dca9cy38m5dvFWTunjxf1b","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/media_shares_permissions.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"dacea22a-61c6-47ed-9d39-d3234a741544","timestamp":"2026-06-14T05:32:14.717Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"dacea22a-61c6-47ed-9d39-d3234a741544","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Dca9cy38m5dvFWTunjxf1b","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ============================= Media Shares Permissions =======================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Apply nobody:users ownership and correct permissions to all media shares.\n9\t# Runs daily as the first job in the maintenance window — arr cleanup depends\n10\t# on correct ownership to rename and delete files.\n11\t#\n12\t# Now a proper daily failsafe: files arrive with wrong ownership from rsync\n13\t# without --chown, manual admin copies, containers with unconfigured PUID/PGID,\n14\t# or unRAID environment resets after updates.\n15\t#\n16\t# ==============================================================================================\n17\t# OPERATIONAL SAFEGUARDS\n18\t# ==============================================================================================\n19\t#\n20\t# acquire_lock \"wait\" — wait if previous run still active (large share scans)\n21\t# detect_hosts() — correct share list per host via MY_ID aliases\n22\t# Empty array guard — warns and exits cleanly if no shares configured\n23\t# Folder existence — skips missing shares with warning, continues others\n24\t# Separate passes — directories and files chmod'd separately for correctness\n25\t# platform_require_cmd — notify script validated before use\n26\t# Silent by default — only failures produce output, success is silent\n27\t#\n28\t# Diagnostic — high corrected count on every run means a container has wrong PUID/PGID:\n29\t# Correct values on unRAID: PUID=99 (nobody) PGID=100 (users)\n30\t# Common culprits: SABnzbd, qBittorrent, slskd — check these first\n31\t# Once fixed, this script should correct 0 files per run (pure failsafe)\n32\t#\n33\t# ==============================================================================================\n34\t# CONFIGURATION\n35\t# ==============================================================================================\n36\t#\n37\t# host*.conf\n38\t#\n39\t# HOST*_MEDIA_PERMISSION_SHARES — shares this host applies permissions to\n40\t# Aliased by detect_hosts() — script uses MEDIA_PERMISSION_SHARES\n41\t#\n42\t# master.conf\n43\t#\n44\t# PERMISSIONS_DIR_MODE — directory permissions (default 755)\n45\t# PERMISSIONS_FILE_MODE — file permissions (default 664)\n46\t# PERMISSIONS_OWNER — ownership applied to all files (default nobody:users)\n47\t#\n48\t# ==============================================================================================\n49\t# RUNTIME MODES\n50\t# ==============================================================================================\n51\t#\n52\t# media_shares_permissions.sh — normal run\n53\t# media_shares_permissions.sh --dry-run — preview without making changes\n54\t# media_shares_permissions.sh --log — verbose output\n55\t# media_shares_permissions.sh --status — show config and exit\n56\t#\n57\t# ==============================================================================================\n58\t\n59\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n60\t\n61\tsource \"$SCRIPT_DIR/../load_config.sh\"\n62\t\n63\tparse_args \"$@\"\n64\t\n65\t# ==============================================================================================\n66\t# ━━━ Setup ━━━\n67\t# ==============================================================================================\n68\tif [[ \"$EUID\" -ne 0 ]]; then\n69\t error \"Must be run as root\"\n70\t exit 1\n71\tfi\n72\t\n73\t\n74\tacquire_lock \"wait\"\n75\t\n76\t# detect_hosts() sets MY_ID and aliases HOST*_MEDIA_PERMISSION_SHARES\n77\tdetect_hosts\n78\t\n79\t# Empty array guard\n80\tif [[ ${#MEDIA_PERMISSION_SHARES[@]} -eq 0 ]]; then\n81\t warn \"MEDIA_PERMISSION_SHARES is empty for $MY_ID — nothing to do\"\n82\t warn \"Check HOST*_MEDIA_PERMISSION_SHARES in host*.conf\"\n83\t exit 0\n84\tfi\n85\t\n86\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes will be made\"\n87\t\n88\t# ==============================================================================================\n89\t# ━━━ Status ━━━\n90\t# ==============================================================================================\n91\tif [[ \"$SHOW_STATUS\" == true ]]; then\n92\t echo \"\"\n93\t echo \"━━━━━ $ICON_SUMMARY STATUS ━━━━━\"\n94\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n95\t echo \"$ICON_PERMS Dir mode: ${PERMISSIONS_DIR_MODE:-755}\"\n96\t echo \"$ICON_PERMS File mode: ${PERMISSIONS_FILE_MODE:-664}\"\n97\t echo \"$ICON_PERMS Owner: $PERMISSIONS_OWNER\"\n98\t echo \"$ICON_PERMS Shares: ${#MEDIA_PERMISSION_SHARES[@]}\"\n99\t echo \"\"\n100\t for share in \"${MEDIA_PERMISSION_SHARES[@]}\"; do\n101\t local_status=\"missing\"\n102\t [[ -d \"$share\" ]] && local_status=\"exists\"\n103\t echo \" $share — $local_status\"\n104\t done\n105\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n106\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n107\t exit 0\n108\tfi\n109\t\n110\t# ==============================================================================================\n111\t# ━━━ Apply Permissions ━━━\n112\t# ==============================================================================================\n113\techo \"\"\n114\techo \"━━━ $ICON_PERMS Media Permissions — $MY_ID ━━━\"\n115\tlog \"Dir mode: ${PERMISSIONS_DIR_MODE:-755}\"\n116\tlog \"File mode: ${PERMISSIONS_FILE_MODE:-664}\"\n117\tlog \"Owner: $PERMISSIONS_OWNER\"\n118\tlog \"Shares: ${#MEDIA_PERMISSION_SHARES[@]}\"\n119\techo \"\"\n120\t\n121\tSTART=$(date +%s)\n122\tFAILED=()\n123\tUPDATED=()\n124\tSKIPPED=()\n125\tTOTAL_DIRS_FIXED=0\n126\tTOTAL_FILES_FIXED=0\n127\t\n128\tfor SHARE in \"${MEDIA_PERMISSION_SHARES[@]}\"; do\n129\t SHARE_NAME=$(basename \"$SHARE\")\n130\t\n131\t if [[ ! -d \"$SHARE\" ]]; then\n132\t warn \"$SHARE_NAME not found — skipping\"\n133\t SKIPPED+=(\"$SHARE_NAME\")\n134\t continue\n135\t fi\n136\t\n137\t if [[ \"$DRY_RUN\" == true ]]; then\n138\t # Count what would be changed without making changes\n139\t DIR_COUNT=$(find \"$SHARE\" -type d ! -perm \"${PERMISSIONS_DIR_MODE:-755}\" \\\n140\t 2>/dev/null | wc -l)\n141\t FILE_COUNT=$(find \"$SHARE\" -type f ! -perm \"${PERMISSIONS_FILE_MODE:-664}\" \\\n142\t 2>/dev/null | wc -l)\n143\t OWNER_COUNT=$(find \"$SHARE\" ! -user nobody -o ! -group users \\\n144\t 2>/dev/null | wc -l)\n145\t warn \"DRY RUN — $SHARE_NAME: $DIR_COUNT dirs, $FILE_COUNT files, $OWNER_COUNT ownership fixes needed\"\n146\t continue\n147\t fi\n148\t\n149\t log \"Updating $SHARE_NAME...\"\n150\t\n151\t CHMOD_DIR_OK=true\n152\t CHMOD_FILE_OK=true\n153\t CHOWN_OK=true\n154\t\n155\t # Count files with wrong ownership before fixing (diagnostic)\n156\t WRONG_OWNER=$(find \"$SHARE\" \\( ! -user nobody -o ! -group users \\) \\\n157\t 2>/dev/null | wc -l)\n158\t\n159\t # Apply ownership first — affects all files and directories\n160\t chown -R \"$PERMISSIONS_OWNER\" \"$SHARE\" 2>/dev/null || CHOWN_OK=false\n161\t\n162\t # Apply directory permissions — separate pass for correctness\n163\t # Directories need execute bit — different from files\n164\t find \"$SHARE\" -type d -exec chmod \"${PERMISSIONS_DIR_MODE:-755}\" {} + \\\n165\t 2>/dev/null || CHMOD_DIR_OK=false\n166\t\n167\t # Apply file permissions — no execute bit on media files\n168\t # Ignore \"No such file\" errors: race condition with volatile dirs (e.g. Emby transcodes)\n169\t _chmod_errs=$(find \"$SHARE\" -type f -exec chmod \"${PERMISSIONS_FILE_MODE:-664}\" {} + 2>&1 | \\\n170\t grep -v \"No such file or directory\" | grep -c \"chmod:\" || true)\n171\t [[ \"$_chmod_errs\" -gt 0 ]] && CHMOD_FILE_OK=false\n172\t\n173\t if [[ \"$CHMOD_DIR_OK\" == true && \\\n174\t \"$CHMOD_FILE_OK\" == true && \\\n175\t \"$CHOWN_OK\" == true ]]; then\n176\t log \"$ICON_UNLOCKED $SHARE_NAME — permissions applied\"\n177\t UPDATED+=(\"$SHARE_NAME\")\n178\t # Log diagnostic if many files had wrong ownership\n179\t if [[ \"$WRONG_OWNER\" -gt 0 ]]; then\n180\t warn \"$SHARE_NAME — corrected $WRONG_OWNER file(s) with wrong ownership\"\n181\t warn \"If this is high, check container PUID/PGID settings (should be PUID=99 PGID=100)\"\n182\t fi\n183\t TOTAL_DIRS_FIXED=$(( TOTAL_DIRS_FIXED + 1 ))\n184\t TOTAL_FILES_FIXED=$(( TOTAL_FILES_FIXED + WRONG_OWNER ))\n185\t else\n186\t error \"$SHARE_NAME — permissions failed\"\n187\t error \" chown: $CHOWN_OK chmod dirs: $CHMOD_DIR_OK chmod files: $CHMOD_FILE_OK\"\n188\t FAILED+=(\"$SHARE_NAME\")\n189\t fi\n190\tdone\n191\t\n192\tEND=$(date +%s)\n193\t\n194\t# ==============================================================================================\n195\t# ━━━ Summary ━━━\n196\t# ==============================================================================================\n197\techo \"\"\n198\techo \"━━━━━ $ICON_SUMMARY MEDIA PERMISSIONS SUMMARY ━━━━━\"\n199\techo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n200\techo \"$ICON_PERMS Dir mode: ${PERMISSIONS_DIR_MODE:-755}\"\n201\techo \"$ICON_PERMS File mode: ${PERMISSIONS_FILE_MODE:-664}\"\n202\techo \"$ICON_PERMS Owner: $PERMISSIONS_OWNER\"\n203\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n204\techo \"\"\n205\t[[ ${#UPDATED[@]} -gt 0 ]] && log \"Updated: ${#UPDATED[@]} shares\"\n206\t[[ ${#SKIPPED[@]} -gt 0 ]] && warn \"Skipped: ${SKIPPED[*]} (not found)\"\n207\t[[ ${#FAILED[@]} -gt 0 ]] && echo \"$ICON_ERROR Failed: ${FAILED[*]}\"\n208\t\n209\t# Diagnostic — high correction count indicates container PUID/PGID issue\n210\tif [[ \"$TOTAL_FILES_FIXED\" -gt 50 ]]; then\n211\t warn \"$TOTAL_FILES_FIXED files had wrong ownership this run\"\n212\t warn \"High count suggests a container is not set to PUID=99 PGID=100\"\n213\t warn \"Common culprits: SABnzbd, qBittorrent, slskd — check container env vars\"\n214\tfi\n215\t\n216\tif [[ \"$DRY_RUN\" == true ]]; then\n217\t warn \"DRY RUN — no changes made\"\n218\telif [[ ${#FAILED[@]} -gt 0 ]]; then\n219\t echo \"$ICON_ERROR Status: SOME SHARES FAILED\"\n220\t notify \"Media permissions failed on $(hostname) — ${FAILED[*]}\" \\\n221\t \"Media Permissions\" \"warning\"\n222\telse\n223\t echo \"$ICON_DONE Status: done — ${#UPDATED[@]} shares updated\"\n224\tfi\n225\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n226\t\n227\t[[ ${#FAILED[@]} -gt 0 ]] && exit 1\n228\texit 0"}]},"uuid":"b2ad4e66-b3bd-48a7-9c34-ef016986f7af","timestamp":"2026-06-14T05:32:14.737Z","sourceToolAssistantUUID":"dacea22a-61c6-47ed-9d39-d3234a741544","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"b2ad4e66-b3bd-48a7-9c34-ef016986f7af","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01H9N2RvP8zY8GJhPUp1oDpj","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/play_state_sync.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"d2b20024-fc03-4a43-b3eb-6e38e0674580","timestamp":"2026-06-14T05:32:14.932Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"d2b20024-fc03-4a43-b3eb-6e38e0674580","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01H9N2RvP8zY8GJhPUp1oDpj","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ================================= Play State Sync ============================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Syncs watched/played state and resume positions across all configured Emby\n9\t# and Jellyfin servers. Newest timestamp wins — no data is ever lost.\n10\t#\n11\t# Users are matched by name (case-insensitive). If a user exists on some servers\n12\t# but not others, those servers are skipped for that user — no errors, no partial\n13\t# syncs from unrelated accounts.\n14\t#\n15\t# Items are matched by external provider IDs:\n16\t# Movies → IMDb ID, then TMDB ID\n17\t# Episodes → TVDB ID + season + episode number\n18\t# Audio → MusicBrainz Track ID\n19\t#\n20\t# ==============================================================================================\n21\t# SYNC LOGIC\n22\t# ==============================================================================================\n23\t#\n24\t# For each matched item across ≥2 servers:\n25\t# 1. Compare LastPlayedDate across all servers that have a play record.\n26\t# 2. The server with the newest LastPlayedDate is authoritative.\n27\t# 3. Push that server's state (Played, PlayCount, LastPlayedDate,\n28\t# PlaybackPositionTicks) to every other server.\n29\t# 4. Servers with no record for that item also receive the state.\n30\t#\n31\t# Resume positions (partial plays, not marked Played):\n32\t# Synced by comparing PlaybackPositionTicks when LastPlayedDate is absent.\n33\t# The higher tick count wins.\n34\t#\n35\t# ==============================================================================================\n36\t# CONFIGURATION (host*.conf, aliased by detect_hosts)\n37\t# ==============================================================================================\n38\t#\n39\t# HOST*_TRANSCODE_SERVERS \"Name|URL|APIKey|type\" entries per host (emby/jellyfin)\n40\t# All hosts are discovered automatically — no extra config needed.\n41\t# Remote host URLs have localhost rewritten to their Tailscale IP.\n42\t#\n43\t# master.conf\n44\t#\n45\t# PLAY_SYNC_ENABLED Master toggle (default: true)\n46\t# PLAY_SYNC_REMOTE Sync across all hosts via Tailscale (default: true)\n47\t# false = local servers only (this host's Emby + Jellyfin)\n48\t# PLAY_SYNC_DAYS How many days back to check for played items (default: 90)\n49\t# Use 0 to sync all played items (slow on large libraries).\n50\t# PLAY_SYNC_TYPES Comma-separated item types to sync (default: Movie,Episode,Audio)\n51\t#\n52\t# ==============================================================================================\n53\t# RUNTIME MODES\n54\t# ==============================================================================================\n55\t#\n56\t# play_state_sync.sh\n57\t# Sync all matched users across all configured servers.\n58\t#\n59\t# play_state_sync.sh --dry-run\n60\t# Show what would be synced without writing any state.\n61\t#\n62\t# play_state_sync.sh --status\n63\t# Show configured servers, reachability, and user counts.\n64\t#\n65\t# play_state_sync.sh --full\n66\t# Ignore PLAY_SYNC_DAYS — sync all played items (may be slow).\n67\t#\n68\t# play_state_sync.sh --log\n69\t# Verbose output — show each item comparison.\n70\t#\n71\t# ==============================================================================================\n72\t\n73\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n74\tsource \"$SCRIPT_DIR/../load_config.sh\"\n75\t\n76\t# ── Handle --full before parse_args ───────────────────────────────────────────\n77\tFULL_SYNC=false\n78\t_FILTERED=()\n79\tfor _a in \"$@\"; do\n80\t [[ \"$_a\" == \"--full\" ]] && FULL_SYNC=true || _FILTERED+=(\"$_a\")\n81\tdone\n82\tparse_args \"${_FILTERED[@]}\"\n83\t\n84\t# ==============================================================================================\n85\t# ━━━ Setup ━━━\n86\t# ==============================================================================================\n87\t[[ \"${PLAY_SYNC_ENABLED:-true}\" != \"true\" ]] && echo \"Play state sync disabled\" && exit 0\n88\t\n89\tSYNC_DAYS=\"${PLAY_SYNC_DAYS:-90}\"\n90\tSYNC_TYPES=\"${PLAY_SYNC_TYPES:-Movie,Episode,Audio}\"\n91\t[[ \"$FULL_SYNC\" == true ]] && SYNC_DAYS=0\n92\tlog \"$ICON_GEAR Config: days=${SYNC_DAYS} types=${SYNC_TYPES} remote=${PLAY_SYNC_REMOTE:-true}\"\n93\t\n94\tcommand -v jq >/dev/null 2>&1 || { error \"jq is required but not installed\"; exit 1; }\n95\t\n96\tacquire_lock\n97\t\n98\t# ── Build server list across ALL hosts ────────────────────────────────────────\n99\t# All HOST*_TRANSCODE_SERVERS arrays are loaded into env by load_config.sh.\n100\t# For remote hosts, localhost in the URL is rewritten to their Tailscale IP.\n101\tdeclare -a SRV_NAME SRV_URL SRV_KEY SRV_TYPE\n102\t_srv_count=0\n103\t_my_hostname=$(hostname -s)\n104\t\n105\t_add_server() {\n106\t local name=\"$1\" url=\"$2\" key=\"$3\" type=\"$4\"\n107\t [[ -z \"$url\" || -z \"$key\" ]] && return\n108\t [[ \"$key\" == \"YOUR_API_KEY\"* || \"$key\" == \"placeholder\"* ]] && return\n109\t SRV_NAME[$_srv_count]=\"$name\"\n110\t SRV_URL[$_srv_count]=\"$url\"\n111\t SRV_KEY[$_srv_count]=\"$key\"\n112\t SRV_TYPE[$_srv_count]=\"$type\"\n113\t (( _srv_count++ ))\n114\t}\n115\t\n116\tfor _varname in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do\n117\t _num=\"${_varname//[^0-9]/}\"\n118\t _host_hostname=\"${!_varname}\"\n119\t [[ -z \"$_host_hostname\" ]] && continue\n120\t\n121\t _is_me=false\n122\t [[ \"${_host_hostname,,}\" == \"${_my_hostname,,}\" ]] && _is_me=true\n123\t\n124\t # Skip remote hosts when PLAY_SYNC_REMOTE=false\n125\t if [[ \"$_is_me\" == false && \"${PLAY_SYNC_REMOTE:-true}\" != \"true\" ]]; then\n126\t log \"$_host_hostname — remote sync disabled (PLAY_SYNC_REMOTE=false), skipping\"\n127\t continue\n128\t fi\n129\t\n130\t # Resolve Tailscale IP for remote hosts\n131\t _ts_ip=\"\"\n132\t if [[ \"$_is_me\" == false ]]; then\n133\t _ts_ip=$(resolve_tailscale_ip \"$_host_hostname\")\n134\t if [[ -z \"$_ts_ip\" ]]; then\n135\t log \"$_host_hostname — Tailscale IP not found, skipping\"\n136\t continue\n137\t fi\n138\t fi\n139\t\n140\t # Load this host's TRANSCODE_SERVERS array\n141\t _srv_arr_name=\"HOST${_num}_TRANSCODE_SERVERS\"\n142\t eval \"_host_entries=(\\\"\\${${_srv_arr_name}[@]}\\\")\"\n143\t [[ \"${#_host_entries[@]}\" -eq 0 ]] && continue\n144\t\n145\t for _entry in \"${_host_entries[@]}\"; do\n146\t IFS='|' read -r _name _url _key _type <<< \"$_entry\"\n147\t [[ \"$_type\" == \"emby\" || \"$_type\" == \"jellyfin\" ]] || continue\n148\t # For remote hosts rewrite localhost/127.0.0.1 → Tailscale IP\n149\t if [[ \"$_is_me\" == false ]]; then\n150\t _url=\"${_url//localhost/$_ts_ip}\"\n151\t _url=\"${_url//127.0.0.1/$_ts_ip}\"\n152\t fi\n153\t _add_server \"${_name} (${_host_hostname})\" \"$_url\" \"$_key\" \"$_type\"\n154\t done\n155\tdone\n156\t\n157\tif [[ \"$_srv_count\" -lt 2 ]]; then\n158\t error \"Need at least 2 media servers configured — found $_srv_count\"\n159\t exit 1\n160\tfi\n161\t\n162\t# ==============================================================================================\n163\t# ━━━ API Helpers ━━━\n164\t# ==============================================================================================\n165\t_api_get() {\n166\t local url=\"$1\" key=\"$2\" endpoint=\"$3\"\n167\t curl -sf --max-time 30 \\\n168\t -H \"X-Emby-Token: $key\" \\\n169\t \"${url%/}/${endpoint}\" 2>/dev/null\n170\t}\n171\t\n172\t_api_post() {\n173\t local url=\"$1\" key=\"$2\" endpoint=\"$3\" data=\"${4:-}\"\n174\t if [[ -n \"$data\" ]]; then\n175\t curl -sf --max-time 30 -s -o /dev/null -w \"%{http_code}\" -X POST \\\n176\t -H \"X-Emby-Token: $key\" \\\n177\t -H \"Content-Type: application/json\" \\\n178\t -d \"$data\" \\\n179\t \"${url%/}/${endpoint}\" 2>/dev/null\n180\t else\n181\t curl -sf --max-time 30 -s -o /dev/null -w \"%{http_code}\" -X POST \\\n182\t -H \"X-Emby-Token: $key\" \\\n183\t \"${url%/}/${endpoint}\" 2>/dev/null\n184\t fi\n185\t}\n186\t\n187\t# ISO 8601 date → unix seconds (portable, no date -d on BusyBox)\n188\t_iso_to_epoch() {\n189\t local dt=\"$1\"\n190\t [[ -z \"$dt\" || \"$dt\" == \"null\" ]] && echo 0 && return\n191\t # Strip fractional seconds and Z, convert to seconds\n192\t dt=\"${dt%.*}\" # remove .NNNNNNN\n193\t dt=\"${dt%Z}\" # remove trailing Z\n194\t dt=\"${dt/T/ }\" # T → space\n195\t date -u -d \"$dt UTC\" +%s 2>/dev/null || echo 0\n196\t}\n197\t\n198\t# Ticks → seconds (1 tick = 100ns, 10_000_000 ticks = 1s)\n199\t_ticks_to_sec() {\n200\t echo $(( ${1:-0} / 10000000 ))\n201\t}\n202\t\n203\t# ==============================================================================================\n204\t# ━━━ Status ━━━\n205\t# ==============================================================================================\n206\tif [[ \"$SHOW_STATUS\" == true ]]; then\n207\t echo \"\"\n208\t echo \"━━━━━ $ICON_SUMMARY PLAY STATE SYNC STATUS ━━━━━\"\n209\t echo \"$ICON_GEAR Remote sync: ${PLAY_SYNC_REMOTE:-true}\"\n210\t echo \"$ICON_GEAR Sync days: ${SYNC_DAYS:-all}\"\n211\t echo \"$ICON_GEAR Item types: $SYNC_TYPES\"\n212\t echo \"\"\n213\t for i in $(seq 0 $(( _srv_count - 1 ))); do\n214\t echo \"$ICON_HOST [${SRV_TYPE[$i]}] ${SRV_NAME[$i]} (${SRV_URL[$i]})\"\n215\t _users=$(_api_get \"${SRV_URL[$i]}\" \"${SRV_KEY[$i]}\" \"Users\" 2>/dev/null | jq -r '.[].Name' 2>/dev/null | wc -l)\n216\t if [[ \"$_users\" -gt 0 ]]; then\n217\t echo \" $ICON_DONE Reachable — $_users user(s)\"\n218\t _api_get \"${SRV_URL[$i]}\" \"${SRV_KEY[$i]}\" \"Users\" 2>/dev/null \\\n219\t | jq -r '.[].Name' 2>/dev/null | while read -r n; do echo \" · $n\"; done\n220\t else\n221\t echo \" $ICON_ERROR Unreachable or no users\"\n222\t fi\n223\t done\n224\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n225\t exit 0\n226\tfi\n227\t\n228\t# ==============================================================================================\n229\t# ━━━ Main Sync ━━━\n230\t# ==============================================================================================\n231\techo \"━━━ $ICON_SYNC Play State Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━\"\n232\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no state will be written\"\n233\t[[ \"$FULL_SYNC\" == true ]] && log \"Full sync mode — ignoring PLAY_SYNC_DAYS\"\n234\t\n235\tSTART=$(date +%s)\n236\tTOTAL_SYNCED=0\n237\tTOTAL_SKIPPED=0\n238\tTOTAL_ERRORS=0\n239\t\n240\t# ── Step 1: Fetch users from each server ─────────────────────────────────────\n241\tdeclare -A SRV_USERS # idx → JSON array string of users\n242\t\n243\tlog \"Fetching users...\"\n244\tfor i in $(seq 0 $(( _srv_count - 1 ))); do\n245\t _resp=$(_api_get \"${SRV_URL[$i]}\" \"${SRV_KEY[$i]}\" \"Users\")\n246\t if [[ -z \"$_resp\" ]]; then\n247\t warn \"${SRV_NAME[$i]} — unreachable, skipping\"\n248\t SRV_USERS[$i]=\"\"\n249\t continue\n250\t fi\n251\t SRV_USERS[$i]=\"$_resp\"\n252\t _count=$(echo \"$_resp\" | jq 'length' 2>/dev/null || echo 0)\n253\t log \"${SRV_NAME[$i]} — $_count user(s)\"\n254\tdone\n255\t\n256\t# ── Step 2: Build cross-server user map ──────────────────────────────────────\n257\t# lowercase_name → \"server_idx:user_id server_idx:user_id ...\"\n258\tdeclare -A USER_MAP\n259\t\n260\tfor i in $(seq 0 $(( _srv_count - 1 ))); do\n261\t [[ -z \"${SRV_USERS[$i]}\" ]] && continue\n262\t while IFS=$'\\t' read -r uid uname; do\n263\t [[ -z \"$uid\" || -z \"$uname\" ]] && continue\n264\t lname=\"${uname,,}\"\n265\t if [[ -n \"${USER_MAP[$lname]}\" ]]; then\n266\t USER_MAP[$lname]+=\" ${i}:${uid}\"\n267\t else\n268\t USER_MAP[$lname]=\"${i}:${uid}\"\n269\t fi\n270\t done < <(echo \"${SRV_USERS[$i]}\" | jq -r '.[] | [.Id, .Name] | @tsv' 2>/dev/null)\n271\tdone\n272\t\n273\t# ── Step 3: Sync per matched user ────────────────────────────────────────────\n274\t_date_filter=\"\"\n275\tif [[ \"$SYNC_DAYS\" -gt 0 ]]; then\n276\t _cutoff=$(date -u -d \"$SYNC_DAYS days ago\" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || \\\n277\t date -u -v \"-${SYNC_DAYS}d\" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)\n278\t [[ -n \"$_cutoff\" ]] && _date_filter=\"&MinDateLastSaved=$_cutoff\"\n279\tfi\n280\t\n281\tfor lname in \"${!USER_MAP[@]}\"; do\n282\t read -ra _pairs <<< \"${USER_MAP[$lname]}\"\n283\t\n284\t # Skip users only on one server\n285\t [[ \"${#_pairs[@]}\" -lt 2 ]] && log \" $lname — only on 1 server, skipping\" && continue\n286\t\n287\t echo \"\"\n288\t echo \"── User: $lname (${#_pairs[@]} server(s)) ──\"\n289\t\n290\t # Build per-server user context\n291\t declare -A U_IDX U_UID\n292\t for _pair in \"${_pairs[@]}\"; do\n293\t IFS=':' read -r _si _ui <<< \"$_pair\"\n294\t U_IDX[\"$_si\"]=\"$_si\"\n295\t U_UID[\"$_si\"]=\"$_ui\"\n296\t done\n297\t\n298\t # ── Fetch played items from each server for this user ────────────────────\n299\t # Key: provider_id_string → sorted list of (epoch, srv_idx, item_id, play_count, ticks, played)\n300\t declare -A ITEM_MAP # provider_key → JSON per-server data\n301\t\n302\t for _si in \"${!U_IDX[@]}\"; do\n303\t _uid=\"${U_UID[$_si]}\"\n304\t # All played items — no limit, covers both date-stamped and batch-marked (null date) entries\n305\t _endpoint=\"Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending\"\n306\t _resp=$(_api_get \"${SRV_URL[$_si]}\" \"${SRV_KEY[$_si]}\" \"$_endpoint\")\n307\t if [[ -z \"$_resp\" ]]; then\n308\t warn \" ${SRV_NAME[$_si]} — failed to fetch items for $lname\"\n309\t continue\n310\t fi\n311\t\n312\t # Resume positions (not yet marked played)\n313\t _endpoint2=\"Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&SortBy=DatePlayed&SortOrder=Descending&Filters=IsResumable\"\n314\t _resp2=$(_api_get \"${SRV_URL[$_si]}\" \"${SRV_KEY[$_si]}\" \"$_endpoint2\")\n315\t\n316\t # Combine and deduplicate by Id\n317\t [[ -z \"$_resp2\" ]] && _resp2='{\"Items\":[]}'\n318\t _combined=$(printf '%s\\n%s' \"$_resp\" \"$_resp2\" | jq -s \\\n319\t '[.[0].Items // [], .[1].Items // []] | add // [] | unique_by(.Id)' 2>/dev/null)\n320\t\n321\t _count=$(echo \"$_combined\" | jq 'length' 2>/dev/null || echo 0)\n322\t log \" ${SRV_NAME[$_si]} — $_count item(s) with state for $lname\"\n323\t\n324\t # Build item lookup by provider key\n325\t while IFS=$'\\t' read -r iid itype season ep imdb tmdb tvdb mbtrack played ticks lplayed pcount; do\n326\t # Build canonical provider key\n327\t _pkey=\"\"\n328\t case \"$itype\" in\n329\t Movie)\n330\t [[ \"$imdb\" != \"null\" && -n \"$imdb\" ]] && _pkey=\"imdb:${imdb}\"\n331\t [[ -z \"$_pkey\" && \"$tmdb\" != \"null\" && -n \"$tmdb\" ]] && _pkey=\"tmdb:movie:${tmdb}\"\n332\t ;;\n333\t Episode)\n334\t [[ \"$tvdb\" != \"null\" && -n \"$tvdb\" && \"$season\" != \"null\" && \"$ep\" != \"null\" ]] && \\\n335\t _pkey=\"tvdb:ep:${tvdb}:s${season}e${ep}\"\n336\t ;;\n337\t Audio)\n338\t [[ \"$mbtrack\" != \"null\" && -n \"$mbtrack\" ]] && _pkey=\"mb:track:${mbtrack}\"\n339\t ;;\n340\t esac\n341\t [[ -z \"$_pkey\" ]] && continue\n342\t\n343\t _epoch=$(_iso_to_epoch \"$lplayed\")\n344\t _entry=\"${_si}|${iid}|${played}|${pcount}|${ticks}|${_epoch}|${lplayed}\"\n345\t\n346\t if [[ -n \"${ITEM_MAP[$_pkey]}\" ]]; then\n347\t ITEM_MAP[$_pkey]+=$'\\n'\"$_entry\"\n348\t else\n349\t ITEM_MAP[$_pkey]=\"$_entry\"\n350\t fi\n351\t\n352\t done < <(echo \"$_combined\" | jq -r '.[] | [\n353\t .Id,\n354\t .Type,\n355\t (.ParentIndexNumber // \"null\" | tostring),\n356\t (.IndexNumber // \"null\" | tostring),\n357\t (.ProviderIds.Imdb // \"null\"),\n358\t (.ProviderIds.Tmdb // \"null\"),\n359\t (.ProviderIds.Tvdb // \"null\"),\n360\t (.ProviderIds.MusicBrainzTrackId // \"null\"),\n361\t (.UserData.Played // false | tostring),\n362\t (.UserData.PlaybackPositionTicks // 0 | tostring),\n363\t (.UserData.LastPlayedDate // \"null\"),\n364\t (.UserData.PlayCount // 0 | tostring)\n365\t ] | @tsv' 2>/dev/null)\n366\t done\n367\t\n368\t # ── Compare and sync ─────────────────────────────────────────────────────\n369\t for _pkey in \"${!ITEM_MAP[@]}\"; do\n370\t # Collect all server entries for this item\n371\t declare -A E_EPOCH E_PLAYED E_PCOUNT E_TICKS E_IID E_LPLAYED E_SIDX\n372\t _has_entries=false\n373\t\n374\t while IFS='|' read -r _si _iid _played _pcount _ticks _epoch _lplayed; do\n375\t [[ -z \"$_si\" ]] && continue\n376\t E_SIDX[$_si]=\"$_si\"\n377\t E_IID[$_si]=\"$_iid\"\n378\t E_PLAYED[$_si]=\"$_played\"\n379\t E_PCOUNT[$_si]=\"$_pcount\"\n380\t E_TICKS[$_si]=\"$_ticks\"\n381\t E_EPOCH[$_si]=\"$_epoch\"\n382\t E_LPLAYED[$_si]=\"$_lplayed\"\n383\t _has_entries=true\n384\t done <<< \"${ITEM_MAP[$_pkey]}\"\n385\t\n386\t [[ \"$_has_entries\" == false ]] && continue\n387\t\n388\t # Find the authoritative server: newest LastPlayedDate epoch\n389\t # Tie-break: higher PlayCount, then higher Ticks\n390\t _auth_si=\"\"\n391\t _auth_epoch=0\n392\t _auth_pcount=0\n393\t _auth_ticks=0\n394\t\n395\t for _si in \"${!E_SIDX[@]}\"; do\n396\t _e=\"${E_EPOCH[$_si]:-0}\"\n397\t _pc=\"${E_PCOUNT[$_si]:-0}\"\n398\t _tk=\"${E_TICKS[$_si]:-0}\"\n399\t if [[ \"$_e\" -gt \"$_auth_epoch\" ]] || \\\n400\t [[ \"$_e\" -eq \"$_auth_epoch\" && \"$_pc\" -gt \"$_auth_pcount\" ]] || \\\n401\t [[ \"$_e\" -eq \"$_auth_epoch\" && \"$_pc\" -eq \"$_auth_pcount\" && \"$_tk\" -gt \"$_auth_ticks\" ]]; then\n402\t _auth_si=\"$_si\"\n403\t _auth_epoch=\"$_e\"\n404\t _auth_pcount=\"$_pc\"\n405\t _auth_ticks=\"$_tk\"\n406\t fi\n407\t done\n408\t\n409\t [[ -z \"$_auth_si\" ]] && continue\n410\t\n411\t _auth_played=\"${E_PLAYED[$_auth_si]}\"\n412\t _auth_lplayed=\"${E_LPLAYED[$_auth_si]}\"\n413\t _auth_pcount=\"${E_PCOUNT[$_auth_si]}\"\n414\t _auth_ticks=\"${E_TICKS[$_auth_si]}\"\n415\t\n416\t # Push to servers with older state OR no state at all\n417\t for _si in \"${!U_IDX[@]}\"; do\n418\t [[ \"$_si\" == \"$_auth_si\" ]] && continue\n419\t _their_epoch=\"${E_EPOCH[$_si]:-0}\"\n420\t _their_played=\"${E_PLAYED[$_si]:-false}\"\n421\t\n422\t # Skip if they already have the same/newer state\n423\t _skip=false\n424\t if [[ \"$_auth_played\" == \"true\" ]]; then\n425\t # Played: skip if target is already marked played with same/newer date\n426\t [[ \"$_their_epoch\" -ge \"$_auth_epoch\" && \"$_their_played\" == \"true\" ]] && _skip=true\n427\t else\n428\t # Resume only: skip if target already has same or more ticks\n429\t # (posting ticks via /UserData doesn't set LastPlayedDate, so epoch comparison is useless here)\n430\t [[ \"${E_TICKS[$_si]:-0}\" -ge \"${_auth_ticks:-0}\" && \"$_their_played\" == \"false\" ]] && _skip=true\n431\t fi\n432\t if [[ \"$_skip\" == true ]]; then\n433\t log \" SKIP $_pkey → ${SRV_NAME[$_si]} already up to date\"\n434\t (( TOTAL_SKIPPED++ ))\n435\t continue\n436\t fi\n437\t\n438\t _uid=\"${U_UID[$_si]}\"\n439\t _iid=\"${E_IID[$_si]:-}\" # might not exist on this server yet\n440\t\n441\t # Find item ID on target server by provider key if not in our map\n442\t if [[ -z \"$_iid\" ]]; then\n443\t _ptype=\"${_pkey%%:*}\"\n444\t _pval=\"${_pkey##*:}\"\n445\t case \"$_ptype\" in\n446\t imdb) _search_field=\"imdb.${_pval}\" ;;\n447\t tmdb) _search_field=\"tmdb.${_pval##movie:}\" ;;\n448\t tvdb)\n449\t # _pkey format: tvdb:ep:{tvdb_id}:s{season}e{ep}\n450\t # ##*: gives \"s7e2\" (wrong); strip prefix then first :\n451\t _tvdb_num=\"${_pkey#tvdb:ep:}\"; _tvdb_num=\"${_tvdb_num%%:*}\"\n452\t _search_field=\"tvdb.${_tvdb_num}\"\n453\t ;;\n454\t mb) _search_field=\"\" ;; # skip music if not found\n455\t esac\n456\t\n457\t if [[ -n \"$_search_field\" ]]; then\n458\t _iid=$(_api_get \"${SRV_URL[$_si]}\" \"${SRV_KEY[$_si]}\" \\\n459\t \"Items?AnyProviderIdEquals=${_search_field}&Recursive=true&Fields=ProviderIds&ExcludeLocationTypes=Virtual&Limit=1\" 2>/dev/null \\\n460\t | jq -r '.Items[0].Id // empty' 2>/dev/null)\n461\t fi\n462\t [[ -z \"$_iid\" ]] && log \" SKIP $_pkey → ${SRV_NAME[$_si]} item not found on server\" && continue\n463\t fi\n464\t\n465\t log \" SYNC $_pkey → ${SRV_NAME[$_si]} (auth: ${SRV_NAME[$_auth_si]}, epoch: $_auth_epoch)\"\n466\t\n467\t if [[ \"$DRY_RUN\" == true ]]; then\n468\t echo \" DRY RUN: would sync $_pkey → ${SRV_NAME[$_si]} user=$lname played=$_auth_played date=$_auth_lplayed\"\n469\t (( TOTAL_SYNCED++ ))\n470\t continue\n471\t fi\n472\t\n473\t # Write state to target server\n474\t if [[ \"$_auth_played\" == \"true\" ]]; then\n475\t # Mark as played with date\n476\t _date_param=\"\"\n477\t if [[ \"$_auth_lplayed\" != \"null\" && -n \"$_auth_lplayed\" ]]; then\n478\t # PlayedItems expects DatePlayed in yyyyMMddHHmmss (no separators, no timezone)\n479\t _lp=\"${_auth_lplayed%.*}\"\n480\t _lp=\"${_lp%Z}\"\n481\t _date_param=\"?DatePlayed=${_lp//[-T:]/}\"\n482\t fi\n483\t _http=$(_api_post \"${SRV_URL[$_si]}\" \"${SRV_KEY[$_si]}\" \\\n484\t \"Users/${_uid}/PlayedItems/${_iid}${_date_param}\")\n485\t if [[ \"$_http\" == \"200\" || \"$_http\" == \"201\" ]]; then\n486\t success \" ✓ $_pkey → ${SRV_NAME[$_si]} marked played\"\n487\t (( TOTAL_SYNCED++ ))\n488\t else\n489\t warn \" ✗ $_pkey → ${SRV_NAME[$_si]} failed (HTTP ${_http:-err})\"\n490\t (( TOTAL_ERRORS++ ))\n491\t fi\n492\t else\n493\t # Sync resume position only\n494\t _ticks_int=$(( ${_auth_ticks:-0} ))\n495\t if [[ \"$_ticks_int\" -gt 0 ]]; then\n496\t _payload=\"{\\\"PlaybackPositionTicks\\\":${_ticks_int}}\"\n497\t _http=$(_api_post \"${SRV_URL[$_si]}\" \"${SRV_KEY[$_si]}\" \\\n498\t \"Users/${_uid}/Items/${_iid}/UserData\" \"$_payload\")\n499\t if [[ \"$_http\" == \"200\" || \"$_http\" == \"204\" ]]; then\n500\t success \" ✓ $_pkey → ${SRV_NAME[$_si]} resume synced ($(_ticks_to_sec \"$_ticks_int\")s)\"\n501\t (( TOTAL_SYNCED++ ))\n502\t else\n503\t warn \" ✗ $_pkey → ${SRV_NAME[$_si]} resume sync failed (HTTP ${_http:-err})\"\n504\t (( TOTAL_ERRORS++ ))\n505\t fi\n506\t fi\n507\t fi\n508\t done\n509\t\n510\t unset E_EPOCH E_PLAYED E_PCOUNT E_TICKS E_IID E_LPLAYED E_SIDX\n511\t done\n512\t\n513\t unset ITEM_MAP U_IDX U_UID\n514\tdone\n515\t\n516\t# ==============================================================================================\n517\t# ━━━ Summary ━━━\n518\t# ==============================================================================================\n519\tEND=$(date +%s)\n520\techo \"\"\n521\techo \"━━━━━ $ICON_SUMMARY PLAY STATE SYNC SUMMARY ━━━━━\"\n522\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n523\techo \"$ICON_DONE Synced: $TOTAL_SYNCED\"\n524\t[[ \"$TOTAL_SKIPPED\" -gt 0 ]] && echo \"$ICON_RUNNING Skipped: $TOTAL_SKIPPED (already current)\"\n525\t[[ \"$TOTAL_ERRORS\" -gt 0 ]] && echo \"$ICON_ERROR Errors: $TOTAL_ERRORS\"\n526\t\n527\tif [[ \"$DRY_RUN\" == true ]]; then\n528\t warn \"DRY RUN — no changes written\"\n529\telif [[ \"$TOTAL_ERRORS\" -eq 0 ]]; then\n530\t success \"Done ✅\"\n531\telse\n532\t warn \"Done with $TOTAL_ERRORS error(s)\"\n533\tfi\n534\t"}]},"uuid":"162c5316-96d5-4921-bc68-e705acdc0e0d","timestamp":"2026-06-14T05:32:14.949Z","sourceToolAssistantUUID":"d2b20024-fc03-4a43-b3eb-6e38e0674580","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"162c5316-96d5-4921-bc68-e705acdc0e0d","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019UadWtKNPV2jRFEXJd4h2K","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/playback_aware_lidarr_discovery.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"50d46d9b-0e5b-4eba-9f4e-bdb03aa1e8ae","timestamp":"2026-06-14T05:32:15.133Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"50d46d9b-0e5b-4eba-9f4e-bdb03aa1e8ae","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019UadWtKNPV2jRFEXJd4h2K","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# =========================== Playback-Aware Lidarr Discovery ==================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Two-stage behavior-driven music discovery.\n9\t#\n10\t# Stage 1 — Score what you actually played this week. The top artists become\n11\t# high-quality seeds, not just anything that hit the minimum play count.\n12\t#\n13\t# Stage 2 — Run Last.fm artist.getSimilar on those seeds. Score the recommendations\n14\t# and add the top artists to Lidarr.\n15\t#\n16\t# Goal: 0–5 meaningful Lidarr adds per week, not bulk imports.\n17\t#\n18\t# ==============================================================================================\n19\t# FLOW\n20\t# ==============================================================================================\n21\t#\n22\t# 1. Fetch play completions from Emby activity log (last LOOKBACK_DAYS days)\n23\t# 2. Aggregate by artist — apply per-user influence cap to play weights\n24\t#\n25\t# ── Stage 1 ─────────────────────────────────────────────────────────────────\n26\t# 3. Score each played artist: user signal + recency + Last.fm popularity/quality\n27\t# 4. Take top MAX_ADDS by score → discovery seeds\n28\t#\n29\t# ── Stage 2 ─────────────────────────────────────────────────────────────────\n30\t# 5. For each seed, call Last.fm artist.getSimilar → collect candidates\n31\t# 6. Aggregate: affinity (weighted similarity × seed score) + breadth (distinct seeds)\n32\t# 7. Filter: already in Lidarr, already in Emby, placeholder artists, cooldown\n33\t# 8. Score candidates: affinity + breadth + popularity + quality\n34\t# 9. Take top MAX_ADDS above threshold → add to Lidarr\n35\t#\n36\t# ==============================================================================================\n37\t# SCORING MODEL\n38\t# ==============================================================================================\n39\t#\n40\t# Stage 1 — seed selection (max 100)\n41\t# user_score (0-40) — effective plays × 5, cap 40\n42\t# recency_score (0-30) — days since last play; today→30, older→less\n43\t# popularity_score (0-20) — Last.fm listeners\n44\t# quality_score (0-10) — Last.fm global playcount\n45\t#\n46\t# Stage 2 — candidate scoring (max 100)\n47\t# affinity_score (0-40) — seed_score × similarity, normalized; raw/20, cap 40\n48\t# breadth_score (0-30) — 1 seed→5, 2 seeds→18, 3+seeds→30\n49\t# popularity_score (0-20) — Last.fm listeners\n50\t# quality_score (0-10) — Last.fm global playcount\n51\t#\n52\t# Threshold: LIDARR_DISCOVERY_THRESHOLD (default 70) applied to both stages\n53\t# Max adds: LIDARR_DISCOVERY_MAX_ADDS (default 5) caps each stage\n54\t#\n55\t# ==============================================================================================\n56\t# REQUIREMENTS\n57\t# ==============================================================================================\n58\t#\n59\t# Last.fm API key — required for both stages\n60\t# Configure HOST*_LASTFM_API_KEY in host*.conf\n61\t#\n62\t# ==============================================================================================\n63\t# CONFIGURATION (master.conf)\n64\t# ==============================================================================================\n65\t#\n66\t# LIDARR_DISCOVERY_THRESHOLD — minimum score for Stage 1 seeds and Stage 2 adds (default: 70)\n67\t# LIDARR_DISCOVERY_LOOKBACK_DAYS — Emby play history window in days (default: 7)\n68\t# LIDARR_DISCOVERY_MIN_PLAYS — min plays to be evaluated in Stage 1 (default: 3)\n69\t# LIDARR_DISCOVERY_MAX_ADDS — max seeds (Stage 1) and max adds (Stage 2) (default: 5)\n70\t# LIDARR_DISCOVERY_USER_CAP_PCT — max % any one user contributes to play weight (default: 35)\n71\t# LIDARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a Stage 2 reject (default: 30)\n72\t# LIDARR_DISCOVERY_HISTORY — history/state file path\n73\t#\n74\t# ==============================================================================================\n75\t# RUNTIME MODES\n76\t# ==============================================================================================\n77\t#\n78\t# playback_aware_lidarr_discovery.sh — normal run\n79\t# playback_aware_lidarr_discovery.sh --dry-run — score and rank, no Lidarr changes\n80\t# playback_aware_lidarr_discovery.sh --log — verbose output\n81\t# playback_aware_lidarr_discovery.sh --status — show config and exit\n82\t#\n83\t# Recommended schedule: weekly (WEEKLY_MAINTENANCE_SCRIPTS in master.conf)\n84\t#\n85\t# ==============================================================================================\n86\t# OPERATIONAL SAFEGUARDS\n87\t# ==============================================================================================\n88\t#\n89\t# Root Required\n90\t# acquire_lock and Lidarr API writes require root. Script exits cleanly if not root.\n91\t#\n92\t# Dry-Run Mode\n93\t# --dry-run scores and ranks all candidates but makes no Lidarr API calls and does not\n94\t# write to the history file. Safe to run at any time to preview what would be added.\n95\t#\n96\t# Add-Only\n97\t# Only adds artists to Lidarr. Never deletes or modifies existing entries.\n98\t#\n99\t# Cooldown Guard\n100\t# Candidates rejected this run are recorded in the history file and not re-evaluated\n101\t# until LIDARR_DISCOVERY_REJECT_COOLDOWN days have elapsed.\n102\t#\n103\t# ==============================================================================================\n104\t# STATE FILES\n105\t# ==============================================================================================\n106\t#\n107\t# LIDARR_DISCOVERY_HISTORY (default: $DATA_DIR/lidarr_discovery_history.db)\n108\t# Tracks added artists and rejected candidates with timestamps. Written after every\n109\t# non-dry-run. Enforces reject cooldown and prevents re-adding items added by previous\n110\t# runs. Safe to delete — next run starts fresh with no memory.\n111\t#\n112\t# ==============================================================================================\n113\t\n114\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n115\t\n116\tsource \"$SCRIPT_DIR/../load_config.sh\"\n117\tsource \"$SCRIPT_DIR/../Kernel/decision_engine.sh\"\n118\t\n119\tparse_args \"$@\"\n120\t\n121\t# ==============================================================================================\n122\t# ━━━ Setup ━━━\n123\t# ==============================================================================================\n124\tif [[ \"$EUID\" -ne 0 ]]; then\n125\t error \"Must be run as root\"\n126\t exit 1\n127\tfi\n128\t\n129\tacquire_lock\n130\tdetect_hosts\n131\t\n132\tif ! command -v jq >/dev/null 2>&1; then\n133\t error \"jq not installed — required for API JSON parsing\"\n134\t exit 1\n135\tfi\n136\t\n137\tif [[ -z \"${EMBY_URL:-}\" || -z \"${EMBY_API_KEY:-}\" ]]; then\n138\t error \"EMBY_URL / EMBY_API_KEY not configured — check host*.conf\"\n139\t exit 1\n140\tfi\n141\t\n142\tif [[ -z \"${LIDARR_URL:-}\" || -z \"${LIDARR_API_KEY:-}\" ]]; then\n143\t error \"LIDARR_URL / LIDARR_API_KEY not configured — check host*.conf\"\n144\t exit 1\n145\tfi\n146\t\n147\tif [[ -z \"${LASTFM_API_KEY:-}\" ]]; then\n148\t error \"LASTFM_API_KEY not configured — required for discovery\"\n149\t error \"Configure HOST*_LASTFM_API_KEY in host*.conf\"\n150\t exit 1\n151\tfi\n152\t\n153\tTHRESHOLD=\"${LIDARR_DISCOVERY_THRESHOLD:-70}\"\n154\tLOOKBACK_DAYS=\"${LIDARR_DISCOVERY_LOOKBACK_DAYS:-7}\"\n155\tMIN_PLAYS=\"${LIDARR_DISCOVERY_MIN_PLAYS:-3}\"\n156\tMAX_ADDS=\"${LIDARR_DISCOVERY_MAX_ADDS:-5}\"\n157\tUSER_CAP_PCT=\"${LIDARR_DISCOVERY_USER_CAP_PCT:-35}\"\n158\tREJECT_COOLDOWN=\"${LIDARR_DISCOVERY_REJECT_COOLDOWN:-30}\"\n159\tHISTORY_FILE=\"${LIDARR_DISCOVERY_HISTORY:-${DATA_DIR}/lidarr_discovery_history.db}\"\n160\t\n161\tlog \"$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\"\n162\t\n163\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no artists will be added to Lidarr\"\n164\t\n165\t# ==============================================================================================\n166\t# ━━━ Status ━━━\n167\t# ==============================================================================================\n168\tif [[ \"$SHOW_STATUS\" == true ]]; then\n169\t echo \"\"\n170\t echo \"━━━━━ $ICON_SUMMARY LIDARR DISCOVERY STATUS ━━━━━\"\n171\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n172\t echo \"$ICON_SYNC Emby: ${EMBY_URL}\"\n173\t echo \"$ICON_SYNC Lidarr: ${LIDARR_URL}\"\n174\t echo \"$ICON_GEAR Threshold: ${THRESHOLD} / 100 (both stages)\"\n175\t echo \"$ICON_TIME Lookback: ${LOOKBACK_DAYS} days\"\n176\t echo \"$ICON_GEAR Min plays: ${MIN_PLAYS}\"\n177\t echo \"$ICON_GEAR Max adds: ${MAX_ADDS} (seeds in Stage 1, adds in Stage 2)\"\n178\t echo \"$ICON_GEAR User cap: ${USER_CAP_PCT}% max per user (floor: 3 plays)\"\n179\t echo \"$ICON_GEAR Reject TTL: ${REJECT_COOLDOWN} days\"\n180\t echo \"$ICON_GEAR History file: ${HISTORY_FILE}\"\n181\t echo \"$ICON_GEAR Last.fm: configured\"\n182\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n183\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n184\t exit 0\n185\tfi\n186\t\n187\t# ==============================================================================================\n188\t# ── API HELPERS ───────────────────────────────────────────────────────────────────────────────\n189\t# ==============================================================================================\n190\t\n191\t\n192\t_lidarr_get() {\n193\t curl -sf --max-time 20 \\\n194\t -H \"X-Api-Key: $LIDARR_API_KEY\" \\\n195\t \"${LIDARR_URL}/api/v1/${1}\" 2>/dev/null\n196\t}\n197\t\n198\t_lidarr_lookup() {\n199\t curl -sf --max-time 20 --get \\\n200\t --data-urlencode \"term=$1\" \\\n201\t -H \"X-Api-Key: $LIDARR_API_KEY\" \\\n202\t \"${LIDARR_URL}/api/v1/artist/lookup\" 2>/dev/null\n203\t}\n204\t\n205\t_lidarr_post() {\n206\t curl -sf --max-time 20 -X POST \\\n207\t -H \"X-Api-Key: $LIDARR_API_KEY\" \\\n208\t -H \"Content-Type: application/json\" \\\n209\t -d \"$1\" \\\n210\t \"${LIDARR_URL}/api/v1/artist\" 2>/dev/null\n211\t}\n212\t\n213\t_lastfm_info() {\n214\t curl -sf --max-time 10 --get \\\n215\t --data-urlencode \"method=artist.getinfo\" \\\n216\t --data-urlencode \"artist=$1\" \\\n217\t --data-urlencode \"api_key=$LASTFM_API_KEY\" \\\n218\t --data-urlencode \"format=json\" \\\n219\t \"http://ws.audioscrobbler.com/2.0/\" 2>/dev/null\n220\t}\n221\t\n222\t_lastfm_similar() {\n223\t curl -sf --max-time 15 --get \\\n224\t --data-urlencode \"method=artist.getSimilar\" \\\n225\t --data-urlencode \"artist=$1\" \\\n226\t --data-urlencode \"limit=10\" \\\n227\t --data-urlencode \"api_key=$LASTFM_API_KEY\" \\\n228\t --data-urlencode \"format=json\" \\\n229\t \"http://ws.audioscrobbler.com/2.0/\" 2>/dev/null\n230\t}\n231\t\n232\t# ==============================================================================================\n233\t# ── SCORING HELPERS ───────────────────────────────────────────────────────────────────────────\n234\t# ==============================================================================================\n235\t\n236\t_user_score() {\n237\t local s=$(( $1 * 5 ))\n238\t (( s > 40 )) && s=40\n239\t echo \"$s\"\n240\t}\n241\t\n242\t_recency_score() {\n243\t local days=\"$1\"\n244\t if (( days == 0 )); then echo 30\n245\t elif (( days == 1 )); then echo 25\n246\t elif (( days == 2 )); then echo 20\n247\t elif (( days == 3 )); then echo 15\n248\t elif (( days == 4 )); then echo 10\n249\t elif (( days == 5 )); then echo 8\n250\t else echo 5\n251\t fi\n252\t}\n253\t\n254\t_affinity_score() {\n255\t local s=$(( $1 / 20 ))\n256\t (( s > 40 )) && s=40\n257\t echo \"$s\"\n258\t}\n259\t\n260\t_breadth_score() {\n261\t local seeds=\"$1\"\n262\t if (( seeds >= 3 )); then echo 30\n263\t elif (( seeds == 2 )); then echo 18\n264\t else echo 5\n265\t fi\n266\t}\n267\t\n268\t_popularity_score() {\n269\t local listeners=\"$1\"\n270\t if (( listeners >= 5000000 )); then echo 20\n271\t elif (( listeners >= 1000000 )); then echo 15\n272\t elif (( listeners >= 500000 )); then echo 10\n273\t elif (( listeners >= 100000 )); then echo 5\n274\t else echo 2\n275\t fi\n276\t}\n277\t\n278\t_quality_score() {\n279\t local playcount=\"$1\"\n280\t if (( playcount >= 100000000 )); then echo 10\n281\t elif (( playcount >= 10000000 )); then echo 7\n282\t elif (( playcount >= 1000000 )); then echo 4\n283\t else echo 2\n284\t fi\n285\t}\n286\t\n287\t_is_placeholder_artist() {\n288\t local a=\"${1,,}\"\n289\t [[ \"$a\" =~ ^(va|various|various artists|unknown artist|unknown|soundtrack|original soundtrack|ost)$ ]]\n290\t}\n291\t\n292\t_lfm_scores() {\n293\t local artist=\"$1\"\n294\t local LFM_JSON lfm_listeners lfm_playcount\n295\t LFM_JSON=$(_lastfm_info \"$artist\")\n296\t if [[ -n \"$LFM_JSON\" ]] && echo \"$LFM_JSON\" | jq -e '.artist' >/dev/null 2>&1; then\n297\t lfm_listeners=$(echo \"$LFM_JSON\" | jq -r '.artist.stats.listeners // \"0\"' | tr -d ',')\n298\t lfm_playcount=$(echo \"$LFM_JSON\" | jq -r '.artist.stats.playcount // \"0\"' | tr -d ',')\n299\t lfm_listeners=${lfm_listeners//[^0-9]/}; lfm_listeners=${lfm_listeners:-0}\n300\t lfm_playcount=${lfm_playcount//[^0-9]/}; lfm_playcount=${lfm_playcount:-0}\n301\t echo \"${lfm_listeners}|${lfm_playcount}\"\n302\t else\n303\t echo \"0|0\"\n304\t fi\n305\t}\n306\t\n307\t# ==============================================================================================\n308\t# ━━━ Fetch Emby Play History ━━━\n309\t# ==============================================================================================\n310\techo \"\"\n311\techo \"━━━━━ $ICON_SUMMARY Lidarr Discovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━\"\n312\techo \"$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) | lookback: ${LOOKBACK_DAYS}d | threshold: ${THRESHOLD}/100 | max: ${MAX_ADDS}\"\n313\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no artists will be added\"\n314\t\n315\techo \"\"\n316\techo \"━━━ $ICON_SYNC Emby Play History ━━━\"\n317\t\n318\tCUTOFF_ISO=$(date -d \"${LOOKBACK_DAYS} days ago\" '+%Y-%m-%dT%H:%M:%SZ')\n319\tTODAY_EPOCH=$(date +%s)\n320\tTODAY=$(date +%Y-%m-%d)\n321\t\n322\tACTIVITY_JSON=$(emby_api \"System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000\") || {\n323\t error \"Could not fetch Emby activity log\"\n324\t exit 1\n325\t}\n326\t\n327\tdeclare -A ARTIST_PLAYS\n328\tdeclare -A ARTIST_LAST_PLAY\n329\tdeclare -A ARTIST_USER_PLAYS\n330\tdeclare -A ARTIST_EFFECTIVE # precomputed effective plays after user cap\n331\tTOTAL_PLAYS=0\n332\t\n333\twhile IFS='|' read -r play_date artist user_id; do\n334\t [[ -z \"$artist\" || \"$artist\" == \"null\" ]] && continue\n335\t _is_placeholder_artist \"$artist\" && continue\n336\t\n337\t (( TOTAL_PLAYS++ ))\n338\t ARTIST_PLAYS[\"$artist\"]=$(( ${ARTIST_PLAYS[\"$artist\"]:-0} + 1 ))\n339\t ARTIST_USER_PLAYS[\"${artist}|${user_id}\"]=$(( ${ARTIST_USER_PLAYS[\"${artist}|${user_id}\"]:-0} + 1 ))\n340\t\n341\t current=\"${ARTIST_LAST_PLAY[\"$artist\"]:-}\"\n342\t if [[ -z \"$current\" || \"$play_date\" > \"$current\" ]]; then\n343\t ARTIST_LAST_PLAY[\"$artist\"]=\"$play_date\"\n344\t fi\n345\tdone < <(echo \"$ACTIVITY_JSON\" | jq -r '\n346\t .Items // [] | .[] |\n347\t select(.Name | test(\"has finished playing\"; \"i\")) |\n348\t select(.Name | test(\" - \")) |\n349\t select(.Name | test(\", Ep[0-9]\") | not) |\n350\t [\n351\t .Date,\n352\t (\n353\t .Name |\n354\t split(\" has finished playing \")[1] |\n355\t split(\" on \") | .[0:-1] | join(\" on \") |\n356\t split(\" - \")[0] | ltrimstr(\" \") | rtrimstr(\" \")\n357\t ),\n358\t (.UserId // \"unknown\")\n359\t ] | join(\"|\")\n360\t' 2>/dev/null)\n361\t\n362\t# Precompute effective plays (user cap applied) for all qualifying artists\n363\tfor _a in \"${!ARTIST_PLAYS[@]}\"; do\n364\t _plays=\"${ARTIST_PLAYS[\"$_a\"]}\"\n365\t (( _plays < MIN_PLAYS )) && continue\n366\t _cap=$(( (_plays * USER_CAP_PCT + 99) / 100 ))\n367\t (( _cap < 3 )) && _cap=3\n368\t _eff=0\n369\t for _ukey in \"${!ARTIST_USER_PLAYS[@]}\"; do\n370\t [[ \"$_ukey\" == \"${_a}|\"* ]] || continue\n371\t _uc=\"${ARTIST_USER_PLAYS[\"$_ukey\"]}\"\n372\t (( _eff += _uc < _cap ? _uc : _cap ))\n373\t done\n374\t (( _eff == 0 )) && _eff=\"$_plays\"\n375\t ARTIST_EFFECTIVE[\"$_a\"]=\"$_eff\"\n376\tdone\n377\t\n378\tUNIQUE_ARTISTS=${#ARTIST_PLAYS[@]}\n379\tQUALIFIED=$(( $(echo \"${!ARTIST_EFFECTIVE[@]}\" | wc -w) ))\n380\tlog \"$TOTAL_PLAYS play completions | $UNIQUE_ARTISTS unique artists | $QUALIFIED qualify (${MIN_PLAYS}+ plays)\"\n381\t\n382\tif [[ \"$QUALIFIED\" -eq 0 ]]; then\n383\t warn \"No artists qualify this week — nothing to evaluate\"\n384\t exit 0\n385\tfi\n386\t\n387\t# ==============================================================================================\n388\t# ━━━ Stage 1: Score Played Artists → Select Seeds ━━━\n389\t# ==============================================================================================\n390\techo \"\"\n391\techo \"━━━ $ICON_GEAR Stage 1: Scoring Played Artists ━━━\"\n392\t\n393\tS1_SCORED=() # \"score|artist\"\n394\t\n395\tfor artist in \"${!ARTIST_EFFECTIVE[@]}\"; do\n396\t effective=\"${ARTIST_EFFECTIVE[\"$artist\"]}\"\n397\t last_played=\"${ARTIST_LAST_PLAY[\"$artist\"]}\"\n398\t last_epoch=$(date -d \"$last_played\" +%s 2>/dev/null || echo \"$TODAY_EPOCH\")\n399\t days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))\n400\t\n401\t user_s=$(_user_score \"$effective\")\n402\t recency_s=$(_recency_score \"$days_ago\")\n403\t\n404\t IFS='|' read -r lfm_listeners lfm_playcount <<< \"$(_lfm_scores \"$artist\")\"\n405\t popularity_s=$(_popularity_score \"$lfm_listeners\")\n406\t quality_s=$(_quality_score \"$lfm_playcount\")\n407\t\n408\t total=$(score_candidate \"$user_s\" \"$popularity_s\" \"$recency_s\" \"$quality_s\")\n409\t total=$(apply_temporal_decay \"$total\" \"$days_ago\")\n410\t\n411\t log \" [${total}] $artist (plays: ${ARTIST_PLAYS[\"$artist\"]}→${effective} | ${days_ago}d ago)\"\n412\t S1_SCORED+=(\"${total}|${artist}\")\n413\tdone\n414\t\n415\tIFS=$'\\n' S1_SORTED=($(printf '%s\\n' \"${S1_SCORED[@]}\" | sort -t'|' -k1 -rn 2>/dev/null))\n416\t\n417\techo \"\"\n418\techo \"━━━ $ICON_SUMMARY Stage 1 Results (top ${MAX_ADDS} seeds) ━━━\"\n419\t\n420\tSEEDS=()\n421\tSEED_SCORES=() # parallel array: score for each seed (used as Stage 2 weight)\n422\t\n423\tfor _entry in \"${S1_SORTED[@]}\"; do\n424\t (( ${#SEEDS[@]} >= MAX_ADDS )) && break\n425\t _score=\"${_entry%%|*}\"\n426\t _artist=\"${_entry#*|}\"\n427\t (( _score < THRESHOLD )) && break # sorted desc — below threshold means rest are too\n428\t SEEDS+=(\"$_artist\")\n429\t SEED_SCORES+=(\"$_score\")\n430\t printf \" Seed [%3s] %s (plays: %s)\\n\" \"$_score\" \"$_artist\" \"${ARTIST_PLAYS[\"$_artist\"]}\"\n431\tdone\n432\t\n433\tif [[ \"${#SEEDS[@]}\" -eq 0 ]]; then\n434\t warn \"No played artists scored above ${THRESHOLD} this week — no seeds for discovery\"\n435\t exit 0\n436\tfi\n437\t\n438\tlog \"${#SEEDS[@]} seed(s) selected\"\n439\t\n440\t# ==============================================================================================\n441\t# ━━━ Stage 2: Last.fm Similarity Discovery ━━━\n442\t# ==============================================================================================\n443\techo \"\"\n444\techo \"━━━ $ICON_SYNC Stage 2: Last.fm Similarity Discovery ━━━\"\n445\t\n446\tdeclare -A CANDIDATE_SCORE\n447\tdeclare -A CANDIDATE_SEED_COUNT\n448\tdeclare -A CANDIDATE_LAST_SEED\n449\t\n450\tfor (( _i=0; _i<${#SEEDS[@]}; _i++ )); do\n451\t seed_artist=\"${SEEDS[$_i]}\"\n452\t seed_score=\"${SEED_SCORES[$_i]}\"\n453\t seed_last_play=\"${ARTIST_LAST_PLAY[\"$seed_artist\"]}\"\n454\t\n455\t log \" Seed: $seed_artist (score: $seed_score)\"\n456\t\n457\t SIMILAR_JSON=$(_lastfm_similar \"$seed_artist\")\n458\t if [[ -z \"$SIMILAR_JSON\" ]]; then\n459\t warn \" Last.fm similar unavailable for: $seed_artist\"\n460\t continue\n461\t fi\n462\t\n463\t while IFS='|' read -r sim_name sim_match; do\n464\t [[ -z \"$sim_name\" || \"$sim_name\" == \"null\" ]] && continue\n465\t _is_placeholder_artist \"$sim_name\" && continue\n466\t [[ \"${sim_name,,}\" == \"${seed_artist,,}\" ]] && continue\n467\t\n468\t sim_pct=$(echo \"$sim_match\" | awk '{printf \"%d\", $1 * 100 + 0.5}')\n469\t (( sim_pct < 1 )) && sim_pct=1\n470\t\n471\t # Weight contribution by seed's Stage 1 score, not raw play count\n472\t contribution=$(( seed_score * sim_pct ))\n473\t CANDIDATE_SCORE[\"$sim_name\"]=$(( ${CANDIDATE_SCORE[\"$sim_name\"]:-0} + contribution ))\n474\t CANDIDATE_SEED_COUNT[\"$sim_name\"]=$(( ${CANDIDATE_SEED_COUNT[\"$sim_name\"]:-0} + 1 ))\n475\t\n476\t existing_date=\"${CANDIDATE_LAST_SEED[\"$sim_name\"]:-}\"\n477\t if [[ -z \"$existing_date\" || \"$seed_last_play\" > \"$existing_date\" ]]; then\n478\t CANDIDATE_LAST_SEED[\"$sim_name\"]=\"$seed_last_play\"\n479\t fi\n480\t done < <(echo \"$SIMILAR_JSON\" | jq -r '\n481\t .similarartists.artist // [] | .[] |\n482\t [.name, .match] | join(\"|\")\n483\t ' 2>/dev/null)\n484\tdone\n485\t\n486\tCANDIDATE_COUNT=${#CANDIDATE_SCORE[@]}\n487\tlog \"$CANDIDATE_COUNT discovery candidates from Last.fm\"\n488\t\n489\tif [[ \"$CANDIDATE_COUNT\" -eq 0 ]]; then\n490\t warn \"No candidates returned from Last.fm — check API key or seed artist names\"\n491\t exit 0\n492\tfi\n493\t\n494\t# ==============================================================================================\n495\t# ━━━ Fetch Existing Libraries ━━━\n496\t# ==============================================================================================\n497\techo \"\"\n498\techo \"━━━ $ICON_SYNC Existing Libraries ━━━\"\n499\t\n500\tLIDARR_ARTISTS_JSON=$(_lidarr_get \"artist\") || { error \"Could not fetch Lidarr artists\"; exit 1; }\n501\tLIDARR_NAMES=$(echo \"$LIDARR_ARTISTS_JSON\" | jq -r '.[].artistName' 2>/dev/null)\n502\tLIDARR_COUNT=$(echo \"$LIDARR_NAMES\" | grep -c . 2>/dev/null || echo 0)\n503\tlog \"$LIDARR_COUNT artists in Lidarr\"\n504\t\n505\tEMBY_LIBRARY_JSON=$(emby_api \"Items?IncludeItemTypes=MusicAlbum&Recursive=true&Fields=AlbumArtists&Limit=10000\") || {\n506\t warn \"Could not fetch Emby artist library — skipping Emby filter\"\n507\t EMBY_ARTIST_NAMES=\"\"\n508\t}\n509\tEMBY_ARTIST_NAMES=$(echo \"$EMBY_LIBRARY_JSON\" | jq -r '.Items[] | .AlbumArtists[]?.Name' 2>/dev/null)\n510\tEMBY_ARTIST_COUNT=$(echo \"$EMBY_ARTIST_NAMES\" | grep -c . 2>/dev/null || echo 0)\n511\tlog \"$EMBY_ARTIST_COUNT album artists in Emby library\"\n512\t\n513\t_in_lidarr() { echo \"$LIDARR_NAMES\" | grep -iq \"^${1}$\"; }\n514\t_in_emby_library(){ [[ -n \"$EMBY_ARTIST_NAMES\" ]] && echo \"$EMBY_ARTIST_NAMES\" | grep -iq \"^${1}$\"; }\n515\t\n516\t# ==============================================================================================\n517\t# ━━━ Score Stage 2 Candidates ━━━\n518\t# ==============================================================================================\n519\techo \"\"\n520\techo \"━━━ $ICON_GEAR Stage 2: Scoring Candidates ━━━\"\n521\t\n522\tACCEPT_LIST=()\n523\tREJECT_LIST=()\n524\tALREADY_KNOWN=0\n525\tSKIP_COOLDOWN=0\n526\t\n527\tfor candidate in \"${!CANDIDATE_SCORE[@]}\"; do\n528\t raw_score=\"${CANDIDATE_SCORE[\"$candidate\"]}\"\n529\t seed_count=\"${CANDIDATE_SEED_COUNT[\"$candidate\"]}\"\n530\t last_seed_date=\"${CANDIDATE_LAST_SEED[\"$candidate\"]:-}\"\n531\t\n532\t if _in_lidarr \"$candidate\"; then\n533\t (( ALREADY_KNOWN++ ))\n534\t log \" $ICON_SKIP In Lidarr: $candidate\"\n535\t continue\n536\t fi\n537\t\n538\t if _in_emby_library \"$candidate\"; then\n539\t (( ALREADY_KNOWN++ ))\n540\t log \" $ICON_SKIP In Emby: $candidate\"\n541\t continue\n542\t fi\n543\t\n544\t if [[ -f \"$HISTORY_FILE\" ]]; then\n545\t last_rejection=$(grep -i \"^REJECT|${candidate}|\" \"$HISTORY_FILE\" 2>/dev/null | tail -1 | cut -d'|' -f3)\n546\t if [[ -n \"$last_rejection\" ]]; then\n547\t last_epoch=$(date -d \"$last_rejection\" +%s 2>/dev/null || echo 0)\n548\t days_since=$(( (TODAY_EPOCH - last_epoch) / 86400 ))\n549\t if (( days_since < REJECT_COOLDOWN )); then\n550\t (( SKIP_COOLDOWN++ ))\n551\t log \" $ICON_SKIP Cooldown (rejected ${days_since}d ago): $candidate\"\n552\t continue\n553\t fi\n554\t fi\n555\t fi\n556\t\n557\t if [[ -n \"$last_seed_date\" ]]; then\n558\t last_epoch=$(date -d \"$last_seed_date\" +%s 2>/dev/null || echo \"$TODAY_EPOCH\")\n559\t days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))\n560\t else\n561\t days_ago=0\n562\t fi\n563\t\n564\t affinity_s=$(_affinity_score \"$raw_score\")\n565\t breadth_s=$(_breadth_score \"$seed_count\")\n566\t\n567\t IFS='|' read -r lfm_listeners lfm_playcount <<< \"$(_lfm_scores \"$candidate\")\"\n568\t if (( lfm_listeners > 0 || lfm_playcount > 0 )); then\n569\t lfm_label=\"${lfm_listeners} lfm listeners\"\n570\t popularity_s=$(_popularity_score \"$lfm_listeners\")\n571\t quality_s=$(_quality_score \"$lfm_playcount\")\n572\t else\n573\t lfm_label=\"not on Last.fm\"\n574\t popularity_s=0\n575\t quality_s=0\n576\t fi\n577\t\n578\t total=$(score_candidate \"$affinity_s\" \"$popularity_s\" \"$breadth_s\" \"$quality_s\")\n579\t total=$(apply_temporal_decay \"$total\" \"$days_ago\")\n580\t decision=$(make_decision \"$total\" \"$THRESHOLD\")\n581\t\n582\t entry=\"${total}|${candidate}|${seed_count}|${affinity_s}|${lfm_label}\"\n583\t [[ \"$decision\" == \"ACCEPT\" ]] && ACCEPT_LIST+=(\"$entry\") || REJECT_LIST+=(\"$entry\")\n584\tdone\n585\t\n586\t# ==============================================================================================\n587\t# ━━━ Results ━━━\n588\t# ==============================================================================================\n589\techo \"\"\n590\techo \"━━━ $ICON_SUMMARY Stage 2 Results (threshold: ${THRESHOLD}, max: ${MAX_ADDS}) ━━━\"\n591\t\n592\tIFS=$'\\n' _ALL_ACCEPTS=($(printf '%s\\n' \"${ACCEPT_LIST[@]}\" | sort -t'|' -k1 -rn 2>/dev/null))\n593\tIFS=$'\\n' SORTED_REJECTS=($(printf '%s\\n' \"${REJECT_LIST[@]}\" | sort -t'|' -k1 -rn 2>/dev/null))\n594\t\n595\t# Cap to MAX_ADDS — overflow goes to reject history so they don't resurface for REJECT_COOLDOWN days\n596\tSORTED_ACCEPTS=(\"${_ALL_ACCEPTS[@]:0:$MAX_ADDS}\")\n597\tfor (( _i=MAX_ADDS; _i<${#_ALL_ACCEPTS[@]}; _i++ )); do\n598\t SORTED_REJECTS+=(\"${_ALL_ACCEPTS[$_i]}\")\n599\tdone\n600\t\n601\t_print_row() {\n602\t local label=\"$1\" entry=\"$2\"\n603\t IFS='|' read -r score name seeds affinity lfm <<< \"$entry\"\n604\t printf \" %-8s [%3s] %-40s seeds: %s | affinity: %2s | %s\\n\" \\\n605\t \"$label\" \"$score\" \"$name\" \"$seeds\" \"$affinity\" \"$lfm\"\n606\t}\n607\t\n608\tfor entry in \"${SORTED_ACCEPTS[@]}\"; do [[ -n \"$entry\" ]] && _print_row \"ACCEPT\" \"$entry\"; done\n609\tfor entry in \"${SORTED_REJECTS[@]}\"; do [[ -n \"$entry\" ]] && _print_row \"REJECT\" \"$entry\"; done\n610\t\n611\techo \"\"\n612\techo \" Already in library: $ALREADY_KNOWN | Cooldown: $SKIP_COOLDOWN\"\n613\techo \" ACCEPT: ${#SORTED_ACCEPTS[@]} | REJECT: ${#SORTED_REJECTS[@]}\"\n614\t\n615\tif [[ \"$DRY_RUN\" == true ]]; then\n616\t warn \"DRY RUN complete — run without --dry-run to add accepted artists\"\n617\t exit 0\n618\tfi\n619\t\n620\tif [[ \"${#SORTED_ACCEPTS[@]}\" -eq 0 ]]; then\n621\t log \"No artists above threshold — nothing to add\"\n622\t for entry in \"${SORTED_REJECTS[@]}\"; do\n623\t [[ -z \"$entry\" ]] && continue\n624\t IFS='|' read -r score name _ <<< \"$entry\"\n625\t echo \"REJECT|${name}|${TODAY}\" >> \"$HISTORY_FILE\" 2>/dev/null\n626\t done\n627\t exit 0\n628\tfi\n629\t\n630\t# ==============================================================================================\n631\t# ━━━ Add to Lidarr ━━━\n632\t# ==============================================================================================\n633\techo \"\"\n634\techo \"━━━ $ICON_SYNC Adding to Lidarr ━━━\"\n635\t\n636\tLIDARR_ROOT=$(_lidarr_get \"rootfolder\" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null)\n637\tif [[ -z \"$LIDARR_ROOT\" ]]; then error \"Could not determine Lidarr root folder\"; exit 1; fi\n638\t\n639\tQUALITY_PROFILES=$(_lidarr_get \"qualityprofile\") || { error \"Could not fetch quality profiles\"; exit 1; }\n640\tMETADATA_PROFILES=$(_lidarr_get \"metadataprofile\") || { error \"Could not fetch metadata profiles\"; exit 1; }\n641\t\n642\tDEFAULT_QUALITY_ID=$(echo \"$QUALITY_PROFILES\" | jq -r '.[0].id' 2>/dev/null)\n643\tDEFAULT_METADATA_ID=$(echo \"$METADATA_PROFILES\" | jq -r '\n644\t first(.[] | select(.name | test(\"Standard\"; \"i\")) | .id) // .[0].id' 2>/dev/null)\n645\t\n646\tlog \"Quality profile: $DEFAULT_QUALITY_ID | Metadata profile: $DEFAULT_METADATA_ID\"\n647\t\n648\tADDED=0\n649\tFAILED=0\n650\t\n651\tfor entry in \"${SORTED_ACCEPTS[@]}\"; do\n652\t [[ -z \"$entry\" ]] && continue\n653\t IFS='|' read -r score name seeds affinity lfm <<< \"$entry\"\n654\t\n655\t LOOKUP=$(_lidarr_lookup \"$name\")\n656\t\n657\t if [[ -z \"$LOOKUP\" ]] || echo \"$LOOKUP\" | jq -e '. == [] or . == null' >/dev/null 2>&1; then\n658\t warn \" $ICON_WARN No match in Lidarr lookup: $name\"\n659\t echo \"FAIL|${name}|${TODAY}\" >> \"$HISTORY_FILE\" 2>/dev/null\n660\t (( FAILED++ ))\n661\t continue\n662\t fi\n663\t\n664\t ARTIST_DATA=$(echo \"$LOOKUP\" | jq '.[0]' 2>/dev/null)\n665\t MBID=$(echo \"$ARTIST_DATA\" | jq -r '.foreignArtistId // \"\"' 2>/dev/null)\n666\t LIDARR_NAME=$(echo \"$ARTIST_DATA\" | jq -r '.artistName // \"\"' 2>/dev/null)\n667\t\n668\t if [[ -z \"$MBID\" ]]; then\n669\t warn \" $ICON_WARN No MusicBrainz ID for: $name\"\n670\t echo \"FAIL|${name}|${TODAY}\" >> \"$HISTORY_FILE\" 2>/dev/null\n671\t (( FAILED++ ))\n672\t continue\n673\t fi\n674\t\n675\t PAYLOAD=$(echo \"$ARTIST_DATA\" | jq \\\n676\t --arg root \"$LIDARR_ROOT\" \\\n677\t --argjson qid \"$DEFAULT_QUALITY_ID\" \\\n678\t --argjson mid \"$DEFAULT_METADATA_ID\" \\\n679\t '. + {\n680\t rootFolderPath: $root,\n681\t qualityProfileId: $qid,\n682\t metadataProfileId: $mid,\n683\t monitored: true,\n684\t addOptions: {\n685\t monitor: \"all\",\n686\t searchForMissingAlbums: true\n687\t }\n688\t }' 2>/dev/null)\n689\t\n690\t RESULT=$(_lidarr_post \"$PAYLOAD\")\n691\t\n692\t if echo \"$RESULT\" | jq -e '.id' >/dev/null 2>&1; then\n693\t log \" $ICON_DONE Added: $LIDARR_NAME (score: $score | seeds: $seeds)\"\n694\t echo \"ACCEPT|${name}|${TODAY}\" >> \"$HISTORY_FILE\" 2>/dev/null\n695\t (( ADDED++ ))\n696\t else\n697\t warn \" $ICON_WARN Failed to add: $name\"\n698\t log \" $(echo \"$RESULT\" | head -c 200)\"\n699\t echo \"FAIL|${name}|${TODAY}\" >> \"$HISTORY_FILE\" 2>/dev/null\n700\t (( FAILED++ ))\n701\t fi\n702\tdone\n703\t\n704\tfor entry in \"${SORTED_REJECTS[@]}\"; do\n705\t [[ -z \"$entry\" ]] && continue\n706\t IFS='|' read -r score name _ <<< \"$entry\"\n707\t echo \"REJECT|${name}|${TODAY}\" >> \"$HISTORY_FILE\" 2>/dev/null\n708\tdone\n709\t\n710\t# ==============================================================================================\n711\t# ━━━ Summary ━━━\n712\t# ==============================================================================================\n713\techo \"\"\n714\techo \"━━━━━ $ICON_SUMMARY DISCOVERY COMPLETE ━━━━━\"\n715\techo \" $ICON_DONE Added: $ADDED\"\n716\t[[ \"$FAILED\" -gt 0 ]] && echo \" $ICON_WARN Failed: $FAILED\"\n717\techo \" $ICON_SKIP Rejected: ${#SORTED_REJECTS[@]}\"\n718\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n719\t\n720\t[[ \"$ADDED\" -gt 0 ]] && notify \\\n721\t \"$ADDED artist(s) added to Lidarr via discovery on $(hostname)\" \\\n722\t \"Lidarr Discovery\" \"normal\"\n723\t\n724\texit 0\n725\t"}]},"uuid":"0dfdd6f2-25ce-4739-a78c-6da82bafd972","timestamp":"2026-06-14T05:32:15.290Z","sourceToolAssistantUUID":"50d46d9b-0e5b-4eba-9f4e-bdb03aa1e8ae","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"0dfdd6f2-25ce-4739-a78c-6da82bafd972","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01J8FJujGiosui4Wgzmdr9KG","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/playback_aware_radarr_discovery.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"3ddde57b-e5ff-4e4b-add9-ab11080f821a","timestamp":"2026-06-14T05:32:15.430Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"3ddde57b-e5ff-4e4b-add9-ab11080f821a","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Hj7AwjKnrxvvbhETnFyHRc","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/playback_aware_sonarr_discovery.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"8619cd59-ba76-44dd-bfb4-46b7f3853e7c","timestamp":"2026-06-14T05:32:15.753Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"8619cd59-ba76-44dd-bfb4-46b7f3853e7c","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Pdvg6uDB9CckGXiEe2JAcT","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/radarr_cleanup.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"f931f71d-410a-43f8-88c2-0e75cb60edc0","timestamp":"2026-06-14T05:32:15.794Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"f931f71d-410a-43f8-88c2-0e75cb60edc0","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Pdvg6uDB9CckGXiEe2JAcT","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ================================= Radarr Cleanup =============================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Delete orphaned movie files not tracked by Radarr. Queries the API for all\n9\t# tracked movie file paths, walks the library on disk, and removes anything\n10\t# untracked that is old enough to be past the import window. Triggers an Emby\n11\t# library clean after each deletion run so ghost entries disappear immediately.\n12\t#\n13\t# ==============================================================================================\n14\t# OPERATIONAL MODEL\n15\t# ==============================================================================================\n16\t#\n17\t# Every file encountered on disk is classified into one of five categories:\n18\t#\n19\t# TRACKED — Radarr API knows this exact path → leave it alone\n20\t# PROTECTED — matches RADARR_PROTECTED_PATTERNS → never delete\n21\t# ORPHAN — video file, not tracked, older than RADARR_ORPHAN_AGE → delete\n22\t# JUNK — not a video extension, not protected → delete regardless of age\n23\t# RECENT — not tracked, under RADARR_ORPHAN_AGE → skip (may be mid-import)\n24\t#\n25\t# Radarr generates movie artwork (*.jpg), metadata (*.nfo), and manages subtitles\n26\t# (*.srt, *.sub, *.ass) but does NOT include these in its tracked file API response.\n27\t# Without PROTECTED classification these would be deleted — breaking Radarr and\n28\t# Emby metadata display.\n29\t#\n30\t# After deletions: notify_emby_scan() triggers Emby \"Clean Missing Files\" task.\n31\t# Emby removes ghost entries immediately — no user-facing file-not-found errors.\n32\t#\n33\t# ==============================================================================================\n34\t# OPERATIONAL SAFEGUARDS\n35\t# ==============================================================================================\n36\t#\n37\t# Six gates — ALL must pass before any file is touched:\n38\t# 1. Container running and not starting/unhealthy\n39\t# 2. API reachable\n40\t# 3. API version matches RADARR_VERSION_MAJOR in master.conf\n41\t# 4. Movie count > 0\n42\t# 5. Tracked file count > 0\n43\t# 6. Deletion size < RADARR_MAX_DELETE_GB — or --i-know-what-im-doing required\n44\t#\n45\t# acquire_lock \"wait\" — large scans take time, wait for previous run to finish\n46\t# jq + curl validation — exits if either tool missing\n47\t# DOCKER_TIMEOUT — container checks protected against daemon hangs\n48\t# notify_emby_scan() — triggers Emby clean after deletion\n49\t# platform_require_cmd — notify script validated before use\n50\t# Silent by default — orphans/junk warn(), clean library logs silently\n51\t#\n52\t# ==============================================================================================\n53\t# CONFIGURATION\n54\t# ==============================================================================================\n55\t#\n56\t# host*.conf\n57\t#\n58\t# HOST*_RADARR_URL / HOST*_RADARR_API_KEY / HOST*_RADARR_MOVIES_ROOT\n59\t# HOST*_RADARR_PATH_MAP — container path → host path translation\n60\t# All aliased by detect_hosts() — script uses unprefixed names\n61\t#\n62\t# master.conf\n63\t#\n64\t# RADARR_ORPHAN_AGE — days before untracked file eligible for deletion\n65\t# RADARR_MAX_DELETE_GB — require --i-know-what-im-doing above this\n66\t# RADARR_EXTENSIONS — video file extensions for orphan classification\n67\t# RADARR_PROTECTED_PATTERNS — file patterns never deleted\n68\t# RADARR_VERSION_MAJOR — expected Radarr major version for API safety check\n69\t# RADARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)\n70\t# ARR_CLEANUP_STATS — stats file path (read by coffee report)\n71\t#\n72\t# ==============================================================================================\n73\t# RUNTIME MODES\n74\t# ==============================================================================================\n75\t#\n76\t# radarr_cleanup.sh — normal run\n77\t# radarr_cleanup.sh --dry-run — preview, no deletions\n78\t# radarr_cleanup.sh --log — verbose output\n79\t# radarr_cleanup.sh --status — show config and exit\n80\t# radarr_cleanup.sh --i-know-what-im-doing — bypass size threshold\n81\t# radarr_cleanup.sh --i-know-what-im-doing --skip-strike-list — NUCLEAR MODE\n82\t#\n83\t# NUCLEAR MODE: both flags bypass age check AND size threshold. User accepts full\n84\t# responsibility — the flag name is long and annoying by design.\n85\t#\n86\t# ==============================================================================================\n87\t\n88\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n89\t\n90\tsource \"$SCRIPT_DIR/../load_config.sh\"\n91\t\n92\t# ── Special flag pre-processing ───────────────────────────────────────────────────────────────\n93\tI_KNOW=false\n94\tSKIP_STRIKES=false\n95\tFILTERED_ARGS=()\n96\tfor arg in \"$@\"; do\n97\t case \"$arg\" in\n98\t --i-know-what-im-doing) I_KNOW=true ;;\n99\t --skip-strike-list) SKIP_STRIKES=true ;;\n100\t *) FILTERED_ARGS+=(\"$arg\") ;;\n101\t esac\n102\tdone\n103\t\n104\tparse_args \"${FILTERED_ARGS[@]}\"\n105\t\n106\t# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────\n107\tif [[ \"$I_KNOW\" == true ]] && [[ \"$SKIP_STRIKES\" == true ]] && [[ \"$DRY_RUN\" != true ]]; then\n108\t echo \"\"\n109\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n110\t echo \"⚠️ WARNING — NUCLEAR MODE ACTIVE\"\n111\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n112\t echo \" Flags: --i-know-what-im-doing --skip-strike-list\"\n113\t echo \" Strike system: BYPASSED — deletes on first pass\"\n114\t echo \" Size threshold: BYPASSED — no GB limit\"\n115\t echo \" Data recovery: NOT POSSIBLE after deletion\"\n116\t echo \"\"\n117\t echo \" Review --dry-run output before proceeding.\"\n118\t echo \" You have 10 seconds to cancel (Ctrl+C)...\"\n119\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n120\t sleep 10\n121\t echo \" Proceeding...\"\n122\t echo \"\"\n123\tfi\n124\t\n125\t# ==============================================================================================\n126\t# ━━━ Setup ━━━\n127\t# ==============================================================================================\n128\tif [[ \"$EUID\" -ne 0 ]]; then\n129\t error \"Must be run as root\"\n130\t exit 1\n131\tfi\n132\t\n133\tif ! command -v curl >/dev/null 2>&1; then\n134\t error \"curl not found — required for Radarr API calls\"\n135\t exit 1\n136\tfi\n137\t\n138\tif ! command -v jq >/dev/null 2>&1; then\n139\t error \"jq not found — required for JSON parsing\"\n140\t notify \"Radarr cleanup failed on $(hostname) — jq not installed\" \"Radarr Cleanup\" \"warning\"\n141\t exit 1\n142\tfi\n143\t\n144\t\n145\tacquire_lock \"wait\"\n146\tTMP_DIR=\"/tmp/radarr_cleanup_$$\"\n147\tmkdir -p \"$TMP_DIR\"\n148\ttrap \"_release_all_locks; rm -rf $TMP_DIR\" EXIT\n149\t\n150\tif ! command -v docker &>/dev/null; then\n151\t error \"Docker command not found\"\n152\t exit 1\n153\tfi\n154\t\n155\t# detect_hosts() sets MY_ID and aliases RADARR_URL, RADARR_API_KEY, RADARR_MOVIES_ROOT\n156\tdetect_hosts\n157\t\n158\tDOCKER_TIMEOUT=15\n159\tRADARR_CONTAINER=\"Radarr\"\n160\t\n161\t# Build path map from MY_ID's Radarr path map\n162\tdeclare -A ARR_PATH_MAP\n163\tlocal_path_map_var=\"${MY_ID}_RADARR_PATH_MAP\"\n164\teval \"for key in \\\"\\${!${local_path_map_var}[@]}\\\"; do\n165\t ARR_PATH_MAP[\\\"\\$key\\\"]=\\\"\\${${local_path_map_var}[\\$key]}\\\"\n166\tdone\"\n167\t\n168\trequire_var RADARR_URL\n169\trequire_var RADARR_API_KEY\n170\trequire_var RADARR_MOVIES_ROOT\n171\t\n172\tif [[ ! -d \"$RADARR_MOVIES_ROOT\" ]]; then\n173\t error \"Movies root not found: $RADARR_MOVIES_ROOT\"\n174\t notify \"Radarr cleanup failed on $(hostname) — movies root not found: $RADARR_MOVIES_ROOT\" \\\n175\t \"Radarr Cleanup\" \"warning\"\n176\t exit 1\n177\tfi\n178\t\n179\tlog \"$ICON_GEAR Config: url=${RADARR_URL} root=${RADARR_MOVIES_ROOT}\"\n180\techo \" $MY_ID ($LOCAL_SERVER_NAME) — $RADARR_URL\"\n181\t\n182\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no files will be deleted\"\n183\t[[ \"$I_KNOW\" == true ]] && warn \"OVERRIDE — --i-know-what-im-doing active\"\n184\t[[ \"$SKIP_STRIKES\" == true ]] && warn \"OVERRIDE — --skip-strike-list active — age check bypassed\"\n185\t\n186\t# ==============================================================================================\n187\t# ━━━ Status ━━━\n188\t# ==============================================================================================\n189\tif [[ \"$SHOW_STATUS\" == true ]]; then\n190\t echo \"\"\n191\t echo \"━━━━━ $ICON_SUMMARY STATUS ━━━━━\"\n192\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n193\t echo \"$ICON_GEAR Radarr URL: $RADARR_URL\"\n194\t echo \"$ICON_GEAR Movies root: $RADARR_MOVIES_ROOT\"\n195\t echo \"$ICON_TIME Orphan age: ${RADARR_ORPHAN_AGE} days\"\n196\t echo \"$ICON_GEAR Max delete: ${RADARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)\"\n197\t echo \"$ICON_GEAR Radarr ver: v${RADARR_VERSION_MAJOR} expected\"\n198\t echo \"$ICON_GEAR Extensions: ${RADARR_EXTENSIONS[*]}\"\n199\t echo \"$ICON_GEAR Protected patterns: ${RADARR_PROTECTED_PATTERNS[*]}\"\n200\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n201\t echo \"$ICON_GEAR I know: $I_KNOW\"\n202\t echo \"$ICON_GEAR Skip strikes: $SKIP_STRIKES\"\n203\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n204\t exit 0\n205\tfi\n206\t\n207\t# ==============================================================================================\n208\t# ━━━ Safety Layer 1 — Container Health ━━━\n209\t# ==============================================================================================\n210\techo \"\"\n211\techo \"━━━ $ICON_SHIELD Safety Checks ━━━\"\n212\t\n213\tCONTAINER_RUNNING=$(timeout \"$DOCKER_TIMEOUT\" docker inspect -f \\\n214\t '{{.State.Running}}' \"$RADARR_CONTAINER\" 2>/dev/null)\n215\tif [[ \"$CONTAINER_RUNNING\" != \"true\" ]]; then\n216\t error \"$RADARR_CONTAINER is not running — aborting\"\n217\t notify \"Radarr cleanup aborted on $(hostname) — container not running\" \\\n218\t \"Radarr Cleanup\" \"warning\"\n219\t exit 1\n220\tfi\n221\t\n222\tCONTAINER_HEALTH=$(timeout \"$DOCKER_TIMEOUT\" docker inspect -f \\\n223\t '{{.State.Health.Status}}' \"$RADARR_CONTAINER\" 2>/dev/null)\n224\tcase \"$CONTAINER_HEALTH\" in\n225\t healthy) info \"$RADARR_CONTAINER is healthy\" ;;\n226\t \"\") info \"$RADARR_CONTAINER has no health check — proceeding\" ;;\n227\t starting)\n228\t error \"$RADARR_CONTAINER is still starting — aborting\"\n229\t notify \"Radarr cleanup aborted on $(hostname) — container still starting\" \\\n230\t \"Radarr Cleanup\" \"warning\"\n231\t exit 1 ;;\n232\t unhealthy)\n233\t error \"$RADARR_CONTAINER is unhealthy — aborting\"\n234\t notify \"Radarr cleanup aborted on $(hostname) — container unhealthy\" \\\n235\t \"Radarr Cleanup\" \"warning\"\n236\t exit 1 ;;\n237\t *) warn \"$RADARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution\" ;;\n238\tesac\n239\t\n240\tinfo \"Safety layer 1 passed — container healthy\"\n241\t\n242\t# ==============================================================================================\n243\t# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────\n244\t# ==============================================================================================\n245\t\n246\tradarr_api() {\n247\t local endpoint=\"$1\"\n248\t local response http_code body\n249\t\n250\t response=$(curl -sf \\\n251\t --max-time 30 \\\n252\t -H \"X-Api-Key: $RADARR_API_KEY\" \\\n253\t -w \"\\n%{http_code}\" \\\n254\t \"${RADARR_URL}/api/v3/${endpoint}\" 2>/dev/null)\n255\t\n256\t http_code=$(echo \"$response\" | tail -1)\n257\t body=$(echo \"$response\" | head -n -1)\n258\t\n259\t if [[ \"$http_code\" != \"200\" ]]; then\n260\t error \"Radarr API HTTP $http_code for: $endpoint\"\n261\t return 1\n262\t fi\n263\t echo \"$body\"\n264\t}\n265\t\n266\tis_video_file() {\n267\t local ext=\"${1##*.}\"\n268\t ext=\"${ext,,}\"\n269\t for valid_ext in \"${RADARR_EXTENSIONS[@]}\"; do\n270\t [[ \"$ext\" == \"$valid_ext\" ]] && return 0\n271\t done\n272\t return 1\n273\t}\n274\t\n275\tis_protected_file() {\n276\t local filename\n277\t filename=$(basename \"$1\")\n278\t for pattern in \"${RADARR_PROTECTED_PATTERNS[@]}\"; do\n279\t # shellcheck disable=SC2254\n280\t case \"$filename\" in\n281\t $pattern) return 0 ;;\n282\t esac\n283\t done\n284\t return 1\n285\t}\n286\t\n287\tformat_bytes() {\n288\t local bytes=$1\n289\t if (( bytes > 1073741824 )); then\n290\t awk \"BEGIN {printf \\\"%.1fGB\\\", $bytes / 1073741824}\"\n291\t elif (( bytes > 1048576 )); then\n292\t awk \"BEGIN {printf \\\"%.1fMB\\\", $bytes / 1048576}\"\n293\t else\n294\t echo \"${bytes}B\"\n295\t fi\n296\t}\n297\t\n298\t# ==============================================================================================\n299\t# ━━━ Pre-flight: Radarr Import Scan ━━━\n300\t# ==============================================================================================\n301\techo \"\"\n302\techo \"━━━ $ICON_SYNC Pre-flight: Radarr Import Scan ━━━\"\n303\t\n304\t# Fetch root folders from Radarr API and translate container paths to host paths\n305\tmapfile -t SCAN_ROOTS < <(\n306\t radarr_api \"rootfolder\" | \\\n307\t jq -r '.[].path' 2>/dev/null | \\\n308\t while IFS= read -r cp; do translate_path \"$cp\"; done\n309\t)\n310\t\n311\tif [[ \"${#SCAN_ROOTS[@]}\" -eq 0 ]]; then\n312\t error \"No root folders returned from Radarr API — aborting\"\n313\t notify \"Radarr cleanup aborted on $(hostname) — no root folders from API\" \\\n314\t \"Radarr Cleanup\" \"warning\"\n315\t exit 1\n316\tfi\n317\t\n318\tinfo \"Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}\"\n319\t\n320\tinfo \"Triggering DownloadedMoviesScan (all root folders)\"\n321\tSCAN_PAYLOAD='{\"name\": \"DownloadedMoviesScan\"}'\n322\t\n323\tSCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \\\n324\t -H \"X-Api-Key: $RADARR_API_KEY\" \\\n325\t -H \"Content-Type: application/json\" \\\n326\t -d \"$SCAN_PAYLOAD\" \\\n327\t \"${RADARR_URL}/api/v3/command\" 2>/dev/null)\n328\t\n329\tSCAN_CMD_ID=$(echo \"$SCAN_RESPONSE\" | jq -r '.id // empty' 2>/dev/null)\n330\t\n331\tif [[ -z \"$SCAN_CMD_ID\" ]]; then\n332\t warn \"Could not trigger import scan — proceeding without pre-flight\"\n333\telse\n334\t info \"Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion...\"\n335\t POLL_TIMEOUT=${RADARR_IMPORT_SCAN_TIMEOUT:-600}\n336\t POLLED=0\n337\t while [[ \"$POLLED\" -lt \"$POLL_TIMEOUT\" ]]; do\n338\t SCAN_STATUS=$(curl -sf --max-time 10 \\\n339\t -H \"X-Api-Key: $RADARR_API_KEY\" \\\n340\t \"${RADARR_URL}/api/v3/command/${SCAN_CMD_ID}\" 2>/dev/null | \\\n341\t jq -r '.status // empty' 2>/dev/null)\n342\t case \"$SCAN_STATUS\" in\n343\t completed) info \"Import scan complete ✅\"; break ;;\n344\t failed) warn \"Import scan reported failed — proceeding anyway\"; break ;;\n345\t esac\n346\t sleep 10\n347\t (( POLLED += 10 ))\n348\t [[ $(( POLLED % 60 )) -eq 0 ]] && log \" Still scanning... (${POLLED}s elapsed)\"\n349\t done\n350\t [[ \"$POLLED\" -ge \"$POLL_TIMEOUT\" ]] && \\\n351\t warn \"Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway\"\n352\tfi\n353\t\n354\t# ==============================================================================================\n355\t# ━━━ Fetch Radarr Tracked Files ━━━\n356\t# ==============================================================================================\n357\techo \"\"\n358\techo \"━━━ $ICON_SYNC Fetching Radarr Tracked Files ━━━\"\n359\t\n360\t# Safety Layer 2 — API reachability\n361\tif ! check_api \"$RADARR_URL\" \"Radarr\" 10; then\n362\t notify \"Radarr cleanup aborted on $(hostname) — API unreachable\" \"Radarr Cleanup\" \"warning\"\n363\t exit 1\n364\tfi\n365\t\n366\t# Safety Layer 3 — API version check\n367\tcheck_arr_version \"$RADARR_URL\" \"$RADARR_API_KEY\" \"v3\" \"$RADARR_VERSION_MAJOR\" \"Radarr\" || exit 1\n368\t\n369\tinfo \"Querying Radarr API...\"\n370\t\n371\t# Fetch all movies\n372\tMOVIES_RESPONSE=$(radarr_api \"movie\") || {\n373\t error \"Failed to fetch movies from Radarr\"\n374\t notify \"Radarr cleanup failed on $(hostname) — could not fetch movies\" \\\n375\t \"Radarr Cleanup\" \"warning\"\n376\t exit 1\n377\t}\n378\t\n379\tMOVIE_IDS=$(echo \"$MOVIES_RESPONSE\" | jq -r '.[].id' 2>/dev/null)\n380\tMOVIE_COUNT=$(echo \"$MOVIE_IDS\" | grep -c \".\" 2>/dev/null || echo 0)\n381\t\n382\t# Safety Layer 4 — movie count > 0\n383\tif [[ \"$MOVIE_COUNT\" -eq 0 ]]; then\n384\t error \"API returned 0 movies — aborting to prevent mass deletion\"\n385\t notify \"Radarr cleanup aborted on $(hostname) — 0 movies returned\" \\\n386\t \"Radarr Cleanup\" \"warning\"\n387\t exit 1\n388\tfi\n389\t\n390\tinfo \"Found $MOVIE_COUNT movies — fetching movie files...\"\n391\t\n392\tTRACKED_FILE=\"$TMP_DIR/tracked_paths.txt\"\n393\t> \"$TRACKED_FILE\"\n394\t\n395\tMOVIE_INDEX=0\n396\twhile IFS= read -r movie_id; do\n397\t [[ -z \"$movie_id\" ]] && continue\n398\t (( MOVIE_INDEX++ ))\n399\t [[ $(( MOVIE_INDEX % 100 )) -eq 0 ]] && \\\n400\t log \"Fetching files: $MOVIE_INDEX/$MOVIE_COUNT movies...\"\n401\t MOVIE_FILES=$(radarr_api \"moviefile?movieId=${movie_id}\" 2>/dev/null)\n402\t if [[ -n \"$MOVIE_FILES\" ]]; then\n403\t while IFS= read -r api_path; do\n404\t [[ -z \"$api_path\" ]] && continue\n405\t translate_path \"$api_path\" >> \"$TRACKED_FILE\"\n406\t done < <(echo \"$MOVIE_FILES\" | jq -r '.[].path // .path' 2>/dev/null)\n407\t fi\n408\tdone <<< \"$MOVIE_IDS\"\n409\t\n410\tsort -u \"$TRACKED_FILE\" -o \"$TRACKED_FILE\"\n411\t\n412\t# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file\n413\t# Eliminates the main performance bottleneck for large libraries\n414\tdeclare -A TRACKED_MAP\n415\twhile IFS= read -r _tracked_path; do\n416\t [[ -n \"$_tracked_path\" ]] && TRACKED_MAP[\"$_tracked_path\"]=1\n417\tdone < \"$TRACKED_FILE\"\n418\tunset _tracked_path\n419\tinfo \"Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths\"\n420\tTRACKED_COUNT=$(wc -l < \"$TRACKED_FILE\")\n421\t\n422\t# Safety Layer 5 — tracked count > 0\n423\tif [[ \"$TRACKED_COUNT\" -eq 0 ]]; then\n424\t error \"API returned 0 tracked files — aborting to prevent mass deletion\"\n425\t notify \"Radarr cleanup aborted on $(hostname) — 0 tracked files returned\" \\\n426\t \"Radarr Cleanup\" \"warning\"\n427\t exit 1\n428\tfi\n429\t\n430\tinfo \"$MOVIE_COUNT movies | $TRACKED_COUNT tracked movie files\"\n431\t\n432\t# ==============================================================================================\n433\t# ━━━ Scan Movies Root ━━━\n434\t# ==============================================================================================\n435\techo \"\"\n436\techo \"━━━ $ICON_CLEAN Scanning Movies Root ━━━\"\n437\tinfo \"Root: $RADARR_MOVIES_ROOT | Orphan age: ${RADARR_ORPHAN_AGE} days\"\n438\t\n439\tSTART=$(date +%s)\n440\tORPHAN_COUNT=0\n441\tJUNK_COUNT=0\n442\tRECENT_COUNT=0\n443\tPROTECTED_COUNT=0\n444\tORPHAN_BYTES=0\n445\tJUNK_BYTES=0\n446\t\n447\tAGE_SECONDS=$(( RADARR_ORPHAN_AGE * 86400 ))\n448\tNOW=$(date +%s)\n449\tMAX_DELETE_BYTES=$(awk \"BEGIN {printf \\\"%d\\\", $RADARR_MAX_DELETE_GB * 1073741824}\")\n450\t\n451\twhile IFS= read -r filepath; do\n452\t [[ -z \"$filepath\" ]] && continue\n453\t\n454\t if [[ -n \"${TRACKED_MAP[$filepath]:-}\" ]]; then\n455\t log \"TRACKED: $filepath\"\n456\t continue\n457\t fi\n458\t\n459\t if is_protected_file \"$filepath\"; then\n460\t log \"$ICON_PROTECTED PROTECTED: $filepath\"\n461\t (( PROTECTED_COUNT++ ))\n462\t continue\n463\t fi\n464\t\n465\t FILE_SIZE=$(stat -c%s \"$filepath\" 2>/dev/null || echo 0)\n466\t\n467\t if is_video_file \"$filepath\"; then\n468\t FILE_MTIME=$(stat -c %Y \"$filepath\" 2>/dev/null || echo 0)\n469\t FILE_AGE=$(( NOW - FILE_MTIME ))\n470\t\n471\t if [[ \"$FILE_AGE\" -lt \"$AGE_SECONDS\" ]] && [[ \"$SKIP_STRIKES\" != true ]]; then\n472\t log \"RECENT (skipping): $filepath\"\n473\t (( RECENT_COUNT++ ))\n474\t continue\n475\t fi\n476\t\n477\t warn \"$ICON_TRASH ORPHAN: $filepath\"\n478\t (( ORPHAN_COUNT++ ))\n479\t ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))\n480\t else\n481\t log \"JUNK: $filepath\"\n482\t (( JUNK_COUNT++ ))\n483\t JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))\n484\t fi\n485\t\n486\tdone < <(\n487\t for host_path in \"${SCAN_ROOTS[@]}\"; do\n488\t [[ -d \"$host_path\" ]] && find \"$host_path\" -type f 2>/dev/null\n489\t done | sort -u\n490\t)\n491\t\n492\tTOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))\n493\tTOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))\n494\t\n495\t# ==============================================================================================\n496\t# ━━━ Safety Layer 6 — Deletion Size Threshold ━━━\n497\t# ==============================================================================================\n498\tif [[ \"$TOTAL_DELETE_BYTES\" -gt \"$MAX_DELETE_BYTES\" ]]; then\n499\t TOTAL_HUMAN=$(awk \"BEGIN {printf \\\"%.1fGB\\\", $TOTAL_DELETE_BYTES / 1073741824}\")\n500\t if [[ \"$I_KNOW\" != true ]]; then\n501\t echo \"\"\n502\t error \"Deletion would exceed ${RADARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted\"\n503\t error \"Review ORPHAN lines above carefully before proceeding\"\n504\t error \"Rerun with: --i-know-what-im-doing\"\n505\t error \"To also bypass age check: add --skip-strike-list\"\n506\t notify \"Radarr cleanup halted on $(hostname) — ${TOTAL_HUMAN} requires --i-know-what-im-doing\" \\\n507\t \"Radarr Cleanup\" \"warning\"\n508\t exit 1\n509\t else\n510\t warn \"OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing\"\n511\t fi\n512\tfi\n513\t\n514\t# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────\n515\tif [[ \"$DRY_RUN\" == false ]]; then\n516\t while IFS= read -r filepath; do\n517\t [[ -z \"$filepath\" ]] && continue\n518\t [[ -n \"${TRACKED_MAP[$filepath]:-}\" ]] && continue\n519\t is_protected_file \"$filepath\" && continue\n520\t\n521\t FILE_MTIME=$(stat -c %Y \"$filepath\" 2>/dev/null || echo 0)\n522\t FILE_AGE=$(( NOW - FILE_MTIME ))\n523\t\n524\t if is_video_file \"$filepath\"; then\n525\t [[ \"$FILE_AGE\" -lt \"$AGE_SECONDS\" ]] && \\\n526\t [[ \"$SKIP_STRIKES\" != true ]] && continue\n527\t fi\n528\t\n529\t rm -f \"$filepath\" 2>/dev/null || error \"Failed to delete: $filepath\"\n530\t\n531\t done < <(\n532\t for host_path in \"${SCAN_ROOTS[@]}\"; do\n533\t [[ -d \"$host_path\" ]] && find \"$host_path\" -type f 2>/dev/null\n534\t done | sort -u\n535\t )\n536\t\n537\t info \"Cleaning up empty folders...\"\n538\t for host_path in \"${SCAN_ROOTS[@]}\"; do\n539\t [[ -d \"$host_path\" ]] && \\\n540\t find \"$host_path\" -mindepth 1 -type d -empty -delete 2>/dev/null\n541\t done\n542\t info \"Empty folders removed\"\n543\tfi\n544\t\n545\tEND=$(date +%s)\n546\t\n547\tORPHAN_HUMAN=$(format_bytes \"$ORPHAN_BYTES\")\n548\tJUNK_HUMAN=$(format_bytes \"$JUNK_BYTES\")\n549\t\n550\t# ==============================================================================================\n551\t# ━━━ Summary ━━━\n552\t# ==============================================================================================\n553\techo \"\"\n554\techo \"━━━━━ $ICON_SUMMARY RADARR CLEANUP SUMMARY ━━━━━\"\n555\techo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n556\techo \"$ICON_SYNC Tracked: $TRACKED_COUNT files ($MOVIE_COUNT movies)\"\n557\techo \"$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles, metadata)\"\n558\techo \"$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)\"\n559\techo \"$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)\"\n560\techo \"$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${RADARR_ORPHAN_AGE} days)\"\n561\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n562\techo \"\"\n563\t\n564\tif [[ \"$DRY_RUN\" == true ]]; then\n565\t warn \"DRY RUN — no files deleted\"\n566\telif [[ \"$TOTAL_REMOVED\" -eq 0 ]]; then\n567\t echo \"$ICON_DONE Clean — nothing to remove\"\n568\telse\n569\t warn \"$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)\"\n570\t notify \"Radarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)\" \\\n571\t \"Radarr Cleanup\" \"warning\"\n572\t # Notify Emby to clean missing files — removes ghost entries immediately\n573\t notify_emby_scan\n574\tfi\n575\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n576\t\n577\t# Write stats for sunday_morning_coffee_report.sh\n578\tif [[ \"$DRY_RUN\" == false ]] && [[ -n \"${ARR_CLEANUP_STATS:-}\" ]]; then\n579\t echo \"$(date '+%Y-%m-%d')|radarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}\" \\\n580\t >> \"$ARR_CLEANUP_STATS\" 2>/dev/null || true\n581\tfi\n582\t\n583\texit 0"}]},"uuid":"4348d687-e2ef-45cb-840f-0aee113c1ca0","timestamp":"2026-06-14T05:32:15.801Z","sourceToolAssistantUUID":"f931f71d-410a-43f8-88c2-0e75cb60edc0","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"4348d687-e2ef-45cb-840f-0aee113c1ca0","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018bGpkUGfCkyxu1kxJcDh28","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/radarr_tmdb_removed.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"bca39942-ee58-4c23-83c5-902ea5529b29","timestamp":"2026-06-14T05:32:15.802Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"3ddde57b-e5ff-4e4b-add9-ab11080f821a","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01J8FJujGiosui4Wgzmdr9KG","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# =========================== Playback-Aware Radarr Discovery ==================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Two-stage behavior-driven movie discovery.\n9\t#\n10\t# Stage 1 — Score recently watched movies in Emby. The top movies become\n11\t# high-quality seeds, weighted by recency and TMDB rating.\n12\t#\n13\t# Stage 2 — Run TMDB recommendations on those seeds. Score the candidates\n14\t# and add the top movies to Radarr.\n15\t#\n16\t# Goal: 0–5 meaningful Radarr adds per run, not bulk imports.\n17\t#\n18\t# ==============================================================================================\n19\t# FLOW\n20\t# ==============================================================================================\n21\t#\n22\t# 1. Fetch recently watched movies from Emby (SEED_LIBRARIES, last LOOKBACK_DAYS days)\n23\t# — only movies with a TMDB ID are eligible as seeds\n24\t#\n25\t# ── Stage 1 ─────────────────────────────────────────────────────────────────\n26\t# 2. Score each watched movie: recency + TMDB rating + vote count\n27\t# 3. Take top MAX_SEEDS by score → discovery seeds\n28\t#\n29\t# ── Stage 2 ─────────────────────────────────────────────────────────────────\n30\t# 4. For each seed, call TMDB movie recommendations → collect candidates\n31\t# 5. Aggregate: breadth (distinct seeds recommending this movie)\n32\t# 6. Filter: already in Radarr, already in Emby, below min votes/rating, cooldown\n33\t# 7. Score candidates: breadth + TMDB rating + vote count\n34\t# 8. Take top MAX_ADDS above threshold → add to Radarr\n35\t#\n36\t# ==============================================================================================\n37\t# SCORING MODEL\n38\t# ==============================================================================================\n39\t#\n40\t# Stage 1 — seed selection (max 100)\n41\t# recency_score (0-50) — days since last watch; 0-3d→50, 4-7d→40, 8-14d→30, 15-21d→20, 22-30d→10\n42\t# rating_score (0-30) — TMDB vote_average: 8.0+→30, 7.5+→25, 7.0+→18, 6.5+→12, 6.0+→8, else→3\n43\t# votes_score (0-20) — TMDB vote_count: 10k+→20, 5k+→15, 1k+→10, 200+→5, else→2\n44\t#\n45\t# Stage 2 — candidate scoring (max 100)\n46\t# breadth_score (0-40) — 3+ seeds→40, 2 seeds→25, 1 seed→10\n47\t# rating_score (0-40) — TMDB vote_average: 8.0+→40, 7.5+→32, 7.0+→25, 6.5+→18, 6.0+→12, else→5\n48\t# votes_score (0-20) — TMDB vote_count (same thresholds)\n49\t#\n50\t# Threshold: RADARR_DISCOVERY_THRESHOLD (default 60) — lower than Lidarr since tastes are broader\n51\t# Max adds: RADARR_DISCOVERY_MAX_ADDS (default 5)\n52\t#\n53\t# ==============================================================================================\n54\t# REQUIREMENTS\n55\t# ==============================================================================================\n56\t#\n57\t# TMDB API key — required for Stage 2 recommendations\n58\t# Configure HOST*_TMDB_API_KEY in host*.conf\n59\t# Free key at: https://www.themoviedb.org/settings/api\n60\t#\n61\t# ==============================================================================================\n62\t# CONFIGURATION (master.conf)\n63\t# ==============================================================================================\n64\t#\n65\t# RADARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 52)\n66\t# RADARR_DISCOVERY_LOOKBACK_DAYS — Emby watch history window in days (default: 30)\n67\t# RADARR_DISCOVERY_MAX_SEEDS — max seed movies from Stage 1 (default: 5)\n68\t# RADARR_DISCOVERY_MAX_ADDS — max movies to add per run (default: 5)\n69\t# RADARR_DISCOVERY_MIN_VOTE_COUNT — min TMDB votes for a candidate (default: 100)\n70\t# RADARR_DISCOVERY_MIN_RATING — min TMDB vote_average × 10 (default: 60 = 6.0/10)\n71\t# RADARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a rejected movie (default: 60)\n72\t# RADARR_DISCOVERY_SEED_LIBRARIES — Emby library names to draw seeds from (default: (\"Movies\"))\n73\t# RADARR_DISCOVERY_HISTORY — history/state file path\n74\t#\n75\t# ==============================================================================================\n76\t# RUNTIME MODES\n77\t# ==============================================================================================\n78\t#\n79\t# playback_aware_radarr_discovery.sh — normal run\n80\t# playback_aware_radarr_discovery.sh --dry-run — score and rank, no Radarr changes\n81\t# playback_aware_radarr_discovery.sh --log — verbose output\n82\t# playback_aware_radarr_discovery.sh --status — show config and exit\n83\t#\n84\t# Recommended schedule: weekly (WEEKLY_MAINTENANCE_SCRIPTS in master.conf)\n85\t#\n86\t# ==============================================================================================\n87\t# OPERATIONAL SAFEGUARDS\n88\t# ==============================================================================================\n89\t#\n90\t# Root Required\n91\t# acquire_lock and Radarr API writes require root. Script exits cleanly if not root.\n92\t#\n93\t# Dry-Run Mode\n94\t# --dry-run scores and ranks all candidates but makes no Radarr API calls and does not\n95\t# write to the history file. Safe to run at any time to preview what would be added.\n96\t#\n97\t# Add-Only\n98\t# Only adds movies to Radarr. Never deletes or modifies existing entries.\n99\t#\n100\t# Cooldown Guard\n101\t# Candidates rejected this run are recorded in the history file and not re-evaluated\n102\t# until RADARR_DISCOVERY_REJECT_COOLDOWN days have elapsed.\n103\t#\n104\t# ==============================================================================================\n105\t# STATE FILES\n106\t# ==============================================================================================\n107\t#\n108\t# RADARR_DISCOVERY_HISTORY (default: $DATA_DIR/radarr_discovery_history.db)\n109\t# Tracks added movies and rejected candidates with timestamps. Written after every\n110\t# non-dry-run. Enforces reject cooldown and prevents re-adding items added by previous\n111\t# runs. Safe to delete — next run starts fresh with no memory.\n112\t#\n113\t# ==============================================================================================\n114\t\n115\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n116\t\n117\tsource \"$SCRIPT_DIR/../load_config.sh\"\n118\tsource \"$SCRIPT_DIR/../Kernel/decision_engine.sh\"\n119\t\n120\tparse_args \"$@\"\n121\t\n122\t# ==============================================================================================\n123\t# ━━━ Setup ━━━\n124\t# ==============================================================================================\n125\tif [[ \"$EUID\" -ne 0 ]]; then\n126\t error \"Must be run as root\"\n127\t exit 1\n128\tfi\n129\t\n130\tacquire_lock\n131\tdetect_hosts\n132\t\n133\tif ! command -v jq >/dev/null 2>&1; then\n134\t error \"jq not installed — required for API JSON parsing\"\n135\t exit 1\n136\tfi\n137\t\n138\tif [[ -z \"${EMBY_URL:-}\" || -z \"${EMBY_API_KEY:-}\" ]]; then\n139\t error \"EMBY_URL / EMBY_API_KEY not configured — check host*.conf\"\n140\t exit 1\n141\tfi\n142\t\n143\tif [[ -z \"${RADARR_URL:-}\" || -z \"${RADARR_API_KEY:-}\" ]]; then\n144\t error \"RADARR_URL / RADARR_API_KEY not configured — check host*.conf\"\n145\t exit 1\n146\tfi\n147\t\n148\tif [[ -z \"${TMDB_API_KEY:-}\" ]]; then\n149\t error \"TMDB_API_KEY not configured — required for discovery\"\n150\t error \"Get a free key at https://www.themoviedb.org/settings/api\"\n151\t error \"Configure HOST*_TMDB_API_KEY in host*.conf\"\n152\t exit 1\n153\tfi\n154\t\n155\tTHRESHOLD=\"${RADARR_DISCOVERY_THRESHOLD:-60}\"\n156\tLOOKBACK_DAYS=\"${RADARR_DISCOVERY_LOOKBACK_DAYS:-30}\"\n157\tMAX_SEEDS=\"${RADARR_DISCOVERY_MAX_SEEDS:-5}\"\n158\tMAX_ADDS=\"${RADARR_DISCOVERY_MAX_ADDS:-5}\"\n159\tMIN_VOTE_COUNT=\"${RADARR_DISCOVERY_MIN_VOTE_COUNT:-100}\"\n160\tMIN_RATING=\"${RADARR_DISCOVERY_MIN_RATING:-60}\"\n161\tREJECT_COOLDOWN=\"${RADARR_DISCOVERY_REJECT_COOLDOWN:-60}\"\n162\tHISTORY_FILE=\"${RADARR_DISCOVERY_HISTORY:-${DATA_DIR}/radarr_discovery_history.db}\"\n163\tSEED_LIBRARIES=(\"${RADARR_DISCOVERY_SEED_LIBRARIES[@]:-Movies}\")\n164\t\n165\tlog \"$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\"\n166\t\n167\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no movies will be added to Radarr\"\n168\t\n169\t_fmt_rating() { local v=\"${1:-0}\"; echo \"${v::-1}.${v: -1}\" 2>/dev/null || echo \"$v\"; }\n170\t\n171\t# ==============================================================================================\n172\t# ━━━ Status ━━━\n173\t# ==============================================================================================\n174\tif [[ \"$SHOW_STATUS\" == true ]]; then\n175\t echo \"\"\n176\t echo \"━━━━━ $ICON_SUMMARY RADARR DISCOVERY STATUS ━━━━━\"\n177\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n178\t echo \"$ICON_SYNC Emby: ${EMBY_URL}\"\n179\t echo \"$ICON_SYNC Radarr: ${RADARR_URL}\"\n180\t echo \"$ICON_GEAR Threshold: ${THRESHOLD} / 100\"\n181\t echo \"$ICON_TIME Lookback: ${LOOKBACK_DAYS} days\"\n182\t echo \"$ICON_GEAR Max seeds: ${MAX_SEEDS}\"\n183\t echo \"$ICON_GEAR Max adds: ${MAX_ADDS}\"\n184\t echo \"$ICON_GEAR Min rating: ${MIN_RATING} ($(_fmt_rating \"$MIN_RATING\")/10 TMDB)\"\n185\t echo \"$ICON_GEAR Min votes: ${MIN_VOTE_COUNT}\"\n186\t echo \"$ICON_GEAR Reject TTL: ${REJECT_COOLDOWN} days\"\n187\t echo \"$ICON_GEAR Seed libs: ${SEED_LIBRARIES[*]}\"\n188\t echo \"$ICON_GEAR History file: ${HISTORY_FILE}\"\n189\t echo \"$ICON_GEAR TMDB: configured\"\n190\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n191\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n192\t exit 0\n193\tfi\n194\t\n195\t# ==============================================================================================\n196\t# ── API HELPERS ───────────────────────────────────────────────────────────────────────────────\n197\t# ==============================================================================================\n198\t\n199\t\n200\t_radarr_get() {\n201\t curl -sf --max-time 20 \\\n202\t -H \"X-Api-Key: $RADARR_API_KEY\" \\\n203\t \"${RADARR_URL}/api/v3/${1}\" 2>/dev/null\n204\t}\n205\t\n206\t_radarr_lookup() {\n207\t curl -sf --max-time 20 --get \\\n208\t --data-urlencode \"term=tmdb:${1}\" \\\n209\t -H \"X-Api-Key: $RADARR_API_KEY\" \\\n210\t \"${RADARR_URL}/api/v3/movie/lookup\" 2>/dev/null\n211\t}\n212\t\n213\t_radarr_post() {\n214\t curl -sf --max-time 20 -X POST \\\n215\t -H \"X-Api-Key: $RADARR_API_KEY\" \\\n216\t -H \"Content-Type: application/json\" \\\n217\t -d \"$1\" \\\n218\t \"${RADARR_URL}/api/v3/movie\" 2>/dev/null\n219\t}\n220\t\n221\t_radarr_command() {\n222\t curl -sf --max-time 20 -X POST \\\n223\t -H \"X-Api-Key: $RADARR_API_KEY\" \\\n224\t -H \"Content-Type: application/json\" \\\n225\t -d \"$1\" \\\n226\t \"${RADARR_URL}/api/v3/command\" 2>/dev/null\n227\t}\n228\t\n229\t_tmdb_recommendations() {\n230\t curl -sf --max-time 15 \\\n231\t \"https://api.themoviedb.org/3/movie/${1}/recommendations?api_key=${TMDB_API_KEY}&language=en-US&page=1\" \\\n232\t 2>/dev/null\n233\t}\n234\t\n235\t# ==============================================================================================\n236\t# ── SCORING HELPERS ───────────────────────────────────────────────────────────────────────────\n237\t# ==============================================================================================\n238\t\n239\t_recency_score() {\n240\t local days=\"$1\"\n241\t if (( days <= 3 )); then echo 50\n242\t elif (( days <= 7 )); then echo 40\n243\t elif (( days <= 14 )); then echo 30\n244\t elif (( days <= 21 )); then echo 20\n245\t elif (( days <= 30 )); then echo 10\n246\t else echo 0\n247\t fi\n248\t}\n249\t\n250\t# Stage 1: play frequency score (max 50) — complements recency (max 50) for 100 total\n251\t_freq_score() {\n252\t local c=\"$1\"\n253\t if (( c >= 4 )); then echo 50\n254\t elif (( c >= 2 )); then echo 35\n255\t else echo 20\n256\t fi\n257\t}\n258\t\n259\t# vote_avg_int = vote_average × 10 as integer (e.g. 7.8 → 78)\n260\t_rating_score_s2() {\n261\t local v=\"$1\"\n262\t if (( v >= 80 )); then echo 40\n263\t elif (( v >= 75 )); then echo 32\n264\t elif (( v >= 70 )); then echo 25\n265\t elif (( v >= 65 )); then echo 18\n266\t elif (( v >= 60 )); then echo 12\n267\t else echo 5\n268\t fi\n269\t}\n270\t\n271\t_votes_score() {\n272\t local c=\"$1\"\n273\t if (( c >= 10000 )); then echo 20\n274\t elif (( c >= 5000 )); then echo 15\n275\t elif (( c >= 1000 )); then echo 10\n276\t elif (( c >= 200 )); then echo 5\n277\t else echo 2\n278\t fi\n279\t}\n280\t\n281\t_breadth_score() {\n282\t local seeds=\"$1\"\n283\t if (( seeds >= 3 )); then echo 40\n284\t elif (( seeds == 2 )); then echo 25\n285\t else echo 10\n286\t fi\n287\t}\n288\t\n289\t# ==============================================================================================\n290\t# ━━━ Fetch Emby Libraries + Recently Watched Movies ━━━\n291\t# ==============================================================================================\n292\techo \"\"\n293\techo \"━━━━━ $ICON_SUMMARY Radarr Discovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━\"\n294\techo \"$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) | lookback: ${LOOKBACK_DAYS}d | threshold: ${THRESHOLD}/100 | max: ${MAX_ADDS}\"\n295\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no movies will be added\"\n296\t\n297\techo \"\"\n298\techo \"━━━ $ICON_SYNC Emby Watch History ━━━\"\n299\t\n300\tLIBRARIES_JSON=$(emby_api \"Library/VirtualFolders\") || { error \"Could not fetch Emby libraries\"; exit 1; }\n301\tCUTOFF_ISO=$(date -d \"${LOOKBACK_DAYS} days ago\" '+%Y-%m-%dT%H:%M:%SZ')\n302\tTODAY_EPOCH=$(date +%s)\n303\tTODAY=$(date +%Y-%m-%d)\n304\t\n305\t# Build TMDB index of all movies in Emby — used in Stage 2 to filter already-owned movies\n306\tdeclare -A EMBY_TMDB_IDS # tmdb_id → 1\n307\t\n308\tfor lib_name in \"${SEED_LIBRARIES[@]}\"; do\n309\t lib_id=$(echo \"$LIBRARIES_JSON\" | jq -r --arg n \"$lib_name\" '.[] | select(.Name == $n) | .ItemId' 2>/dev/null)\n310\t if [[ -z \"$lib_id\" ]]; then\n311\t warn \"Emby library not found: $lib_name\"\n312\t continue\n313\t fi\n314\t log \"Indexing library: $lib_name (ItemId: $lib_id)\"\n315\t LIB_JSON=$(emby_api \"Items?ParentId=${lib_id}&IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds&Limit=10000\") || {\n316\t warn \"Could not index library: $lib_name\"\n317\t continue\n318\t }\n319\t while IFS= read -r tmdb_id; do\n320\t [[ -n \"$tmdb_id\" && \"$tmdb_id\" != \"null\" ]] && EMBY_TMDB_IDS[\"$tmdb_id\"]=1\n321\t done < <(echo \"$LIB_JSON\" | jq -r '.Items[].ProviderIds.Tmdb // empty' 2>/dev/null)\n322\tdone\n323\tlog \"${#EMBY_TMDB_IDS[@]} movies in Emby TMDB index\"\n324\t\n325\t# Fetch recent play completions from the server-level activity log.\n326\t# Each entry includes an ItemId — batch-fetch those items to determine type (Movie vs. Music/TV).\n327\t# SortBy=DatePlayed on Items requires UserId context and errors without one; the activity log\n328\t# is server-scoped and doesn't have that limitation.\n329\tACTIVITY_JSON=$(emby_api \"System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000\") || {\n330\t error \"Could not fetch Emby activity log\"\n331\t exit 1\n332\t}\n333\t\n334\tdeclare -A ITEM_PLAYS # emby_item_id → play count\n335\tdeclare -A ITEM_LAST_PLAY # emby_item_id → ISO date of most recent play\n336\t\n337\twhile IFS='|' read -r item_id play_date; do\n338\t [[ -z \"$item_id\" || \"$item_id\" == \"null\" ]] && continue\n339\t ITEM_PLAYS[\"$item_id\"]=$(( ${ITEM_PLAYS[\"$item_id\"]:-0} + 1 ))\n340\t current=\"${ITEM_LAST_PLAY[\"$item_id\"]:-}\"\n341\t if [[ -z \"$current\" || \"$play_date\" > \"$current\" ]]; then\n342\t ITEM_LAST_PLAY[\"$item_id\"]=\"$play_date\"\n343\t fi\n344\tdone < <(echo \"$ACTIVITY_JSON\" | jq -r '\n345\t .Items[] | select(.Type == \"playback.stop\") | select(.ItemId != null and .ItemId != \"\") |\n346\t [.ItemId, .Date] | join(\"|\")\n347\t' 2>/dev/null)\n348\t\n349\tlog \"${#ITEM_PLAYS[@]} unique items in activity log\"\n350\t\n351\tdeclare -A WATCHED_MOVIES # tmdb_id → \"title|play_count|last_play_date\"\n352\t\n353\tif [[ \"${#ITEM_PLAYS[@]}\" -gt 0 ]]; then\n354\t # Fetch in batches of 100 — large ID lists exceed GET URL limits\n355\t ALL_ITEM_IDS=(\"${!ITEM_PLAYS[@]}\")\n356\t BATCH_SIZE=100\n357\t for (( _b=0; _b<${#ALL_ITEM_IDS[@]}; _b+=BATCH_SIZE )); do\n358\t BATCH=(\"${ALL_ITEM_IDS[@]:_b:BATCH_SIZE}\")\n359\t IDS_CSV=$(printf '%s,' \"${BATCH[@]}\"); IDS_CSV=\"${IDS_CSV%,}\"\n360\t ITEMS_DETAIL=$(emby_api \"Items?Ids=${IDS_CSV}&Fields=ProviderIds,Type&Limit=200\") || {\n361\t warn \"Could not fetch item batch starting at $_b\"\n362\t continue\n363\t }\n364\t while IFS='|' read -r item_id item_type tmdb_id title; do\n365\t [[ \"$item_type\" != \"Movie\" ]] && continue\n366\t [[ -z \"$tmdb_id\" || \"$tmdb_id\" == \"null\" ]] && continue\n367\t play_count=\"${ITEM_PLAYS[\"$item_id\"]:-1}\"\n368\t last_play=\"${ITEM_LAST_PLAY[\"$item_id\"]:-}\"\n369\t if [[ -n \"${WATCHED_MOVIES[\"$tmdb_id\"]:-}\" ]]; then\n370\t IFS='|' read -r ex_title ex_plays ex_date <<< \"${WATCHED_MOVIES[\"$tmdb_id\"]}\"\n371\t play_count=$(( ex_plays + play_count ))\n372\t [[ \"$last_play\" > \"$ex_date\" ]] || last_play=\"$ex_date\"\n373\t title=\"$ex_title\"\n374\t fi\n375\t WATCHED_MOVIES[\"$tmdb_id\"]=\"${title}|${play_count}|${last_play}\"\n376\t done < <(echo \"$ITEMS_DETAIL\" | jq -r '.Items[] |\n377\t [(.Id | tostring), .Type, (.ProviderIds.Tmdb // \"\"), .Name] | join(\"|\")\n378\t ' 2>/dev/null)\n379\t done\n380\tfi\n381\t\n382\tWATCHED_COUNT=${#WATCHED_MOVIES[@]}\n383\tlog \"$WATCHED_COUNT movies watched in the last ${LOOKBACK_DAYS} days with TMDB IDs\"\n384\t\n385\tif [[ \"$WATCHED_COUNT\" -eq 0 ]]; then\n386\t warn \"No recently watched movies found — nothing to seed from\"\n387\t exit 0\n388\tfi\n389\t\n390\t# ==============================================================================================\n391\t# ━━━ Stage 1: Score Watched Movies → Select Seeds ━━━\n392\t# ==============================================================================================\n393\t# Scoring: recency (0-50) + play frequency across all users (0-50) = max 100\n394\t# No TMDB rating at Stage 1 — CommunityRating is not exposed by Emby's Items API\n395\techo \"\"\n396\techo \"━━━ $ICON_GEAR Stage 1: Scoring Watched Movies ━━━\"\n397\t\n398\tS1_SCORED=() # \"score|tmdb_id|title\"\n399\t\n400\tfor tmdb_id in \"${!WATCHED_MOVIES[@]}\"; do\n401\t IFS='|' read -r title play_count last_play <<< \"${WATCHED_MOVIES[\"$tmdb_id\"]}\"\n402\t\n403\t last_epoch=$(date -d \"$last_play\" +%s 2>/dev/null || echo \"$TODAY_EPOCH\")\n404\t days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))\n405\t\n406\t recency_s=$(_recency_score \"$days_ago\")\n407\t freq_s=$(_freq_score \"$play_count\")\n408\t total=$(( recency_s + freq_s ))\n409\t\n410\t log \" [${total}] $title (TMDB: $tmdb_id | ${days_ago}d ago | plays: $play_count)\"\n411\t S1_SCORED+=(\"${total}|${tmdb_id}|${title}\")\n412\tdone\n413\t\n414\tIFS=$'\\n' S1_SORTED=($(printf '%s\\n' \"${S1_SCORED[@]}\" | sort -t'|' -k1 -rn 2>/dev/null))\n415\t\n416\techo \"\"\n417\techo \"━━━ $ICON_SUMMARY Stage 1 Results (top ${MAX_SEEDS} seeds) ━━━\"\n418\t\n419\tSEEDS_TMDB=()\n420\tSEEDS_TITLES=()\n421\t\n422\tfor _entry in \"${S1_SORTED[@]}\"; do\n423\t (( ${#SEEDS_TMDB[@]} >= MAX_SEEDS )) && break\n424\t _score=\"${_entry%%|*}\"\n425\t _rest=\"${_entry#*|}\"\n426\t _tmdb=\"${_rest%%|*}\"\n427\t _title=\"${_rest#*|}\"\n428\t SEEDS_TMDB+=(\"$_tmdb\")\n429\t SEEDS_TITLES+=(\"$_title\")\n430\t printf \" Seed [%3s] %s (TMDB: %s)\\n\" \"$_score\" \"$_title\" \"$_tmdb\"\n431\tdone\n432\t\n433\tif [[ \"${#SEEDS_TMDB[@]}\" -eq 0 ]]; then\n434\t warn \"No recently watched movies qualify — no seeds for discovery\"\n435\t exit 0\n436\tfi\n437\t\n438\tlog \"${#SEEDS_TMDB[@]} seed(s) selected\"\n439\t\n440\t# ==============================================================================================\n441\t# ━━━ Stage 2: TMDB Recommendations ━━━\n442\t# ==============================================================================================\n443\techo \"\"\n444\techo \"━━━ $ICON_SYNC Stage 2: TMDB Recommendations ━━━\"\n445\t\n446\tdeclare -A CANDIDATE_SEEDS # tmdb_id → seed count\n447\tdeclare -A CANDIDATE_TITLE # tmdb_id → title\n448\tdeclare -A CANDIDATE_RATING # tmdb_id → vote_avg_int\n449\tdeclare -A CANDIDATE_VOTES # tmdb_id → vote_count\n450\t\n451\tfor (( _i=0; _i<${#SEEDS_TMDB[@]}; _i++ )); do\n452\t seed_tmdb=\"${SEEDS_TMDB[$_i]}\"\n453\t seed_title=\"${SEEDS_TITLES[$_i]}\"\n454\t\n455\t log \" Seed: $seed_title (TMDB: $seed_tmdb)\"\n456\t\n457\t RECS_JSON=$(_tmdb_recommendations \"$seed_tmdb\")\n458\t if [[ -z \"$RECS_JSON\" ]] || echo \"$RECS_JSON\" | jq -e '.results == [] or .results == null' >/dev/null 2>&1; then\n459\t warn \" No TMDB recommendations for: $seed_title\"\n460\t continue\n461\t fi\n462\t\n463\t while IFS='|' read -r rec_id rec_title rec_avg rec_votes; do\n464\t [[ -z \"$rec_id\" || \"$rec_id\" == \"null\" ]] && continue\n465\t [[ \"$rec_id\" == \"$seed_tmdb\" ]] && continue\n466\t\n467\t vote_avg_int=$(echo \"$rec_avg\" | awk '{printf \"%d\", $1 * 10 + 0.5}' 2>/dev/null)\n468\t vote_avg_int=${vote_avg_int:-0}\n469\t rec_votes=${rec_votes:-0}\n470\t\n471\t CANDIDATE_SEEDS[\"$rec_id\"]=$(( ${CANDIDATE_SEEDS[\"$rec_id\"]:-0} + 1 ))\n472\t CANDIDATE_TITLE[\"$rec_id\"]=\"$rec_title\"\n473\t CANDIDATE_RATING[\"$rec_id\"]=\"$vote_avg_int\"\n474\t CANDIDATE_VOTES[\"$rec_id\"]=\"$rec_votes\"\n475\t done < <(echo \"$RECS_JSON\" | jq -r '.results[] |\n476\t [\n477\t (.id | tostring),\n478\t .title,\n479\t (.vote_average | tostring),\n480\t (.vote_count | tostring)\n481\t ] | join(\"|\")' 2>/dev/null)\n482\tdone\n483\t\n484\tCANDIDATE_COUNT=${#CANDIDATE_SEEDS[@]}\n485\tlog \"$CANDIDATE_COUNT discovery candidates from TMDB\"\n486\t\n487\tif [[ \"$CANDIDATE_COUNT\" -eq 0 ]]; then\n488\t warn \"No candidates returned from TMDB — check API key\"\n489\t exit 0\n490\tfi\n491\t\n492\t# ==============================================================================================\n493\t# ━━━ Fetch Existing Radarr Library ━━━\n494\t# ==============================================================================================\n495\techo \"\"\n496\techo \"━━━ $ICON_SYNC Existing Libraries ━━━\"\n497\t\n498\tRADARR_MOVIES_JSON=$(_radarr_get \"movie\") || { error \"Could not fetch Radarr library\"; exit 1; }\n499\t\n500\tdeclare -A RADARR_TMDB # tmdb_id → 1\n501\twhile read -r tmdb_id; do\n502\t [[ -n \"$tmdb_id\" && \"$tmdb_id\" != \"0\" ]] && RADARR_TMDB[\"$tmdb_id\"]=1\n503\tdone < <(echo \"$RADARR_MOVIES_JSON\" | jq -r '.[].tmdbId // 0 | tostring' 2>/dev/null)\n504\t\n505\tRADARR_COUNT=${#RADARR_TMDB[@]}\n506\tEMBY_COUNT=${#EMBY_TMDB_IDS[@]}\n507\tlog \"$RADARR_COUNT movies in Radarr | $EMBY_COUNT movies in Emby\"\n508\t\n509\t_in_radarr() { [[ \"${RADARR_TMDB[\"$1\"]+x}\" ]]; }\n510\t_in_emby() { [[ \"${EMBY_TMDB_IDS[\"$1\"]+x}\" ]]; }\n511\t\n512\t# ==============================================================================================\n513\t# ━━━ Score Stage 2 Candidates ━━━\n514\t# ==============================================================================================\n515\techo \"\"\n516\techo \"━━━ $ICON_GEAR Stage 2: Scoring Candidates ━━━\"\n517\t\n518\tACCEPT_LIST=()\n519\tREJECT_LIST=()\n520\tALREADY_KNOWN=0\n521\tSKIP_COOLDOWN=0\n522\tSKIP_QUALITY=0\n523\t\n524\tfor rec_id in \"${!CANDIDATE_SEEDS[@]}\"; do\n525\t seed_count=\"${CANDIDATE_SEEDS[\"$rec_id\"]}\"\n526\t title=\"${CANDIDATE_TITLE[\"$rec_id\"]}\"\n527\t vote_avg_int=\"${CANDIDATE_RATING[\"$rec_id\"]}\"\n528\t vote_count=\"${CANDIDATE_VOTES[\"$rec_id\"]}\"\n529\t\n530\t if _in_radarr \"$rec_id\"; then\n531\t (( ALREADY_KNOWN++ ))\n532\t log \" $ICON_SKIP In Radarr: $title\"\n533\t continue\n534\t fi\n535\t\n536\t if _in_emby \"$rec_id\"; then\n537\t (( ALREADY_KNOWN++ ))\n538\t log \" $ICON_SKIP In Emby: $title\"\n539\t continue\n540\t fi\n541\t\n542\t # Hard quality floor — skip before cooldown check to avoid polluting history\n543\t if (( vote_count < MIN_VOTE_COUNT || vote_avg_int < MIN_RATING )); then\n544\t (( SKIP_QUALITY++ ))\n545\t log \" $ICON_SKIP Below quality floor (rating: $(_fmt_rating \"$vote_avg_int\") | votes: $vote_count): $title\"\n546\t continue\n547\t fi\n548\t\n549\t if [[ -f \"$HISTORY_FILE\" ]]; then\n550\t last_rejection=$(grep -i \"^REJECT|${rec_id}|\" \"$HISTORY_FILE\" 2>/dev/null | tail -1 | cut -d'|' -f3)\n551\t if [[ -n \"$last_rejection\" ]]; then\n552\t last_epoch=$(date -d \"$last_rejection\" +%s 2>/dev/null || echo 0)\n553\t days_since=$(( (TODAY_EPOCH - last_epoch) / 86400 ))\n554\t if (( days_since < REJECT_COOLDOWN )); then\n555\t (( SKIP_COOLDOWN++ ))\n556\t log \" $ICON_SKIP Cooldown (rejected ${days_since}d ago): $title\"\n557\t continue\n558\t fi\n559\t fi\n560\t fi\n561\t\n562\t breadth_s=$(_breadth_score \"$seed_count\")\n563\t rating_s=$(_rating_score_s2 \"$vote_avg_int\")\n564\t votes_s=$(_votes_score \"$vote_count\")\n565\t total=$(( breadth_s + rating_s + votes_s ))\n566\t\n567\t entry=\"${total}|${rec_id}|${title}|${seed_count}|${vote_avg_int}|${vote_count}\"\n568\t if (( total >= THRESHOLD )); then\n569\t ACCEPT_LIST+=(\"$entry\")\n570\t else\n571\t REJECT_LIST+=(\"$entry\")\n572\t fi\n573\t\n574\t log \" [${total}] $title (seeds: $seed_count | rating: $(_fmt_rating \"$vote_avg_int\") | votes: $vote_count)\"\n575\tdone\n576\t\n577\t# ==============================================================================================\n578\t# ━━━ Results ━━━\n579\t# ==============================================================================================\n580\techo \"\"\n581\techo \"━━━ $ICON_SUMMARY Stage 2 Results (threshold: ${THRESHOLD}, max: ${MAX_ADDS}) ━━━\"\n582\t\n583\tIFS=$'\\n' _ALL_ACCEPTS=($(printf '%s\\n' \"${ACCEPT_LIST[@]}\" | sort -t'|' -k1 -rn 2>/dev/null))\n584\tIFS=$'\\n' SORTED_REJECTS=($(printf '%s\\n' \"${REJECT_LIST[@]}\" | sort -t'|' -k1 -rn 2>/dev/null))\n585\t\n586\tSORTED_ACCEPTS=(\"${_ALL_ACCEPTS[@]:0:$MAX_ADDS}\")\n587\tfor (( _i=MAX_ADDS; _i<${#_ALL_ACCEPTS[@]}; _i++ )); do\n588\t SORTED_REJECTS+=(\"${_ALL_ACCEPTS[$_i]}\")\n589\tdone\n590\t\n591\t_print_row() {\n592\t local label=\"$1\" entry=\"$2\"\n593\t IFS='|' read -r score rec_id title seeds avg_int votes <<< \"$entry\"\n594\t local rating_fmt\n595\t rating_fmt=$(_fmt_rating \"$avg_int\")\n596\t printf \" %-8s [%3s] %-45s seeds: %s | rating: %s | votes: %s\\n\" \\\n597\t \"$label\" \"$score\" \"$title\" \"$seeds\" \"$rating_fmt\" \"$votes\"\n598\t}\n599\t\n600\tfor entry in \"${SORTED_ACCEPTS[@]}\"; do [[ -n \"$entry\" ]] && _print_row \"ACCEPT\" \"$entry\"; done\n601\tfor entry in \"${SORTED_REJECTS[@]}\"; do [[ -n \"$entry\" ]] && _print_row \"REJECT\" \"$entry\"; done\n602\t\n603\techo \"\"\n604\techo \" Already in library: $ALREADY_KNOWN | Below quality floor: $SKIP_QUALITY | Cooldown: $SKIP_COOLDOWN\"\n605\techo \" ACCEPT: ${#SORTED_ACCEPTS[@]} | REJECT: ${#SORTED_REJECTS[@]}\"\n606\t\n607\tif [[ \"$DRY_RUN\" == true ]]; then\n608\t warn \"DRY RUN complete — run without --dry-run to add accepted movies\"\n609\t exit 0\n610\tfi\n611\t\n612\tif [[ \"${#SORTED_ACCEPTS[@]}\" -eq 0 ]]; then\n613\t log \"No movies above threshold — nothing to add\"\n614\t for entry in \"${SORTED_REJECTS[@]}\"; do\n615\t [[ -z \"$entry\" ]] && continue\n616\t IFS='|' read -r score rec_id title _ <<< \"$entry\"\n617\t echo \"REJECT|${rec_id}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n618\t done\n619\t exit 0\n620\tfi\n621\t\n622\t# ==============================================================================================\n623\t# ━━━ Add to Radarr ━━━\n624\t# ==============================================================================================\n625\techo \"\"\n626\techo \"━━━ $ICON_SYNC Adding to Radarr ━━━\"\n627\t\n628\tRADARR_ROOT=$(_radarr_get \"rootfolder\" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null)\n629\tif [[ -z \"$RADARR_ROOT\" ]]; then error \"Could not determine Radarr root folder\"; exit 1; fi\n630\t\n631\tQUALITY_ID=$(_radarr_get \"qualityprofile\" | jq -r '.[0].id' 2>/dev/null)\n632\tlog \"Root folder: $RADARR_ROOT | Quality profile: $QUALITY_ID\"\n633\t\n634\tADDED=0\n635\tFAILED=0\n636\t\n637\tfor entry in \"${SORTED_ACCEPTS[@]}\"; do\n638\t [[ -z \"$entry\" ]] && continue\n639\t IFS='|' read -r score rec_id title seeds avg_int votes <<< \"$entry\"\n640\t\n641\t LOOKUP=$(_radarr_lookup \"$rec_id\")\n642\t\n643\t if [[ -z \"$LOOKUP\" ]] || echo \"$LOOKUP\" | jq -e '. == [] or . == null' >/dev/null 2>&1; then\n644\t warn \" $ICON_WARN No match in Radarr lookup: $title (TMDB: $rec_id)\"\n645\t echo \"FAIL|${rec_id}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n646\t (( FAILED++ ))\n647\t continue\n648\t fi\n649\t\n650\t MOVIE_DATA=$(echo \"$LOOKUP\" | jq '.[0]' 2>/dev/null)\n651\t RADARR_TITLE=$(echo \"$MOVIE_DATA\" | jq -r '.title // \"\"' 2>/dev/null)\n652\t\n653\t if [[ -z \"$RADARR_TITLE\" ]]; then\n654\t warn \" $ICON_WARN Empty result for: $title\"\n655\t echo \"FAIL|${rec_id}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n656\t (( FAILED++ ))\n657\t continue\n658\t fi\n659\t\n660\t PAYLOAD=$(echo \"$MOVIE_DATA\" | jq \\\n661\t --arg root \"$RADARR_ROOT\" \\\n662\t --argjson qid \"$QUALITY_ID\" \\\n663\t '. + {\n664\t rootFolderPath: $root,\n665\t qualityProfileId: $qid,\n666\t monitored: true,\n667\t addOptions: {\n668\t searchForMovie: false\n669\t }\n670\t }' 2>/dev/null)\n671\t\n672\t RESULT=$(_radarr_post \"$PAYLOAD\")\n673\t\n674\t if echo \"$RESULT\" | jq -e '.id' >/dev/null 2>&1; then\n675\t MOVIE_ID=$(echo \"$RESULT\" | jq -r '.id')\n676\t _radarr_command \"{\\\"name\\\":\\\"MoviesSearch\\\",\\\"movieIds\\\":[${MOVIE_ID}]}\" >/dev/null\n677\t log \" $ICON_DONE Added: $RADARR_TITLE (score: $score | seeds: $seeds)\"\n678\t echo \"ACCEPT|${rec_id}|${TODAY}|${RADARR_TITLE}\" >> \"$HISTORY_FILE\" 2>/dev/null\n679\t (( ADDED++ ))\n680\t else\n681\t warn \" $ICON_WARN Failed to add: $title\"\n682\t log \" $(echo \"$RESULT\" | head -c 200)\"\n683\t echo \"FAIL|${rec_id}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n684\t (( FAILED++ ))\n685\t fi\n686\tdone\n687\t\n688\tfor entry in \"${SORTED_REJECTS[@]}\"; do\n689\t [[ -z \"$entry\" ]] && continue\n690\t IFS='|' read -r score rec_id title _ <<< \"$entry\"\n691\t echo \"REJECT|${rec_id}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n692\tdone\n693\t\n694\t# ==============================================================================================\n695\t# ━━━ Summary ━━━\n696\t# ==============================================================================================\n697\techo \"\"\n698\techo \"━━━━━ $ICON_SUMMARY DISCOVERY COMPLETE ━━━━━\"\n699\techo \" $ICON_DONE Added: $ADDED\"\n700\t[[ \"$FAILED\" -gt 0 ]] && echo \" $ICON_WARN Failed: $FAILED\"\n701\techo \" $ICON_SKIP Rejected: ${#SORTED_REJECTS[@]}\"\n702\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n703\t\n704\t[[ \"$ADDED\" -gt 0 ]] && notify \\\n705\t \"$ADDED movie(s) added to Radarr via discovery on $(hostname)\" \\\n706\t \"Radarr Discovery\" \"normal\"\n707\t\n708\texit 0\n709\t"}]},"uuid":"14656f4d-a0b3-4844-98dc-bf089195f57c","timestamp":"2026-06-14T05:32:15.828Z","sourceToolAssistantUUID":"3ddde57b-e5ff-4e4b-add9-ab11080f821a","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"8619cd59-ba76-44dd-bfb4-46b7f3853e7c","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Hj7AwjKnrxvvbhETnFyHRc","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# =========================== Playback-Aware Sonarr Discovery ==================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Two-stage behavior-driven TV show discovery.\n9\t#\n10\t# Stage 1 — Score recently watched series in Emby. The top series become\n11\t# high-quality seeds, weighted by user diversity, recency, and\n12\t# per-user-capped episode volume.\n13\t#\n14\t# Stage 2 — Run TMDB TV recommendations on those seeds. Score the candidates\n15\t# and add the top shows to Sonarr.\n16\t#\n17\t# Goal: 0–3 meaningful Sonarr adds per run, not bulk imports.\n18\t#\n19\t# ==============================================================================================\n20\t# FLOW\n21\t# ==============================================================================================\n22\t#\n23\t# 1. Fetch all Series from Emby SONARR_EMBY_LIBRARIES — build TMDB+TVDB index\n24\t#\n25\t# ── Stage 1 ─────────────────────────────────────────────────────────────────\n26\t# 2. Fetch Emby activity log (last LOOKBACK_DAYS days) — episode play events\n27\t# 3. Batch-fetch episode items → map episode → series → TMDB ID\n28\t# 4. Aggregate per-user episode counts per series\n29\t# 5. Score each series: user diversity + recency + capped volume\n30\t# 6. Take top MAX_SEEDS → discovery seeds\n31\t#\n32\t# ── Stage 2 ─────────────────────────────────────────────────────────────────\n33\t# 7. For each seed, call TMDB TV recommendations → collect candidates\n34\t# 8. Aggregate: breadth (distinct seeds recommending this show)\n35\t# 9. Filter: already in Sonarr, already in Emby, below min votes/rating, cooldown\n36\t# 10. Score candidates: breadth + TMDB rating + vote count\n37\t# 11. Take top MAX_ADDS above threshold\n38\t# 12. Get TVDB ID via TMDB external_ids → Sonarr lookup → add + trigger SeriesSearch\n39\t#\n40\t# ==============================================================================================\n41\t# SCORING MODEL\n42\t# ==============================================================================================\n43\t#\n44\t# Stage 1 — seed selection (max 100)\n45\t# user_diversity_score (0-50) — unique users who watched: 1→10, 2→25, 3→40, 4+→50\n46\t# recency_score (0-30) — days since last episode; 0-7d→30, 8-14d→20, 15-21d→12, 22+→5\n47\t# volume_score (0-20) — sum of min(user_eps, USER_EPISODE_CAP); 1-4→3, 5-12→8, 13-24→14, 25+→20\n48\t#\n49\t# USER_EPISODE_CAP prevents one person binge-watching from dominating seeds.\n50\t# Example: 4 users × 2 eps each beats 1 user × 50 eps (58 vs 18, before recency).\n51\t#\n52\t# Stage 2 — candidate scoring (max 100)\n53\t# breadth_score (0-40) — 3+ seeds→40, 2 seeds→25, 1 seed→10\n54\t# rating_score (0-40) — TMDB vote_average × 10: 80+→40, 75+→32, 70+→25, 65+→18, 60+→12, else→5\n55\t# votes_score (0-20) — TMDB vote_count: 10k+→20, 5k+→15, 1k+→10, 200+→5, else→2\n56\t#\n57\t# Threshold: SONARR_DISCOVERY_THRESHOLD (default 52)\n58\t# Max adds: SONARR_DISCOVERY_MAX_ADDS (default 3) — TV is a larger commitment than movies\n59\t#\n60\t# ==============================================================================================\n61\t# REQUIREMENTS\n62\t# ==============================================================================================\n63\t#\n64\t# TMDB API key — required for Stage 2 recommendations and external_ids lookup\n65\t# Configure HOST*_TMDB_API_KEY in host*.conf\n66\t# Free key at: https://www.themoviedb.org/settings/api\n67\t#\n68\t# ==============================================================================================\n69\t# CONFIGURATION (master.conf)\n70\t# ==============================================================================================\n71\t#\n72\t# SONARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 52)\n73\t# SONARR_DISCOVERY_LOOKBACK_DAYS — Emby watch history window in days (default: 14)\n74\t# SONARR_DISCOVERY_MAX_SEEDS — max seed series from Stage 1 (default: 5)\n75\t# SONARR_DISCOVERY_MAX_ADDS — max shows to add per run (default: 3)\n76\t# SONARR_DISCOVERY_MIN_VOTE_COUNT — min TMDB votes for a candidate (default: 50)\n77\t# SONARR_DISCOVERY_MIN_RATING — min TMDB vote_average × 10 (default: 65 = 6.5/10)\n78\t# SONARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a rejected show (default: 60)\n79\t# SONARR_DISCOVERY_USER_EPISODE_CAP — max episodes per user in seed scoring (default: 8)\n80\t# SONARR_DISCOVERY_MONITOR_MODE — Sonarr monitor mode on add: \"all\" or \"future\" (default: \"all\")\n81\t# SONARR_EMBY_LIBRARIES — Emby library names to draw seeds from\n82\t# SONARR_DISCOVERY_HISTORY — history/state file path\n83\t#\n84\t# ==============================================================================================\n85\t# RUNTIME MODES\n86\t# ==============================================================================================\n87\t#\n88\t# playback_aware_sonarr_discovery.sh — normal run\n89\t# playback_aware_sonarr_discovery.sh --dry-run — score and rank, no Sonarr changes\n90\t# playback_aware_sonarr_discovery.sh --log — verbose output\n91\t# playback_aware_sonarr_discovery.sh --status — show config and exit\n92\t#\n93\t# Recommended schedule: weekly (WEEKLY_MAINTENANCE_SCRIPTS in master.conf)\n94\t#\n95\t# ==============================================================================================\n96\t# OPERATIONAL SAFEGUARDS\n97\t# ==============================================================================================\n98\t#\n99\t# Root Required\n100\t# acquire_lock and Sonarr API writes require root. Script exits cleanly if not root.\n101\t#\n102\t# Dry-Run Mode\n103\t# --dry-run scores and ranks all candidates but makes no Sonarr API calls and does not\n104\t# write to the history file. Safe to run at any time to preview what would be added.\n105\t#\n106\t# Add-Only\n107\t# Only adds series to Sonarr. Never deletes or modifies existing entries.\n108\t#\n109\t# Cooldown Guard\n110\t# Candidates rejected this run are recorded in the history file and not re-evaluated\n111\t# until SONARR_DISCOVERY_REJECT_COOLDOWN days have elapsed.\n112\t#\n113\t# Monitor Mode\n114\t# SONARR_DISCOVERY_MONITOR_MODE=\"all\" monitors every season on add — correct for shows\n115\t# where you want Sonarr to search back-catalogue. \"future\" only marks upcoming seasons;\n116\t# use only when you intentionally want to skip existing seasons.\n117\t#\n118\t# ==============================================================================================\n119\t# STATE FILES\n120\t# ==============================================================================================\n121\t#\n122\t# SONARR_DISCOVERY_HISTORY (default: $DATA_DIR/sonarr_discovery_history.db)\n123\t# Tracks added series and rejected candidates with timestamps. Written after every\n124\t# non-dry-run. Enforces reject cooldown and prevents re-adding items added by previous\n125\t# runs. Safe to delete — next run starts fresh with no memory.\n126\t#\n127\t# ==============================================================================================\n128\t\n129\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n130\t\n131\tsource \"$SCRIPT_DIR/../load_config.sh\"\n132\tsource \"$SCRIPT_DIR/../Kernel/decision_engine.sh\"\n133\t\n134\tparse_args \"$@\"\n135\t\n136\t# ==============================================================================================\n137\t# ━━━ Setup ━━━\n138\t# ==============================================================================================\n139\tif [[ \"$EUID\" -ne 0 ]]; then\n140\t error \"Must be run as root\"\n141\t exit 1\n142\tfi\n143\t\n144\tacquire_lock\n145\tdetect_hosts\n146\t\n147\tif ! command -v jq >/dev/null 2>&1; then\n148\t error \"jq not installed — required for API JSON parsing\"\n149\t exit 1\n150\tfi\n151\t\n152\tif [[ -z \"${EMBY_URL:-}\" || -z \"${EMBY_API_KEY:-}\" ]]; then\n153\t error \"EMBY_URL / EMBY_API_KEY not configured — check host*.conf\"\n154\t exit 1\n155\tfi\n156\t\n157\tif [[ -z \"${SONARR_URL:-}\" || -z \"${SONARR_API_KEY:-}\" ]]; then\n158\t error \"SONARR_URL / SONARR_API_KEY not configured — check host*.conf\"\n159\t exit 1\n160\tfi\n161\t\n162\tif [[ -z \"${TMDB_API_KEY:-}\" ]]; then\n163\t error \"TMDB_API_KEY not configured — required for discovery\"\n164\t error \"Get a free key at https://www.themoviedb.org/settings/api\"\n165\t error \"Configure HOST*_TMDB_API_KEY in host*.conf\"\n166\t exit 1\n167\tfi\n168\t\n169\tif [[ \"${#SONARR_EMBY_LIBRARIES[@]}\" -eq 0 ]]; then\n170\t error \"SONARR_EMBY_LIBRARIES not configured — check master.conf\"\n171\t exit 1\n172\tfi\n173\t\n174\tTHRESHOLD=\"${SONARR_DISCOVERY_THRESHOLD:-52}\"\n175\tLOOKBACK_DAYS=\"${SONARR_DISCOVERY_LOOKBACK_DAYS:-14}\"\n176\tMAX_SEEDS=\"${SONARR_DISCOVERY_MAX_SEEDS:-5}\"\n177\tMAX_ADDS=\"${SONARR_DISCOVERY_MAX_ADDS:-3}\"\n178\tMIN_VOTE_COUNT=\"${SONARR_DISCOVERY_MIN_VOTE_COUNT:-50}\"\n179\tMIN_RATING=\"${SONARR_DISCOVERY_MIN_RATING:-65}\"\n180\tREJECT_COOLDOWN=\"${SONARR_DISCOVERY_REJECT_COOLDOWN:-60}\"\n181\tUSER_EPISODE_CAP=\"${SONARR_DISCOVERY_USER_EPISODE_CAP:-8}\"\n182\tMONITOR_MODE=\"${SONARR_DISCOVERY_MONITOR_MODE:-all}\"\n183\tHISTORY_FILE=\"${SONARR_DISCOVERY_HISTORY:-${DATA_DIR}/sonarr_discovery_history.db}\"\n184\t\n185\tlog \"$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}\"\n186\t\n187\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no shows will be added to Sonarr\"\n188\t\n189\t_fmt_rating() { local v=\"${1:-0}\"; echo \"${v::-1}.${v: -1}\" 2>/dev/null || echo \"$v\"; }\n190\t\n191\t# ==============================================================================================\n192\t# ━━━ Status ━━━\n193\t# ==============================================================================================\n194\tif [[ \"$SHOW_STATUS\" == true ]]; then\n195\t echo \"\"\n196\t echo \"━━━━━ $ICON_SUMMARY SONARR DISCOVERY STATUS ━━━━━\"\n197\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n198\t echo \"$ICON_SYNC Emby: ${EMBY_URL}\"\n199\t echo \"$ICON_SYNC Sonarr: ${SONARR_URL}\"\n200\t echo \"$ICON_GEAR Threshold: ${THRESHOLD} / 100\"\n201\t echo \"$ICON_TIME Lookback: ${LOOKBACK_DAYS} days\"\n202\t echo \"$ICON_GEAR Max seeds: ${MAX_SEEDS}\"\n203\t echo \"$ICON_GEAR Max adds: ${MAX_ADDS}\"\n204\t echo \"$ICON_GEAR Min rating: ${MIN_RATING} ($(_fmt_rating \"$MIN_RATING\")/10 TMDB)\"\n205\t echo \"$ICON_GEAR Min votes: ${MIN_VOTE_COUNT}\"\n206\t echo \"$ICON_GEAR Reject TTL: ${REJECT_COOLDOWN} days\"\n207\t echo \"$ICON_GEAR User ep cap: ${USER_EPISODE_CAP} episodes\"\n208\t echo \"$ICON_GEAR Monitor mode: ${MONITOR_MODE}\"\n209\t echo \"$ICON_GEAR Seed libs: ${SONARR_EMBY_LIBRARIES[*]}\"\n210\t echo \"$ICON_GEAR History file: ${HISTORY_FILE}\"\n211\t echo \"$ICON_GEAR TMDB: configured\"\n212\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n213\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n214\t exit 0\n215\tfi\n216\t\n217\t# ==============================================================================================\n218\t# ── API HELPERS ───────────────────────────────────────────────────────────────────────────────\n219\t# ==============================================================================================\n220\t\n221\t\n222\t_sonarr_get() {\n223\t curl -sf --max-time 30 \\\n224\t -H \"X-Api-Key: $SONARR_API_KEY\" \\\n225\t \"${SONARR_URL}/api/v3/${1}\" 2>/dev/null\n226\t}\n227\t\n228\t_sonarr_lookup() {\n229\t curl -sf --max-time 20 --get \\\n230\t --data-urlencode \"term=$1\" \\\n231\t -H \"X-Api-Key: $SONARR_API_KEY\" \\\n232\t \"${SONARR_URL}/api/v3/series/lookup\" 2>/dev/null\n233\t}\n234\t\n235\t_sonarr_post() {\n236\t curl -sf --max-time 20 -X POST \\\n237\t -H \"X-Api-Key: $SONARR_API_KEY\" \\\n238\t -H \"Content-Type: application/json\" \\\n239\t -d \"$1\" \\\n240\t \"${SONARR_URL}/api/v3/series\" 2>/dev/null\n241\t}\n242\t\n243\t_sonarr_command() {\n244\t curl -sf --max-time 20 -X POST \\\n245\t -H \"X-Api-Key: $SONARR_API_KEY\" \\\n246\t -H \"Content-Type: application/json\" \\\n247\t -d \"$1\" \\\n248\t \"${SONARR_URL}/api/v3/command\" 2>/dev/null\n249\t}\n250\t\n251\t_tmdb_tv_recommendations() {\n252\t curl -sf --max-time 15 \\\n253\t \"https://api.themoviedb.org/3/tv/${1}/recommendations?api_key=${TMDB_API_KEY}&language=en-US&page=1\" \\\n254\t 2>/dev/null\n255\t}\n256\t\n257\t_tmdb_external_ids() {\n258\t curl -sf --max-time 15 \\\n259\t \"https://api.themoviedb.org/3/tv/${1}/external_ids?api_key=${TMDB_API_KEY}\" \\\n260\t 2>/dev/null\n261\t}\n262\t\n263\t# ==============================================================================================\n264\t# ── SCORING HELPERS ───────────────────────────────────────────────────────────────────────────\n265\t# ==============================================================================================\n266\t\n267\t# Stage 1: unique user count score (0-50) — primary seed driver\n268\t_diversity_score() {\n269\t local n=\"$1\"\n270\t if (( n >= 4 )); then echo 50\n271\t elif (( n == 3 )); then echo 40\n272\t elif (( n == 2 )); then echo 25\n273\t else echo 10\n274\t fi\n275\t}\n276\t\n277\t# Stage 1: recency of most recent episode watched (0-30)\n278\t_recency_score() {\n279\t local days=\"$1\"\n280\t if (( days <= 7 )); then echo 30\n281\t elif (( days <= 14 )); then echo 20\n282\t elif (( days <= 21 )); then echo 12\n283\t elif (( days <= 30 )); then echo 5\n284\t else echo 0\n285\t fi\n286\t}\n287\t\n288\t# Stage 1: per-user-capped episode volume score (0-20)\n289\t_volume_score() {\n290\t local v=\"$1\"\n291\t if (( v >= 25 )); then echo 20\n292\t elif (( v >= 13 )); then echo 14\n293\t elif (( v >= 5 )); then echo 8\n294\t elif (( v >= 1 )); then echo 3\n295\t else echo 0\n296\t fi\n297\t}\n298\t\n299\t# Stage 2: TMDB vote_average × 10 (0-40)\n300\t_rating_score_s2() {\n301\t local v=\"$1\"\n302\t if (( v >= 80 )); then echo 40\n303\t elif (( v >= 75 )); then echo 32\n304\t elif (( v >= 70 )); then echo 25\n305\t elif (( v >= 65 )); then echo 18\n306\t elif (( v >= 60 )); then echo 12\n307\t else echo 5\n308\t fi\n309\t}\n310\t\n311\t# Stage 2: vote count (0-20)\n312\t_votes_score() {\n313\t local c=\"$1\"\n314\t if (( c >= 10000 )); then echo 20\n315\t elif (( c >= 5000 )); then echo 15\n316\t elif (( c >= 1000 )); then echo 10\n317\t elif (( c >= 200 )); then echo 5\n318\t else echo 2\n319\t fi\n320\t}\n321\t\n322\t# Stage 2: seed breadth (0-40)\n323\t_breadth_score() {\n324\t local seeds=\"$1\"\n325\t if (( seeds >= 3 )); then echo 40\n326\t elif (( seeds == 2 )); then echo 25\n327\t else echo 10\n328\t fi\n329\t}\n330\t\n331\t# ==============================================================================================\n332\t# ━━━ Fetch Emby Series Library ━━━\n333\t# ==============================================================================================\n334\techo \"\"\n335\techo \"━━━━━ $ICON_SUMMARY Sonarr Discovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━\"\n336\techo \"$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) | lookback: ${LOOKBACK_DAYS}d | threshold: ${THRESHOLD}/100 | max: ${MAX_ADDS}\"\n337\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no shows will be added\"\n338\t\n339\techo \"\"\n340\techo \"━━━ $ICON_SYNC Emby Series Library ━━━\"\n341\t\n342\tLIBRARIES_JSON=$(emby_api \"Library/VirtualFolders\") || { error \"Could not fetch Emby libraries\"; exit 1; }\n343\tCUTOFF_ISO=$(date -d \"${LOOKBACK_DAYS} days ago\" '+%Y-%m-%dT%H:%M:%SZ')\n344\tTODAY_EPOCH=$(date +%s)\n345\tTODAY=$(date +%Y-%m-%d)\n346\t\n347\t# EMBY_SERIES_BY_ID[emby_item_id] = tmdb_id — used to map episode.SeriesId → series TMDB ID\n348\t# EMBY_SERIES_NAME[tmdb_id] = name — used to display seed names in Stage 1\n349\t# EMBY_TVDB_IDS[tvdb_id] = 1 — used to filter already-owned shows in Stage 2\n350\t# EMBY_TMDB_IDS[tmdb_id] = 1 — used to filter already-owned shows in Stage 2\n351\tdeclare -A EMBY_SERIES_BY_ID\n352\tdeclare -A EMBY_SERIES_NAME\n353\tdeclare -A EMBY_TVDB_IDS\n354\tdeclare -A EMBY_TMDB_IDS\n355\t\n356\tfor lib_name in \"${SONARR_EMBY_LIBRARIES[@]}\"; do\n357\t lib_id=$(echo \"$LIBRARIES_JSON\" | jq -r --arg n \"$lib_name\" '.[] | select(.Name == $n) | .ItemId' 2>/dev/null)\n358\t if [[ -z \"$lib_id\" ]]; then\n359\t warn \"Emby library not found: $lib_name\"\n360\t continue\n361\t fi\n362\t log \"Scanning library: $lib_name (ItemId: $lib_id)\"\n363\t LIB_JSON=$(emby_api \"Items?ParentId=${lib_id}&IncludeItemTypes=Series&Recursive=true&Fields=ProviderIds&Limit=5000\") || {\n364\t warn \"Could not fetch series from library: $lib_name\"\n365\t continue\n366\t }\n367\t while IFS='|' read -r emby_id tmdb_id tvdb_id name; do\n368\t [[ -z \"$emby_id\" || \"$emby_id\" == \"null\" ]] && continue\n369\t [[ -n \"$tmdb_id\" && \"$tmdb_id\" != \"null\" ]] && {\n370\t EMBY_SERIES_BY_ID[\"$emby_id\"]=\"$tmdb_id\"\n371\t EMBY_TMDB_IDS[\"$tmdb_id\"]=1\n372\t [[ -n \"$name\" && \"$name\" != \"null\" ]] && EMBY_SERIES_NAME[\"$tmdb_id\"]=\"$name\"\n373\t }\n374\t [[ -n \"$tvdb_id\" && \"$tvdb_id\" != \"null\" ]] && EMBY_TVDB_IDS[\"$tvdb_id\"]=1\n375\t done < <(echo \"$LIB_JSON\" | jq -r '.Items[] |\n376\t [.Id, (.ProviderIds.Tmdb // \"\"), (.ProviderIds.Tvdb // \"\"), .Name] | join(\"|\")\n377\t ' 2>/dev/null)\n378\tdone\n379\t\n380\tlog \"${#EMBY_SERIES_BY_ID[@]} series in Emby TMDB index\"\n381\t\n382\tif [[ \"${#EMBY_SERIES_BY_ID[@]}\" -eq 0 ]]; then\n383\t warn \"No series found in Emby libraries — nothing to seed from\"\n384\t exit 0\n385\tfi\n386\t\n387\t# ==============================================================================================\n388\t# ━━━ Fetch Emby Activity Log — Episode Plays ━━━\n389\t# ==============================================================================================\n390\techo \"\"\n391\techo \"━━━ $ICON_SYNC Emby Watch History ━━━\"\n392\t\n393\t# Activity log entries: each playback.stop has ItemId (episode), UserId, Date.\n394\t# Track per-user episode plays so we can cap any single user's influence on seed scoring.\n395\tdeclare -A ITEM_USER_PLAYS # \"episode_item_id|user_id\" → play count\n396\tdeclare -A ITEM_LAST_PLAY # episode_item_id → ISO date of most recent play\n397\t\n398\tACTIVITY_JSON=$(emby_api \"System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000\") || {\n399\t error \"Could not fetch Emby activity log\"\n400\t exit 1\n401\t}\n402\t\n403\twhile IFS='|' read -r item_id user_id play_date; do\n404\t [[ -z \"$item_id\" || \"$item_id\" == \"null\" ]] && continue\n405\t [[ -z \"$user_id\" || \"$user_id\" == \"null\" ]] && user_id=\"unknown\"\n406\t key=\"${item_id}|${user_id}\"\n407\t ITEM_USER_PLAYS[\"$key\"]=$(( ${ITEM_USER_PLAYS[\"$key\"]:-0} + 1 ))\n408\t current=\"${ITEM_LAST_PLAY[\"$item_id\"]:-}\"\n409\t if [[ -z \"$current\" || \"$play_date\" > \"$current\" ]]; then\n410\t ITEM_LAST_PLAY[\"$item_id\"]=\"$play_date\"\n411\t fi\n412\tdone < <(echo \"$ACTIVITY_JSON\" | jq -r '\n413\t .Items[] |\n414\t select(.Type == \"playback.stop\") |\n415\t select(.ItemId != null and .ItemId != \"\") |\n416\t [.ItemId, (.UserId // \"unknown\"), .Date] | join(\"|\")\n417\t' 2>/dev/null)\n418\t\n419\t# Deduplicate episode IDs for batch fetch\n420\tdeclare -A _UNIQUE_IDS\n421\tfor key in \"${!ITEM_USER_PLAYS[@]}\"; do\n422\t episode_id=\"${key%%|*}\"\n423\t _UNIQUE_IDS[\"$episode_id\"]=1\n424\tdone\n425\tALL_EPISODE_IDS=(\"${!_UNIQUE_IDS[@]}\")\n426\tunset _UNIQUE_IDS\n427\t\n428\tlog \"${#ALL_EPISODE_IDS[@]} unique episode items in activity log\"\n429\t\n430\t# ==============================================================================================\n431\t# ━━━ Map Episodes → Series via Batch Fetch ━━━\n432\t# ==============================================================================================\n433\t\n434\t# EPISODE_TO_SERIES[episode_item_id] = series_tmdb_id\n435\tdeclare -A EPISODE_TO_SERIES\n436\t\n437\tif [[ \"${#ALL_EPISODE_IDS[@]}\" -gt 0 ]]; then\n438\t BATCH_SIZE=100\n439\t for (( _b=0; _b<${#ALL_EPISODE_IDS[@]}; _b+=BATCH_SIZE )); do\n440\t BATCH=(\"${ALL_EPISODE_IDS[@]:_b:BATCH_SIZE}\")\n441\t IDS_CSV=$(printf '%s,' \"${BATCH[@]}\"); IDS_CSV=\"${IDS_CSV%,}\"\n442\t ITEMS_DETAIL=$(emby_api \"Items?Ids=${IDS_CSV}&Fields=SeriesId,Type&Limit=200\") || {\n443\t warn \"Could not fetch episode batch starting at $_b\"\n444\t continue\n445\t }\n446\t while IFS='|' read -r item_id item_type series_emby_id; do\n447\t [[ \"$item_type\" != \"Episode\" ]] && continue\n448\t [[ -z \"$series_emby_id\" || \"$series_emby_id\" == \"null\" ]] && continue\n449\t series_tmdb=\"${EMBY_SERIES_BY_ID[\"$series_emby_id\"]:-}\"\n450\t [[ -z \"$series_tmdb\" ]] && continue\n451\t EPISODE_TO_SERIES[\"$item_id\"]=\"$series_tmdb\"\n452\t done < <(echo \"$ITEMS_DETAIL\" | jq -r '.Items[] |\n453\t [(.Id | tostring), .Type, (.SeriesId // \"\")] | join(\"|\")\n454\t ' 2>/dev/null)\n455\t done\n456\tfi\n457\t\n458\t# Aggregate per-user episode counts per series\n459\t# SERIES_USER_PLAYS[\"series_tmdb_id|user_id\"] = total episodes played by that user\n460\t# One key per (series, user) pair — safe to count for diversity and cap for volume\n461\tdeclare -A SERIES_USER_PLAYS\n462\tdeclare -A SERIES_LAST_PLAY\n463\t\n464\tfor key in \"${!ITEM_USER_PLAYS[@]}\"; do\n465\t episode_id=\"${key%%|*}\"\n466\t user_id=\"${key#*|}\"\n467\t series_tmdb=\"${EPISODE_TO_SERIES[\"$episode_id\"]:-}\"\n468\t [[ -z \"$series_tmdb\" ]] && continue\n469\t plays=\"${ITEM_USER_PLAYS[$key]}\"\n470\t skey=\"${series_tmdb}|${user_id}\"\n471\t SERIES_USER_PLAYS[\"$skey\"]=$(( ${SERIES_USER_PLAYS[\"$skey\"]:-0} + plays ))\n472\t play_date=\"${ITEM_LAST_PLAY[\"$episode_id\"]:-}\"\n473\t current=\"${SERIES_LAST_PLAY[\"$series_tmdb\"]:-}\"\n474\t if [[ -n \"$play_date\" && ( -z \"$current\" || \"$play_date\" > \"$current\" ) ]]; then\n475\t SERIES_LAST_PLAY[\"$series_tmdb\"]=\"$play_date\"\n476\t fi\n477\tdone\n478\t\n479\t# Pre-aggregate stats per series for scoring\n480\tdeclare -A SERIES_UNIQUE_USERS # series_tmdb → unique user count\n481\tdeclare -A SERIES_CAPPED_VOLUME # series_tmdb → sum of min(user_episodes, cap)\n482\t\n483\tfor key in \"${!SERIES_USER_PLAYS[@]}\"; do\n484\t series_tmdb=\"${key%%|*}\"\n485\t plays=\"${SERIES_USER_PLAYS[$key]}\"\n486\t capped=$(( plays > USER_EPISODE_CAP ? USER_EPISODE_CAP : plays ))\n487\t SERIES_UNIQUE_USERS[\"$series_tmdb\"]=$(( ${SERIES_UNIQUE_USERS[\"$series_tmdb\"]:-0} + 1 ))\n488\t SERIES_CAPPED_VOLUME[\"$series_tmdb\"]=$(( ${SERIES_CAPPED_VOLUME[\"$series_tmdb\"]:-0} + capped ))\n489\tdone\n490\t\n491\tSERIES_COUNT=${#SERIES_UNIQUE_USERS[@]}\n492\tlog \"$SERIES_COUNT series with recent episode activity\"\n493\t\n494\tif [[ \"$SERIES_COUNT\" -eq 0 ]]; then\n495\t warn \"No recently watched series found — nothing to seed from\"\n496\t exit 0\n497\tfi\n498\t\n499\t# ==============================================================================================\n500\t# ━━━ Stage 1: Score Watched Series → Select Seeds ━━━\n501\t# ==============================================================================================\n502\techo \"\"\n503\techo \"━━━ $ICON_GEAR Stage 1: Scoring Watched Series ━━━\"\n504\t\n505\tS1_SCORED=() # \"score|tmdb_id|title\"\n506\t\n507\tfor series_tmdb in \"${!SERIES_UNIQUE_USERS[@]}\"; do\n508\t n_users=\"${SERIES_UNIQUE_USERS[$series_tmdb]}\"\n509\t capped_vol=\"${SERIES_CAPPED_VOLUME[$series_tmdb]:-0}\"\n510\t last_play=\"${SERIES_LAST_PLAY[$series_tmdb]:-}\"\n511\t series_name=\"${EMBY_SERIES_NAME[$series_tmdb]:-TMDB:${series_tmdb}}\"\n512\t\n513\t last_epoch=$(date -d \"$last_play\" +%s 2>/dev/null || echo \"$TODAY_EPOCH\")\n514\t days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))\n515\t\n516\t diversity_s=$(_diversity_score \"$n_users\")\n517\t recency_s=$(_recency_score \"$days_ago\")\n518\t volume_s=$(_volume_score \"$capped_vol\")\n519\t total=$(( diversity_s + recency_s + volume_s ))\n520\t\n521\t log \" [${total}] $series_name | users: $n_users | vol(capped): $capped_vol | ${days_ago}d ago\"\n522\t S1_SCORED+=(\"${total}|${series_tmdb}|${series_name}\")\n523\tdone\n524\t\n525\tIFS=$'\\n' S1_SORTED=($(printf '%s\\n' \"${S1_SCORED[@]}\" | sort -t'|' -k1 -rn 2>/dev/null))\n526\t\n527\techo \"\"\n528\techo \"━━━ $ICON_SUMMARY Stage 1 Results (top ${MAX_SEEDS} seeds) ━━━\"\n529\t\n530\tSEEDS_TMDB=()\n531\t\n532\tfor _entry in \"${S1_SORTED[@]}\"; do\n533\t (( ${#SEEDS_TMDB[@]} >= MAX_SEEDS )) && break\n534\t _score=\"${_entry%%|*}\"\n535\t _rest=\"${_entry#*|}\"\n536\t _tmdb=\"${_rest%%|*}\"\n537\t _name=\"${_rest#*|}\"\n538\t SEEDS_TMDB+=(\"$_tmdb\")\n539\t n_users=\"${SERIES_UNIQUE_USERS[$_tmdb]:-0}\"\n540\t capped_vol=\"${SERIES_CAPPED_VOLUME[$_tmdb]:-0}\"\n541\t printf \" Seed [%3s] %-40s | users: %s | vol: %s\\n\" \"$_score\" \"$_name\" \"$n_users\" \"$capped_vol\"\n542\tdone\n543\t\n544\tif [[ \"${#SEEDS_TMDB[@]}\" -eq 0 ]]; then\n545\t warn \"No series qualify as seeds — nothing to discover from\"\n546\t exit 0\n547\tfi\n548\t\n549\tlog \"${#SEEDS_TMDB[@]} seed(s) selected\"\n550\t\n551\t# ==============================================================================================\n552\t# ━━━ Stage 2: TMDB TV Recommendations ━━━\n553\t# ==============================================================================================\n554\techo \"\"\n555\techo \"━━━ $ICON_SYNC Stage 2: TMDB Recommendations ━━━\"\n556\t\n557\tdeclare -A CANDIDATE_SEEDS # tmdb_id → seed count\n558\tdeclare -A CANDIDATE_TITLE # tmdb_id → show name\n559\tdeclare -A CANDIDATE_RATING # tmdb_id → vote_avg_int\n560\tdeclare -A CANDIDATE_VOTES # tmdb_id → vote_count\n561\t\n562\tfor seed_tmdb in \"${SEEDS_TMDB[@]}\"; do\n563\t log \" Seed TMDB: $seed_tmdb\"\n564\t\n565\t RECS_JSON=$(_tmdb_tv_recommendations \"$seed_tmdb\")\n566\t if [[ -z \"$RECS_JSON\" ]] || echo \"$RECS_JSON\" | jq -e '.results == [] or .results == null' >/dev/null 2>&1; then\n567\t warn \" No TMDB recommendations for TMDB:$seed_tmdb\"\n568\t continue\n569\t fi\n570\t\n571\t while IFS='|' read -r rec_id rec_name rec_avg rec_votes; do\n572\t [[ -z \"$rec_id\" || \"$rec_id\" == \"null\" ]] && continue\n573\t [[ \"$rec_id\" == \"$seed_tmdb\" ]] && continue\n574\t\n575\t vote_avg_int=$(echo \"$rec_avg\" | awk '{printf \"%d\", $1 * 10 + 0.5}' 2>/dev/null)\n576\t vote_avg_int=${vote_avg_int:-0}\n577\t rec_votes=${rec_votes:-0}\n578\t\n579\t CANDIDATE_SEEDS[\"$rec_id\"]=$(( ${CANDIDATE_SEEDS[\"$rec_id\"]:-0} + 1 ))\n580\t CANDIDATE_TITLE[\"$rec_id\"]=\"$rec_name\"\n581\t CANDIDATE_RATING[\"$rec_id\"]=\"$vote_avg_int\"\n582\t CANDIDATE_VOTES[\"$rec_id\"]=\"$rec_votes\"\n583\t done < <(echo \"$RECS_JSON\" | jq -r '.results[] |\n584\t [\n585\t (.id | tostring),\n586\t .name,\n587\t (.vote_average | tostring),\n588\t (.vote_count | tostring)\n589\t ] | join(\"|\")' 2>/dev/null)\n590\tdone\n591\t\n592\tCANDIDATE_COUNT=${#CANDIDATE_SEEDS[@]}\n593\tlog \"$CANDIDATE_COUNT discovery candidates from TMDB\"\n594\t\n595\tif [[ \"$CANDIDATE_COUNT\" -eq 0 ]]; then\n596\t warn \"No candidates returned from TMDB — check API key\"\n597\t exit 0\n598\tfi\n599\t\n600\t# ==============================================================================================\n601\t# ━━━ Fetch Existing Sonarr Library ━━━\n602\t# ==============================================================================================\n603\techo \"\"\n604\techo \"━━━ $ICON_SYNC Existing Libraries ━━━\"\n605\t\n606\tSONARR_SERIES_JSON=$(_sonarr_get \"series\") || { error \"Could not fetch Sonarr library\"; exit 1; }\n607\t\n608\tdeclare -A SONARR_TVDB # tvdb_id → 1\n609\tdeclare -A SONARR_TMDB # tmdb_id → 1 (Sonarr v4 exposes tmdbId)\n610\t\n611\twhile IFS='|' read -r tvdb_id tmdb_id; do\n612\t [[ -n \"$tvdb_id\" && \"$tvdb_id\" != \"0\" ]] && SONARR_TVDB[\"$tvdb_id\"]=1\n613\t [[ -n \"$tmdb_id\" && \"$tmdb_id\" != \"0\" ]] && SONARR_TMDB[\"$tmdb_id\"]=1\n614\tdone < <(echo \"$SONARR_SERIES_JSON\" | jq -r '.[] |\n615\t [(.tvdbId // 0 | tostring), (.tmdbId // 0 | tostring)] | join(\"|\")\n616\t' 2>/dev/null)\n617\t\n618\tlog \"${#SONARR_TVDB[@]} series in Sonarr | ${#EMBY_TMDB_IDS[@]} series in Emby\"\n619\t\n620\t_in_sonarr() { [[ \"${SONARR_TVDB[\"$1\"]+x}\" || \"${SONARR_TMDB[\"$2\"]+x}\" ]]; }\n621\t_in_emby() { [[ \"${EMBY_TMDB_IDS[\"$1\"]+x}\" || \"${EMBY_TVDB_IDS[\"$2\"]+x}\" ]]; }\n622\t\n623\t# ==============================================================================================\n624\t# ━━━ Score Stage 2 Candidates ━━━\n625\t# ==============================================================================================\n626\techo \"\"\n627\techo \"━━━ $ICON_GEAR Stage 2: Scoring Candidates ━━━\"\n628\t\n629\tACCEPT_LIST=()\n630\tREJECT_LIST=()\n631\tALREADY_KNOWN=0\n632\tSKIP_COOLDOWN=0\n633\tSKIP_QUALITY=0\n634\t\n635\tfor rec_tmdb in \"${!CANDIDATE_SEEDS[@]}\"; do\n636\t seed_count=\"${CANDIDATE_SEEDS[\"$rec_tmdb\"]}\"\n637\t title=\"${CANDIDATE_TITLE[\"$rec_tmdb\"]}\"\n638\t vote_avg_int=\"${CANDIDATE_RATING[\"$rec_tmdb\"]}\"\n639\t vote_count=\"${CANDIDATE_VOTES[\"$rec_tmdb\"]}\"\n640\t\n641\t # Check Sonarr by TMDB (tvdb unknown at this point — we resolve it only at add time)\n642\t if [[ \"${SONARR_TMDB[\"$rec_tmdb\"]+x}\" ]]; then\n643\t (( ALREADY_KNOWN++ ))\n644\t log \" $ICON_SKIP In Sonarr: $title\"\n645\t continue\n646\t fi\n647\t\n648\t if [[ \"${EMBY_TMDB_IDS[\"$rec_tmdb\"]+x}\" ]]; then\n649\t (( ALREADY_KNOWN++ ))\n650\t log \" $ICON_SKIP In Emby: $title\"\n651\t continue\n652\t fi\n653\t\n654\t # Hard quality floor — skip before cooldown to avoid polluting history\n655\t if (( vote_count < MIN_VOTE_COUNT || vote_avg_int < MIN_RATING )); then\n656\t (( SKIP_QUALITY++ ))\n657\t log \" $ICON_SKIP Below quality floor (rating: $(_fmt_rating \"$vote_avg_int\") | votes: $vote_count): $title\"\n658\t continue\n659\t fi\n660\t\n661\t if [[ -f \"$HISTORY_FILE\" ]]; then\n662\t last_rejection=$(grep -i \"^REJECT|${rec_tmdb}|\" \"$HISTORY_FILE\" 2>/dev/null | tail -1 | cut -d'|' -f3)\n663\t if [[ -n \"$last_rejection\" ]]; then\n664\t last_epoch=$(date -d \"$last_rejection\" +%s 2>/dev/null || echo 0)\n665\t days_since=$(( (TODAY_EPOCH - last_epoch) / 86400 ))\n666\t if (( days_since < REJECT_COOLDOWN )); then\n667\t (( SKIP_COOLDOWN++ ))\n668\t log \" $ICON_SKIP Cooldown (rejected ${days_since}d ago): $title\"\n669\t continue\n670\t fi\n671\t fi\n672\t fi\n673\t\n674\t breadth_s=$(_breadth_score \"$seed_count\")\n675\t rating_s=$(_rating_score_s2 \"$vote_avg_int\")\n676\t votes_s=$(_votes_score \"$vote_count\")\n677\t total=$(( breadth_s + rating_s + votes_s ))\n678\t\n679\t entry=\"${total}|${rec_tmdb}|${title}|${seed_count}|${vote_avg_int}|${vote_count}\"\n680\t if (( total >= THRESHOLD )); then\n681\t ACCEPT_LIST+=(\"$entry\")\n682\t else\n683\t REJECT_LIST+=(\"$entry\")\n684\t fi\n685\t\n686\t log \" [${total}] $title (seeds: $seed_count | rating: $(_fmt_rating \"$vote_avg_int\") | votes: $vote_count)\"\n687\tdone\n688\t\n689\t# ==============================================================================================\n690\t# ━━━ Results ━━━\n691\t# ==============================================================================================\n692\techo \"\"\n693\techo \"━━━ $ICON_SUMMARY Stage 2 Results (threshold: ${THRESHOLD}, max: ${MAX_ADDS}) ━━━\"\n694\t\n695\tIFS=$'\\n' _ALL_ACCEPTS=($(printf '%s\\n' \"${ACCEPT_LIST[@]}\" | sort -t'|' -k1 -rn 2>/dev/null))\n696\tIFS=$'\\n' SORTED_REJECTS=($(printf '%s\\n' \"${REJECT_LIST[@]}\" | sort -t'|' -k1 -rn 2>/dev/null))\n697\t\n698\tSORTED_ACCEPTS=(\"${_ALL_ACCEPTS[@]:0:$MAX_ADDS}\")\n699\tfor (( _i=MAX_ADDS; _i<${#_ALL_ACCEPTS[@]}; _i++ )); do\n700\t SORTED_REJECTS+=(\"${_ALL_ACCEPTS[$_i]}\")\n701\tdone\n702\t\n703\t_print_row() {\n704\t local label=\"$1\" entry=\"$2\"\n705\t IFS='|' read -r score rec_tmdb title seeds avg_int votes <<< \"$entry\"\n706\t local rating_fmt\n707\t rating_fmt=$(_fmt_rating \"$avg_int\")\n708\t printf \" %-8s [%3s] %-45s seeds: %s | rating: %s | votes: %s\\n\" \\\n709\t \"$label\" \"$score\" \"$title\" \"$seeds\" \"$rating_fmt\" \"$votes\"\n710\t}\n711\t\n712\tfor entry in \"${SORTED_ACCEPTS[@]}\"; do [[ -n \"$entry\" ]] && _print_row \"ACCEPT\" \"$entry\"; done\n713\tfor entry in \"${SORTED_REJECTS[@]}\"; do [[ -n \"$entry\" ]] && _print_row \"REJECT\" \"$entry\"; done\n714\t\n715\techo \"\"\n716\techo \" Already in library: $ALREADY_KNOWN | Below quality floor: $SKIP_QUALITY | Cooldown: $SKIP_COOLDOWN\"\n717\techo \" ACCEPT: ${#SORTED_ACCEPTS[@]} | REJECT: ${#SORTED_REJECTS[@]}\"\n718\t\n719\tif [[ \"$DRY_RUN\" == true ]]; then\n720\t warn \"DRY RUN complete — run without --dry-run to add accepted shows\"\n721\t exit 0\n722\tfi\n723\t\n724\tif [[ \"${#SORTED_ACCEPTS[@]}\" -eq 0 ]]; then\n725\t log \"No shows above threshold — nothing to add\"\n726\t for entry in \"${SORTED_REJECTS[@]}\"; do\n727\t [[ -z \"$entry\" ]] && continue\n728\t IFS='|' read -r score rec_tmdb title _ <<< \"$entry\"\n729\t echo \"REJECT|${rec_tmdb}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n730\t done\n731\t exit 0\n732\tfi\n733\t\n734\t# ==============================================================================================\n735\t# ━━━ Add to Sonarr ━━━\n736\t# ==============================================================================================\n737\techo \"\"\n738\techo \"━━━ $ICON_SYNC Adding to Sonarr ━━━\"\n739\t\n740\tSONARR_ROOT=$(_sonarr_get \"rootfolder\" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null)\n741\tif [[ -z \"$SONARR_ROOT\" ]]; then error \"Could not determine Sonarr root folder\"; exit 1; fi\n742\t\n743\tQUALITY_ID=$(_sonarr_get \"qualityprofile\" | jq -r '.[0].id' 2>/dev/null)\n744\tlog \"Root folder: $SONARR_ROOT | Quality profile: $QUALITY_ID | Monitor: $MONITOR_MODE\"\n745\t\n746\tADDED=0\n747\tFAILED=0\n748\t\n749\tfor entry in \"${SORTED_ACCEPTS[@]}\"; do\n750\t [[ -z \"$entry\" ]] && continue\n751\t IFS='|' read -r score rec_tmdb title seeds avg_int votes <<< \"$entry\"\n752\t\n753\t # Resolve TVDB ID via TMDB external_ids — Sonarr lookup requires tvdb: term\n754\t EXT_JSON=$(_tmdb_external_ids \"$rec_tmdb\")\n755\t tvdb_id=$(echo \"$EXT_JSON\" | jq -r '.tvdb_id // empty' 2>/dev/null)\n756\t\n757\t if [[ -z \"$tvdb_id\" ]]; then\n758\t warn \" $ICON_WARN No TVDB ID from TMDB for: $title (TMDB: $rec_tmdb)\"\n759\t echo \"FAIL|${rec_tmdb}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n760\t (( FAILED++ ))\n761\t continue\n762\t fi\n763\t\n764\t # Check if already in Sonarr by TVDB (may have been added since we loaded the library)\n765\t if [[ \"${SONARR_TVDB[\"$tvdb_id\"]+x}\" ]]; then\n766\t log \" $ICON_SKIP Already in Sonarr (TVDB $tvdb_id): $title\"\n767\t (( ALREADY_KNOWN++ ))\n768\t continue\n769\t fi\n770\t\n771\t # Check if already in Emby by TVDB\n772\t if [[ \"${EMBY_TVDB_IDS[\"$tvdb_id\"]+x}\" ]]; then\n773\t log \" $ICON_SKIP Already in Emby (TVDB $tvdb_id): $title\"\n774\t (( ALREADY_KNOWN++ ))\n775\t continue\n776\t fi\n777\t\n778\t LOOKUP=$(_sonarr_lookup \"tvdb:$tvdb_id\")\n779\t\n780\t if [[ -z \"$LOOKUP\" ]] || echo \"$LOOKUP\" | jq -e '. == [] or . == null' >/dev/null 2>&1; then\n781\t warn \" $ICON_WARN No match in Sonarr lookup: $title (TVDB: $tvdb_id)\"\n782\t echo \"FAIL|${rec_tmdb}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n783\t (( FAILED++ ))\n784\t continue\n785\t fi\n786\t\n787\t SERIES_DATA=$(echo \"$LOOKUP\" | jq '.[0]' 2>/dev/null)\n788\t SONARR_TITLE=$(echo \"$SERIES_DATA\" | jq -r '.title // \"\"' 2>/dev/null)\n789\t\n790\t if [[ -z \"$SONARR_TITLE\" ]]; then\n791\t warn \" $ICON_WARN Empty result for: $title\"\n792\t echo \"FAIL|${rec_tmdb}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n793\t (( FAILED++ ))\n794\t continue\n795\t fi\n796\t\n797\t PAYLOAD=$(echo \"$SERIES_DATA\" | jq \\\n798\t --arg root \"$SONARR_ROOT\" \\\n799\t --argjson qid \"$QUALITY_ID\" \\\n800\t --arg mon \"$MONITOR_MODE\" \\\n801\t '. + {\n802\t rootFolderPath: $root,\n803\t qualityProfileId: $qid,\n804\t monitored: true,\n805\t seasonFolder: true,\n806\t addOptions: {\n807\t monitor: $mon,\n808\t searchForMissingEpisodes: false,\n809\t searchForCutoffUnmetEpisodes: false\n810\t }\n811\t }' 2>/dev/null)\n812\t\n813\t RESULT=$(_sonarr_post \"$PAYLOAD\")\n814\t\n815\t if echo \"$RESULT\" | jq -e '.id' >/dev/null 2>&1; then\n816\t SERIES_ID=$(echo \"$RESULT\" | jq -r '.id')\n817\t _sonarr_command \"{\\\"name\\\":\\\"SeriesSearch\\\",\\\"seriesId\\\":${SERIES_ID}}\" >/dev/null\n818\t log \" $ICON_DONE Added: $SONARR_TITLE (score: $score | seeds: $seeds | TVDB: $tvdb_id)\"\n819\t echo \"ACCEPT|${rec_tmdb}|${TODAY}|${SONARR_TITLE}\" >> \"$HISTORY_FILE\" 2>/dev/null\n820\t (( ADDED++ ))\n821\t else\n822\t warn \" $ICON_WARN Failed to add: $title\"\n823\t log \" $(echo \"$RESULT\" | head -c 200)\"\n824\t echo \"FAIL|${rec_tmdb}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n825\t (( FAILED++ ))\n826\t fi\n827\tdone\n828\t\n829\tfor entry in \"${SORTED_REJECTS[@]}\"; do\n830\t [[ -z \"$entry\" ]] && continue\n831\t IFS='|' read -r score rec_tmdb title _ <<< \"$entry\"\n832\t echo \"REJECT|${rec_tmdb}|${TODAY}|${title}\" >> \"$HISTORY_FILE\" 2>/dev/null\n833\tdone\n834\t\n835\t# ==============================================================================================\n836\t# ━━━ Summary ━━━\n837\t# ==============================================================================================\n838\techo \"\"\n839\techo \"━━━━━ $ICON_SUMMARY DISCOVERY COMPLETE ━━━━━\"\n840\techo \" $ICON_DONE Added: $ADDED\"\n841\t[[ \"$FAILED\" -gt 0 ]] && echo \" $ICON_WARN Failed: $FAILED\"\n842\techo \" $ICON_SKIP Rejected: ${#SORTED_REJECTS[@]}\"\n843\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n844\t\n845\t[[ \"$ADDED\" -gt 0 ]] && notify \\\n846\t \"$ADDED show(s) added to Sonarr via discovery on $(hostname)\" \\\n847\t \"Sonarr Discovery\" \"normal\"\n848\t\n849\texit 0\n850\t"}]},"uuid":"e5943f62-353f-40c1-a9d8-5cd3b66a93aa","timestamp":"2026-06-14T05:32:15.918Z","sourceToolAssistantUUID":"8619cd59-ba76-44dd-bfb4-46b7f3853e7c","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"bca39942-ee58-4c23-83c5-902ea5529b29","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018bGpkUGfCkyxu1kxJcDh28","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ============================= Radarr — TMDb Removed ==========================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Remove movies from Radarr that TMDb has dropped. Radarr marks these with\n9\t# status=\"deleted\" — they generate health errors and can never be monitored\n10\t# or downloaded. Most are announced-but-never-released films delisted before\n11\t# release.\n12\t#\n13\t# ==============================================================================================\n14\t# OPERATIONAL MODEL\n15\t# ==============================================================================================\n16\t#\n17\t# Queries Radarr API for movies with status=\"deleted\" (TMDb removal marker)\n18\t# Reports each entry with file status and size\n19\t# Removes the movie record from Radarr\n20\t# Optionally deletes associated files (disabled by default — most have none)\n21\t# Optionally adds to Radarr's import exclusion list (default: true — prevents re-add)\n22\t#\n23\t# Files are NOT deleted by default — use --delete-files to also remove from disk.\n24\t# Per-deletion output is always visible — deletions are never silently swallowed.\n25\t#\n26\t# ==============================================================================================\n27\t# OPERATIONAL SAFEGUARDS\n28\t# ==============================================================================================\n29\t#\n30\t# detect_hosts() — exits cleanly if RADARR_URL empty (Radarr not on this host)\n31\t# API pre-flight — verifies Radarr reachable before querying\n32\t# Dry-run mode — full preview without removing anything\n33\t#\n34\t# ==============================================================================================\n35\t# CONFIGURATION\n36\t# ==============================================================================================\n37\t#\n38\t# master.conf\n39\t#\n40\t# RADARR_DROPPED_ADD_EXCLUSION — add removed movies to import exclusion (default: true)\n41\t#\n42\t# host*.conf\n43\t#\n44\t# HOST1_RADARR_URL / HOST1_RADARR_API_KEY\n45\t# Aliased by detect_hosts() — script uses RADARR_URL / RADARR_API_KEY\n46\t#\n47\t# ==============================================================================================\n48\t# RUNTIME MODES\n49\t# ==============================================================================================\n50\t#\n51\t# radarr_tmdb_removed.sh — remove records, keep files, add exclusion\n52\t# radarr_tmdb_removed.sh --delete-files — also delete files from disk\n53\t# radarr_tmdb_removed.sh --dry-run — preview without removing anything\n54\t# radarr_tmdb_removed.sh --log — verbose output\n55\t# radarr_tmdb_removed.sh --status — show config and exit\n56\t#\n57\t# ==============================================================================================\n58\t\n59\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n60\t\n61\tsource \"$SCRIPT_DIR/../load_config.sh\"\n62\t\n63\t# ── Parse --delete-files before standard parse_args ───────────────────────────────────────────\n64\tDELETE_FILES=false\n65\tFILTERED_ARGS=()\n66\tfor arg in \"$@\"; do\n67\t if [[ \"$arg\" == \"--delete-files\" ]]; then\n68\t DELETE_FILES=true\n69\t else\n70\t FILTERED_ARGS+=(\"$arg\")\n71\t fi\n72\tdone\n73\t\n74\tparse_args \"${FILTERED_ARGS[@]}\"\n75\t\n76\t# ==============================================================================================\n77\t# ━━━ Setup ━━━\n78\t# ==============================================================================================\n79\tif [[ \"$EUID\" -ne 0 ]]; then\n80\t error \"Must be run as root\"\n81\t exit 1\n82\tfi\n83\t\n84\tif ! command -v curl >/dev/null 2>&1; then\n85\t error \"curl not found\"\n86\t exit 1\n87\tfi\n88\tif ! command -v jq >/dev/null 2>&1; then\n89\t error \"jq not found\"\n90\t exit 1\n91\tfi\n92\t\n93\tacquire_lock\n94\t\n95\tdetect_hosts\n96\t\n97\tif [[ -z \"$RADARR_URL\" ]]; then\n98\t echo \"Radarr not configured for $MY_ID — skipping\"\n99\t exit 0\n100\tfi\n101\t\n102\tADD_EXCLUSION=\"${RADARR_DROPPED_ADD_EXCLUSION:-true}\"\n103\t\n104\tlog \"$ICON_GEAR Config: url=${RADARR_URL} add-exclusion=${ADD_EXCLUSION} delete-files=${DELETE_FILES:-false}\"\n105\techo \" $MY_ID ($LOCAL_SERVER_NAME) — $RADARR_URL\"\n106\t[[ \"$DELETE_FILES\" == true ]] && warn \"DELETE FILES MODE — files will be removed from disk\"\n107\t[[ \"$DELETE_FILES\" == false ]] && echo \" Files: records only (use --delete-files to also remove from disk)\"\n108\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes will be made\"\n109\t\n110\t# ==============================================================================================\n111\t# ━━━ Status ━━━\n112\t# ==============================================================================================\n113\tif [[ \"$SHOW_STATUS\" == true ]]; then\n114\t echo \"\"\n115\t echo \"━━━━━ $ICON_SUMMARY STATUS ━━━━━\"\n116\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n117\t echo \"$ICON_GEAR Radarr URL: $RADARR_URL\"\n118\t echo \"$ICON_GEAR Add exclusion: $ADD_EXCLUSION\"\n119\t echo \"$ICON_GEAR Delete files: $DELETE_FILES\"\n120\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n121\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n122\t exit 0\n123\tfi\n124\t\n125\t# ==============================================================================================\n126\t# ━━━ Query Radarr ━━━\n127\t# ==============================================================================================\n128\techo \"\"\n129\techo \"━━━ $ICON_SYNC Querying Radarr ━━━\"\n130\t\n131\tif ! curl -sf --connect-timeout 5 --max-time 10 \\\n132\t \"$RADARR_URL/api/v3/system/status?apikey=$RADARR_API_KEY\" | jq -e '.version' >/dev/null 2>&1; then\n133\t error \"Radarr API unreachable at $RADARR_URL\"\n134\t exit 1\n135\tfi\n136\t\n137\tMOVIES=$(curl -sf --connect-timeout 5 --max-time 30 \\\n138\t \"$RADARR_URL/api/v3/movie?apikey=$RADARR_API_KEY\" 2>/dev/null)\n139\t\n140\tif [[ -z \"$MOVIES\" || \"$MOVIES\" == \"null\" ]]; then\n141\t error \"Radarr movie API returned empty\"\n142\t exit 1\n143\tfi\n144\t\n145\tTOTAL=$(echo \"$MOVIES\" | jq '. | length')\n146\tDROPPED=$(echo \"$MOVIES\" | jq '[.[] | select(.status == \"deleted\")] | length')\n147\techo \" $TOTAL movies total — $DROPPED dropped from TMDb\"\n148\t\n149\tif [[ \"$DROPPED\" -eq 0 ]]; then\n150\t log \"No TMDb-removed movies found — nothing to do\"\n151\t echo \"\"\n152\t echo \"━━━━━ $ICON_SUMMARY RADARR TMDB REMOVED SUMMARY ━━━━━\"\n153\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n154\t echo \"$ICON_DONE Status: nothing to remove\"\n155\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n156\t exit 0\n157\tfi\n158\t\n159\t# ==============================================================================================\n160\t# ━━━ Remove Dropped Movies ━━━\n161\t# ==============================================================================================\n162\techo \"\"\n163\techo \"━━━ $ICON_TRASH Remove TMDb-Dropped Movies ━━━\"\n164\t\n165\tSTART=$(date +%s)\n166\tREMOVED=()\n167\tFAILED=()\n168\tFILES_DELETED=0\n169\tFILES_SKIPPED=0\n170\t\n171\twhile IFS=$'\\t' read -r id title year tmdb_id has_file file_size; do\n172\t [[ -z \"$id\" ]] && continue\n173\t\n174\t SIZE_HUMAN=\"\"\n175\t if [[ \"$has_file\" == \"true\" && \"$file_size\" -gt 0 ]]; then\n176\t SIZE_HUMAN=$(awk \"BEGIN {printf \\\"%.1fGB\\\", $file_size / 1073741824}\")\n177\t log \"$ICON_WARN $title ($year) [tmdbid $tmdb_id] — HAS FILE: $SIZE_HUMAN\"\n178\t else\n179\t log \"$ICON_TRASH $title ($year) [tmdbid $tmdb_id] — no file\"\n180\t fi\n181\t\n182\t if [[ \"$DRY_RUN\" == true ]]; then\n183\t warn \"DRY RUN — would remove: $title\"\n184\t [[ \"$DELETE_FILES\" == true && \"$has_file\" == \"true\" ]] && \\\n185\t warn \"DRY RUN — would delete file: $SIZE_HUMAN\"\n186\t REMOVED+=(\"$title\")\n187\t continue\n188\t fi\n189\t\n190\t DELETE_PARAM=\"false\"\n191\t if [[ \"$DELETE_FILES\" == true && \"$has_file\" == \"true\" ]]; then\n192\t DELETE_PARAM=\"true\"\n193\t fi\n194\t\n195\t RESP=$(curl -sf -X DELETE --connect-timeout 5 --max-time 15 \\\n196\t \"$RADARR_URL/api/v3/movie/${id}?deleteFiles=${DELETE_PARAM}&addImportExclusion=${ADD_EXCLUSION}&apikey=$RADARR_API_KEY\" \\\n197\t 2>/dev/null)\n198\t CURL_EXIT=$?\n199\t\n200\t if [[ \"$CURL_EXIT\" -eq 0 ]]; then\n201\t log \" Removed from Radarr ✅\"\n202\t REMOVED+=(\"$title\")\n203\t if [[ \"$DELETE_PARAM\" == \"true\" ]]; then\n204\t (( FILES_DELETED++ ))\n205\t elif [[ \"$has_file\" == \"true\" ]]; then\n206\t (( FILES_SKIPPED++ ))\n207\t fi\n208\t else\n209\t warn \" Failed to remove $title (curl exit $CURL_EXIT)\"\n210\t FAILED+=(\"$title\")\n211\t fi\n212\t\n213\tdone < <(echo \"$MOVIES\" | jq -r '\n214\t .[] | select(.status == \"deleted\") |\n215\t [\n216\t (.id | tostring),\n217\t .title,\n218\t (.year | tostring),\n219\t (.tmdbId | tostring),\n220\t (if .hasFile then \"true\" else \"false\" end),\n221\t (if .movieFile.size? then (.movieFile.size | tostring) else \"0\" end)\n222\t ] | @tsv\n223\t')\n224\t\n225\tEND=$(date +%s)\n226\t\n227\t# ==============================================================================================\n228\t# ━━━ Summary ━━━\n229\t# ==============================================================================================\n230\techo \"\"\n231\techo \"━━━━━ $ICON_SUMMARY RADARR TMDB REMOVED SUMMARY ━━━━━\"\n232\techo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n233\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n234\techo \"$ICON_TRASH Removed: ${#REMOVED[@]} of $DROPPED\"\n235\t[[ ${#FAILED[@]} -gt 0 ]] && echo \"$ICON_ERROR Failed: ${FAILED[*]}\"\n236\t[[ \"$FILES_DELETED\" -gt 0 ]] && echo \"$ICON_TRASH Files deleted: $FILES_DELETED\"\n237\t[[ \"$FILES_SKIPPED\" -gt 0 ]] && echo \"$ICON_WARN Files kept: $FILES_SKIPPED (had files — use --delete-files to remove)\"\n238\t[[ \"$ADD_EXCLUSION\" == \"true\" ]] && echo \"$ICON_GEAR Import exclusion added for removed entries\"\n239\t\n240\tif [[ \"$DRY_RUN\" == true ]]; then\n241\t warn \"DRY RUN — no changes made\"\n242\telif [[ ${#FAILED[@]} -eq 0 ]]; then\n243\t echo \"$ICON_DONE Status: done ✅\"\n244\telse\n245\t warn \"Status: ${#FAILED[@]} removal(s) failed\"\n246\tfi\n247\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n248\t\n249\t[[ ${#FAILED[@]} -gt 0 ]] && exit 1\n250\texit 0\n251\t"}]},"uuid":"868797f6-76b3-4bb3-895f-b55c6f969b18","timestamp":"2026-06-14T05:32:15.826Z","sourceToolAssistantUUID":"bca39942-ee58-4c23-83c5-902ea5529b29","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"868797f6-76b3-4bb3-895f-b55c6f969b18","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MsxWmDpZmx3MDRrWqCLSZv","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/sonarr_cleanup.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"cf926a08-f6b3-44d9-94d5-d774c12d9461","timestamp":"2026-06-14T05:32:15.986Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"cf926a08-f6b3-44d9-94d5-d774c12d9461","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MsxWmDpZmx3MDRrWqCLSZv","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ================================= Sonarr Cleanup =============================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Delete orphaned TV episode files not tracked by Sonarr. Queries the API for\n9\t# all tracked episode file paths, walks the library on disk, and removes anything\n10\t# untracked that is old enough to be past the import window. Triggers an Emby\n11\t# library clean after each deletion run so ghost entries disappear immediately.\n12\t#\n13\t# ==============================================================================================\n14\t# OPERATIONAL MODEL\n15\t# ==============================================================================================\n16\t#\n17\t# Every file encountered on disk is classified into one of five categories:\n18\t#\n19\t# TRACKED — Sonarr API knows this exact path → leave it alone\n20\t# PROTECTED — matches SONARR_PROTECTED_PATTERNS → never delete\n21\t# ORPHAN — video file, not tracked, older than SONARR_ORPHAN_AGE → delete\n22\t# JUNK — not a video extension, not protected → delete regardless of age\n23\t# RECENT — not tracked, under SONARR_ORPHAN_AGE → skip (may be mid-import)\n24\t#\n25\t# Sonarr generates show artwork (*.jpg), metadata (*.nfo), and manages subtitles\n26\t# (*.srt, *.sub, *.ass) but does NOT include these in its tracked file API response.\n27\t# Without PROTECTED classification these would be deleted — breaking Sonarr and\n28\t# Emby metadata display.\n29\t#\n30\t# After deletions: notify_emby_scan() triggers Emby \"Clean Missing Files\" task.\n31\t# Emby removes ghost entries immediately — no user-facing file-not-found errors.\n32\t#\n33\t# ==============================================================================================\n34\t# OPERATIONAL SAFEGUARDS\n35\t# ==============================================================================================\n36\t#\n37\t# Six gates — ALL must pass before any file is touched:\n38\t# 1. Container running and not starting/unhealthy\n39\t# 2. API reachable\n40\t# 3. API version matches SONARR_VERSION_MAJOR in master.conf\n41\t# 4. Series count > 0\n42\t# 5. Tracked file count > 0\n43\t# 6. Deletion size < SONARR_MAX_DELETE_GB — or --i-know-what-im-doing required\n44\t#\n45\t# acquire_lock \"wait\" — large scans take time, wait for previous run to finish\n46\t# jq + curl validation — exits if either tool missing\n47\t# DOCKER_TIMEOUT — container checks protected against daemon hangs\n48\t# notify_emby_scan() — triggers Emby clean after deletion\n49\t# platform_require_cmd — notify script validated before use\n50\t# Silent by default — orphans/junk warn(), clean library logs silently\n51\t#\n52\t# ==============================================================================================\n53\t# CONFIGURATION\n54\t# ==============================================================================================\n55\t#\n56\t# host*.conf\n57\t#\n58\t# HOST*_SONARR_URL / HOST*_SONARR_API_KEY / HOST*_SONARR_TV_ROOT\n59\t# HOST*_SONARR_PATH_MAP — container path → host path translation\n60\t# All aliased by detect_hosts() — script uses unprefixed names\n61\t#\n62\t# master.conf\n63\t#\n64\t# SONARR_ORPHAN_AGE — days before untracked file eligible for deletion\n65\t# SONARR_MAX_DELETE_GB — require --i-know-what-im-doing above this\n66\t# SONARR_EXTENSIONS — video file extensions for orphan classification\n67\t# SONARR_PROTECTED_PATTERNS — file patterns never deleted\n68\t# SONARR_VERSION_MAJOR — expected Sonarr major version for API safety check\n69\t# SONARR_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)\n70\t# ARR_CLEANUP_STATS — stats file path (read by coffee report)\n71\t#\n72\t# ==============================================================================================\n73\t# RUNTIME MODES\n74\t# ==============================================================================================\n75\t#\n76\t# sonarr_cleanup.sh — normal run\n77\t# sonarr_cleanup.sh --dry-run — preview, no deletions\n78\t# sonarr_cleanup.sh --log — verbose output\n79\t# sonarr_cleanup.sh --status — show config and exit\n80\t# sonarr_cleanup.sh --i-know-what-im-doing — bypass size threshold\n81\t# sonarr_cleanup.sh --i-know-what-im-doing --skip-strike-list — NUCLEAR MODE\n82\t#\n83\t# NUCLEAR MODE: both flags bypass age check AND size threshold. User accepts full\n84\t# responsibility — the flag name is long and annoying by design.\n85\t#\n86\t# ==============================================================================================\n87\t\n88\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n89\t\n90\tsource \"$SCRIPT_DIR/../load_config.sh\"\n91\t\n92\t# ── Special flag pre-processing ───────────────────────────────────────────────────────────────\n93\tI_KNOW=false\n94\tSKIP_STRIKES=false\n95\tFILTERED_ARGS=()\n96\tfor arg in \"$@\"; do\n97\t case \"$arg\" in\n98\t --i-know-what-im-doing) I_KNOW=true ;;\n99\t --skip-strike-list) SKIP_STRIKES=true ;;\n100\t *) FILTERED_ARGS+=(\"$arg\") ;;\n101\t esac\n102\tdone\n103\t\n104\tparse_args \"${FILTERED_ARGS[@]}\"\n105\t\n106\t# ── Nuclear mode warning ──────────────────────────────────────────────────────────────────────\n107\tif [[ \"$I_KNOW\" == true ]] && [[ \"$SKIP_STRIKES\" == true ]] && [[ \"$DRY_RUN\" != true ]]; then\n108\t echo \"\"\n109\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n110\t echo \"⚠️ WARNING — NUCLEAR MODE ACTIVE\"\n111\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n112\t echo \" Flags: --i-know-what-im-doing --skip-strike-list\"\n113\t echo \" Strike system: BYPASSED — deletes on first pass\"\n114\t echo \" Size threshold: BYPASSED — no GB limit\"\n115\t echo \" Data recovery: NOT POSSIBLE after deletion\"\n116\t echo \"\"\n117\t echo \" Review --dry-run output before proceeding.\"\n118\t echo \" You have 10 seconds to cancel (Ctrl+C)...\"\n119\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n120\t sleep 10\n121\t echo \" Proceeding...\"\n122\t echo \"\"\n123\tfi\n124\t\n125\t# ==============================================================================================\n126\t# ━━━ Setup ━━━\n127\t# ==============================================================================================\n128\tif [[ \"$EUID\" -ne 0 ]]; then\n129\t error \"Must be run as root\"\n130\t exit 1\n131\tfi\n132\t\n133\tif ! command -v curl >/dev/null 2>&1; then\n134\t error \"curl not found — required for Sonarr API calls\"\n135\t exit 1\n136\tfi\n137\t\n138\tif ! command -v jq >/dev/null 2>&1; then\n139\t error \"jq not found — required for JSON parsing\"\n140\t notify \"Sonarr cleanup failed on $(hostname) — jq not installed\" \"Sonarr Cleanup\" \"warning\"\n141\t exit 1\n142\tfi\n143\t\n144\t\n145\tacquire_lock \"wait\"\n146\tTMP_DIR=\"/tmp/sonarr_cleanup_$$\"\n147\tmkdir -p \"$TMP_DIR\"\n148\ttrap \"_release_all_locks; rm -rf $TMP_DIR\" EXIT\n149\t\n150\tif ! command -v docker &>/dev/null; then\n151\t error \"Docker command not found\"\n152\t exit 1\n153\tfi\n154\t\n155\t# detect_hosts() sets MY_ID and aliases SONARR_URL, SONARR_API_KEY, SONARR_TV_ROOT\n156\tdetect_hosts\n157\t\n158\tDOCKER_TIMEOUT=15\n159\tSONARR_CONTAINER=\"Sonarr\"\n160\t\n161\t# Build path map from MY_ID's Sonarr path map\n162\tdeclare -A ARR_PATH_MAP\n163\tlocal_path_map_var=\"${MY_ID}_SONARR_PATH_MAP\"\n164\teval \"for key in \\\"\\${!${local_path_map_var}[@]}\\\"; do\n165\t ARR_PATH_MAP[\\\"\\$key\\\"]=\\\"\\${${local_path_map_var}[\\$key]}\\\"\n166\tdone\"\n167\t\n168\trequire_var SONARR_URL\n169\trequire_var SONARR_API_KEY\n170\trequire_var SONARR_TV_ROOT\n171\t\n172\tif [[ ! -d \"$SONARR_TV_ROOT\" ]]; then\n173\t error \"TV root not found: $SONARR_TV_ROOT\"\n174\t notify \"Sonarr cleanup failed on $(hostname) — TV root not found: $SONARR_TV_ROOT\" \\\n175\t \"Sonarr Cleanup\" \"warning\"\n176\t exit 1\n177\tfi\n178\t\n179\tlog \"$ICON_GEAR Config: url=${SONARR_URL} root=${SONARR_TV_ROOT}\"\n180\techo \" $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_URL\"\n181\t\n182\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no files will be deleted\"\n183\t[[ \"$I_KNOW\" == true ]] && warn \"OVERRIDE — --i-know-what-im-doing active\"\n184\t[[ \"$SKIP_STRIKES\" == true ]] && warn \"OVERRIDE — --skip-strike-list active — age check bypassed\"\n185\t\n186\t# ==============================================================================================\n187\t# ━━━ Status ━━━\n188\t# ==============================================================================================\n189\tif [[ \"$SHOW_STATUS\" == true ]]; then\n190\t echo \"\"\n191\t echo \"━━━━━ $ICON_SUMMARY STATUS ━━━━━\"\n192\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n193\t echo \"$ICON_GEAR Sonarr URL: $SONARR_URL\"\n194\t echo \"$ICON_GEAR TV root: $SONARR_TV_ROOT\"\n195\t echo \"$ICON_TIME Orphan age: ${SONARR_ORPHAN_AGE} days\"\n196\t echo \"$ICON_GEAR Max delete: ${SONARR_MAX_DELETE_GB}GB (requires --i-know-what-im-doing)\"\n197\t echo \"$ICON_GEAR Sonarr ver: v${SONARR_VERSION_MAJOR} expected\"\n198\t echo \"$ICON_GEAR Extensions: ${SONARR_EXTENSIONS[*]}\"\n199\t echo \"$ICON_GEAR Protected patterns: ${SONARR_PROTECTED_PATTERNS[*]}\"\n200\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n201\t echo \"$ICON_GEAR I know: $I_KNOW\"\n202\t echo \"$ICON_GEAR Skip strikes: $SKIP_STRIKES\"\n203\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n204\t exit 0\n205\tfi\n206\t\n207\t# ==============================================================================================\n208\t# ━━━ Safety Layer 1 — Container Health ━━━\n209\t# ==============================================================================================\n210\techo \"\"\n211\techo \"━━━ $ICON_SHIELD Safety Checks ━━━\"\n212\t\n213\tCONTAINER_RUNNING=$(timeout \"$DOCKER_TIMEOUT\" docker inspect -f \\\n214\t '{{.State.Running}}' \"$SONARR_CONTAINER\" 2>/dev/null)\n215\tif [[ \"$CONTAINER_RUNNING\" != \"true\" ]]; then\n216\t error \"$SONARR_CONTAINER is not running — aborting\"\n217\t notify \"Sonarr cleanup aborted on $(hostname) — container not running\" \\\n218\t \"Sonarr Cleanup\" \"warning\"\n219\t exit 1\n220\tfi\n221\t\n222\tCONTAINER_HEALTH=$(timeout \"$DOCKER_TIMEOUT\" docker inspect -f \\\n223\t '{{.State.Health.Status}}' \"$SONARR_CONTAINER\" 2>/dev/null)\n224\tcase \"$CONTAINER_HEALTH\" in\n225\t healthy) info \"$SONARR_CONTAINER is healthy\" ;;\n226\t \"\") info \"$SONARR_CONTAINER has no health check — proceeding\" ;;\n227\t starting)\n228\t error \"$SONARR_CONTAINER is still starting — aborting\"\n229\t notify \"Sonarr cleanup aborted on $(hostname) — container still starting\" \\\n230\t \"Sonarr Cleanup\" \"warning\"\n231\t exit 1 ;;\n232\t unhealthy)\n233\t error \"$SONARR_CONTAINER is unhealthy — aborting\"\n234\t notify \"Sonarr cleanup aborted on $(hostname) — container unhealthy\" \\\n235\t \"Sonarr Cleanup\" \"warning\"\n236\t exit 1 ;;\n237\t *) warn \"$SONARR_CONTAINER health: $CONTAINER_HEALTH — proceeding with caution\" ;;\n238\tesac\n239\t\n240\tinfo \"Safety layer 1 passed — container healthy\"\n241\t\n242\t# ==============================================================================================\n243\t# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────\n244\t# ==============================================================================================\n245\t\n246\tsonarr_api() {\n247\t local endpoint=\"$1\"\n248\t local response http_code body\n249\t\n250\t response=$(curl -sf \\\n251\t --max-time 30 \\\n252\t -H \"X-Api-Key: $SONARR_API_KEY\" \\\n253\t -w \"\\n%{http_code}\" \\\n254\t \"${SONARR_URL}/api/v3/${endpoint}\" 2>/dev/null)\n255\t\n256\t http_code=$(echo \"$response\" | tail -1)\n257\t body=$(echo \"$response\" | head -n -1)\n258\t\n259\t if [[ \"$http_code\" != \"200\" ]]; then\n260\t error \"Sonarr API HTTP $http_code for: $endpoint\"\n261\t return 1\n262\t fi\n263\t echo \"$body\"\n264\t}\n265\t\n266\tis_video_file() {\n267\t local ext=\"${1##*.}\"\n268\t ext=\"${ext,,}\"\n269\t for valid_ext in \"${SONARR_EXTENSIONS[@]}\"; do\n270\t [[ \"$ext\" == \"$valid_ext\" ]] && return 0\n271\t done\n272\t return 1\n273\t}\n274\t\n275\tis_protected_file() {\n276\t local filename\n277\t filename=$(basename \"$1\")\n278\t for pattern in \"${SONARR_PROTECTED_PATTERNS[@]}\"; do\n279\t # shellcheck disable=SC2254\n280\t case \"$filename\" in\n281\t $pattern) return 0 ;;\n282\t esac\n283\t done\n284\t return 1\n285\t}\n286\t\n287\tformat_bytes() {\n288\t local bytes=$1\n289\t if (( bytes > 1073741824 )); then\n290\t awk \"BEGIN {printf \\\"%.1fGB\\\", $bytes / 1073741824}\"\n291\t elif (( bytes > 1048576 )); then\n292\t awk \"BEGIN {printf \\\"%.1fMB\\\", $bytes / 1048576}\"\n293\t else\n294\t echo \"${bytes}B\"\n295\t fi\n296\t}\n297\t\n298\t# ==============================================================================================\n299\t# ━━━ Pre-flight: Sonarr Import Scan ━━━\n300\t# ==============================================================================================\n301\techo \"\"\n302\techo \"━━━ $ICON_SYNC Pre-flight: Sonarr Import Scan ━━━\"\n303\t\n304\t# Fetch root folders from Sonarr API and translate container paths to host paths\n305\tmapfile -t SCAN_ROOTS < <(\n306\t sonarr_api \"rootfolder\" | \\\n307\t jq -r '.[].path' 2>/dev/null | \\\n308\t while IFS= read -r cp; do translate_path \"$cp\"; done\n309\t)\n310\t\n311\tif [[ \"${#SCAN_ROOTS[@]}\" -eq 0 ]]; then\n312\t error \"No root folders returned from Sonarr API — aborting\"\n313\t notify \"Sonarr cleanup aborted on $(hostname) — no root folders from API\" \\\n314\t \"Sonarr Cleanup\" \"warning\"\n315\t exit 1\n316\tfi\n317\t\n318\tinfo \"Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}\"\n319\t\n320\tinfo \"Triggering DownloadedEpisodesScan (all root folders)\"\n321\tSCAN_PAYLOAD='{\"name\": \"DownloadedEpisodesScan\"}'\n322\t\n323\tSCAN_RESPONSE=$(curl -sf --max-time 30 -X POST \\\n324\t -H \"X-Api-Key: $SONARR_API_KEY\" \\\n325\t -H \"Content-Type: application/json\" \\\n326\t -d \"$SCAN_PAYLOAD\" \\\n327\t \"${SONARR_URL}/api/v3/command\" 2>/dev/null)\n328\t\n329\tSCAN_CMD_ID=$(echo \"$SCAN_RESPONSE\" | jq -r '.id // empty' 2>/dev/null)\n330\t\n331\tif [[ -z \"$SCAN_CMD_ID\" ]]; then\n332\t warn \"Could not trigger import scan — proceeding without pre-flight\"\n333\telse\n334\t info \"Import scan queued (command ID: $SCAN_CMD_ID) — waiting for completion...\"\n335\t POLL_TIMEOUT=${SONARR_IMPORT_SCAN_TIMEOUT:-600}\n336\t POLLED=0\n337\t while [[ \"$POLLED\" -lt \"$POLL_TIMEOUT\" ]]; do\n338\t SCAN_STATUS=$(curl -sf --max-time 10 \\\n339\t -H \"X-Api-Key: $SONARR_API_KEY\" \\\n340\t \"${SONARR_URL}/api/v3/command/${SCAN_CMD_ID}\" 2>/dev/null | \\\n341\t jq -r '.status // empty' 2>/dev/null)\n342\t case \"$SCAN_STATUS\" in\n343\t completed) info \"Import scan complete ✅\"; break ;;\n344\t failed) warn \"Import scan reported failed — proceeding anyway\"; break ;;\n345\t esac\n346\t sleep 10\n347\t (( POLLED += 10 ))\n348\t [[ $(( POLLED % 60 )) -eq 0 ]] && log \" Still scanning... (${POLLED}s elapsed)\"\n349\t done\n350\t [[ \"$POLLED\" -ge \"$POLL_TIMEOUT\" ]] && \\\n351\t warn \"Import scan timed out after ${POLL_TIMEOUT}s — proceeding anyway\"\n352\tfi\n353\t\n354\t# ==============================================================================================\n355\t# ━━━ Fetch Sonarr Tracked Files ━━━\n356\t# ==============================================================================================\n357\techo \"\"\n358\techo \"━━━ $ICON_SYNC Fetching Sonarr Tracked Files ━━━\"\n359\t\n360\t# Safety Layer 2 — API reachability\n361\tif ! check_api \"$SONARR_URL\" \"Sonarr\" 10; then\n362\t notify \"Sonarr cleanup aborted on $(hostname) — API unreachable\" \"Sonarr Cleanup\" \"warning\"\n363\t exit 1\n364\tfi\n365\t\n366\t# Safety Layer 3 — API version check\n367\tcheck_arr_version \"$SONARR_URL\" \"$SONARR_API_KEY\" \"v3\" \"$SONARR_VERSION_MAJOR\" \"Sonarr\" || exit 1\n368\t\n369\tinfo \"Querying Sonarr API...\"\n370\t\n371\t# Fetch all series\n372\tSERIES_RESPONSE=$(sonarr_api \"series\") || {\n373\t error \"Failed to fetch series from Sonarr\"\n374\t notify \"Sonarr cleanup failed on $(hostname) — could not fetch series\" \\\n375\t \"Sonarr Cleanup\" \"warning\"\n376\t exit 1\n377\t}\n378\t\n379\tSERIES_IDS=$(echo \"$SERIES_RESPONSE\" | jq -r '.[].id' 2>/dev/null)\n380\tSERIES_COUNT=$(echo \"$SERIES_IDS\" | grep -c \".\" 2>/dev/null || echo 0)\n381\t\n382\t# Safety Layer 4 — series count > 0\n383\tif [[ \"$SERIES_COUNT\" -eq 0 ]]; then\n384\t error \"API returned 0 series — aborting to prevent mass deletion\"\n385\t notify \"Sonarr cleanup aborted on $(hostname) — 0 series returned\" \\\n386\t \"Sonarr Cleanup\" \"warning\"\n387\t exit 1\n388\tfi\n389\t\n390\tinfo \"Found $SERIES_COUNT series — fetching episode files...\"\n391\t\n392\tTRACKED_FILE=\"$TMP_DIR/tracked_paths.txt\"\n393\t> \"$TRACKED_FILE\"\n394\t\n395\tSERIES_INDEX=0\n396\twhile IFS= read -r series_id; do\n397\t [[ -z \"$series_id\" ]] && continue\n398\t (( SERIES_INDEX++ ))\n399\t [[ $(( SERIES_INDEX % 50 )) -eq 0 ]] && \\\n400\t log \"Fetching files: $SERIES_INDEX/$SERIES_COUNT series...\"\n401\t SERIES_FILES=$(sonarr_api \"episodefile?seriesId=${series_id}\" 2>/dev/null)\n402\t if [[ -n \"$SERIES_FILES\" ]]; then\n403\t while IFS= read -r api_path; do\n404\t [[ -z \"$api_path\" ]] && continue\n405\t translate_path \"$api_path\" >> \"$TRACKED_FILE\"\n406\t done < <(echo \"$SERIES_FILES\" | jq -r '.[].path' 2>/dev/null)\n407\t fi\n408\tdone <<< \"$SERIES_IDS\"\n409\t\n410\tsort -u \"$TRACKED_FILE\" -o \"$TRACKED_FILE\"\n411\t\n412\t# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file\n413\tdeclare -A TRACKED_MAP\n414\twhile IFS= read -r _tracked_path; do\n415\t [[ -n \"$_tracked_path\" ]] && TRACKED_MAP[\"$_tracked_path\"]=1\n416\tdone < \"$TRACKED_FILE\"\n417\tunset _tracked_path\n418\tinfo \"Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths\"\n419\tTRACKED_COUNT=$(wc -l < \"$TRACKED_FILE\")\n420\t\n421\t# Safety Layer 5 — tracked count > 0\n422\tif [[ \"$TRACKED_COUNT\" -eq 0 ]]; then\n423\t error \"API returned 0 tracked files — aborting to prevent mass deletion\"\n424\t notify \"Sonarr cleanup aborted on $(hostname) — 0 tracked files returned\" \\\n425\t \"Sonarr Cleanup\" \"warning\"\n426\t exit 1\n427\tfi\n428\t\n429\tinfo \"$SERIES_COUNT series | $TRACKED_COUNT tracked episode files\"\n430\t\n431\t# ==============================================================================================\n432\t# ━━━ Scan TV Root ━━━\n433\t# ==============================================================================================\n434\techo \"\"\n435\techo \"━━━ $ICON_CLEAN Scanning TV Root ━━━\"\n436\tinfo \"Root: $SONARR_TV_ROOT | Orphan age: ${SONARR_ORPHAN_AGE} days\"\n437\t\n438\tSTART=$(date +%s)\n439\tORPHAN_COUNT=0\n440\tJUNK_COUNT=0\n441\tRECENT_COUNT=0\n442\tPROTECTED_COUNT=0\n443\tORPHAN_BYTES=0\n444\tJUNK_BYTES=0\n445\t\n446\tAGE_SECONDS=$(( SONARR_ORPHAN_AGE * 86400 ))\n447\tNOW=$(date +%s)\n448\tMAX_DELETE_BYTES=$(awk \"BEGIN {printf \\\"%d\\\", $SONARR_MAX_DELETE_GB * 1073741824}\")\n449\t\n450\twhile IFS= read -r filepath; do\n451\t [[ -z \"$filepath\" ]] && continue\n452\t\n453\t if [[ -n \"${TRACKED_MAP[$filepath]:-}\" ]]; then\n454\t log \"TRACKED: $filepath\"\n455\t continue\n456\t fi\n457\t\n458\t if is_protected_file \"$filepath\"; then\n459\t log \"$ICON_PROTECTED PROTECTED: $filepath\"\n460\t (( PROTECTED_COUNT++ ))\n461\t continue\n462\t fi\n463\t\n464\t FILE_SIZE=$(stat -c%s \"$filepath\" 2>/dev/null || echo 0)\n465\t\n466\t if is_video_file \"$filepath\"; then\n467\t FILE_MTIME=$(stat -c %Y \"$filepath\" 2>/dev/null || echo 0)\n468\t FILE_AGE=$(( NOW - FILE_MTIME ))\n469\t\n470\t if [[ \"$FILE_AGE\" -lt \"$AGE_SECONDS\" ]] && [[ \"$SKIP_STRIKES\" != true ]]; then\n471\t log \"RECENT (skipping): $filepath\"\n472\t (( RECENT_COUNT++ ))\n473\t continue\n474\t fi\n475\t\n476\t warn \"$ICON_TRASH ORPHAN: $filepath\"\n477\t (( ORPHAN_COUNT++ ))\n478\t ORPHAN_BYTES=$(( ORPHAN_BYTES + FILE_SIZE ))\n479\t else\n480\t log \"JUNK: $filepath\"\n481\t (( JUNK_COUNT++ ))\n482\t JUNK_BYTES=$(( JUNK_BYTES + FILE_SIZE ))\n483\t fi\n484\t\n485\tdone < <(\n486\t for host_path in \"${SCAN_ROOTS[@]}\"; do\n487\t [[ -d \"$host_path\" ]] && find \"$host_path\" -type f 2>/dev/null\n488\t done | sort -u\n489\t)\n490\t\n491\tTOTAL_DELETE_BYTES=$(( ORPHAN_BYTES + JUNK_BYTES ))\n492\tTOTAL_REMOVED=$(( ORPHAN_COUNT + JUNK_COUNT ))\n493\t\n494\t# ==============================================================================================\n495\t# ━━━ Safety Layer 6 — Deletion Size Threshold ━━━\n496\t# ==============================================================================================\n497\tif [[ \"$TOTAL_DELETE_BYTES\" -gt \"$MAX_DELETE_BYTES\" ]]; then\n498\t TOTAL_HUMAN=$(awk \"BEGIN {printf \\\"%.1fGB\\\", $TOTAL_DELETE_BYTES / 1073741824}\")\n499\t if [[ \"$I_KNOW\" != true ]]; then\n500\t echo \"\"\n501\t error \"Deletion would exceed ${SONARR_MAX_DELETE_GB}GB — $TOTAL_HUMAN would be deleted\"\n502\t error \"Review ORPHAN lines above carefully before proceeding\"\n503\t error \"Rerun with: --i-know-what-im-doing\"\n504\t error \"To also bypass age check: add --skip-strike-list\"\n505\t notify \"Sonarr cleanup halted on $(hostname) — ${TOTAL_HUMAN} requires --i-know-what-im-doing\" \\\n506\t \"Sonarr Cleanup\" \"warning\"\n507\t exit 1\n508\t else\n509\t warn \"OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing\"\n510\t fi\n511\tfi\n512\t\n513\t# ── Execute Deletions ─────────────────────────────────────────────────────────────────────────\n514\tif [[ \"$DRY_RUN\" == false ]]; then\n515\t while IFS= read -r filepath; do\n516\t [[ -z \"$filepath\" ]] && continue\n517\t [[ -n \"${TRACKED_MAP[$filepath]:-}\" ]] && continue\n518\t is_protected_file \"$filepath\" && continue\n519\t\n520\t FILE_MTIME=$(stat -c %Y \"$filepath\" 2>/dev/null || echo 0)\n521\t FILE_AGE=$(( NOW - FILE_MTIME ))\n522\t\n523\t if is_video_file \"$filepath\"; then\n524\t [[ \"$FILE_AGE\" -lt \"$AGE_SECONDS\" ]] && \\\n525\t [[ \"$SKIP_STRIKES\" != true ]] && continue\n526\t fi\n527\t\n528\t rm -f \"$filepath\" 2>/dev/null || error \"Failed to delete: $filepath\"\n529\t\n530\t done < <(\n531\t for host_path in \"${SCAN_ROOTS[@]}\"; do\n532\t [[ -d \"$host_path\" ]] && find \"$host_path\" -type f 2>/dev/null\n533\t done | sort -u\n534\t )\n535\t\n536\t info \"Cleaning up empty folders...\"\n537\t for host_path in \"${SCAN_ROOTS[@]}\"; do\n538\t [[ -d \"$host_path\" ]] && \\\n539\t find \"$host_path\" -mindepth 1 -type d -empty -delete 2>/dev/null\n540\t done\n541\t info \"Empty folders removed\"\n542\tfi\n543\t\n544\tEND=$(date +%s)\n545\t\n546\tORPHAN_HUMAN=$(format_bytes \"$ORPHAN_BYTES\")\n547\tJUNK_HUMAN=$(format_bytes \"$JUNK_BYTES\")\n548\t\n549\t# ==============================================================================================\n550\t# ━━━ Summary ━━━\n551\t# ==============================================================================================\n552\techo \"\"\n553\techo \"━━━━━ $ICON_SUMMARY SONARR CLEANUP SUMMARY ━━━━━\"\n554\techo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n555\techo \"$ICON_SYNC Tracked: $TRACKED_COUNT files ($SERIES_COUNT series)\"\n556\techo \"$ICON_SHIELD Protected: $PROTECTED_COUNT files (artwork, subtitles, metadata)\"\n557\techo \"$ICON_TRASH Orphans: $ORPHAN_COUNT files ($ORPHAN_HUMAN)\"\n558\techo \"$ICON_TRASH Junk: $JUNK_COUNT files ($JUNK_HUMAN)\"\n559\techo \"$ICON_SKIP Recent skipped: $RECENT_COUNT files (under ${SONARR_ORPHAN_AGE} days)\"\n560\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n561\techo \"\"\n562\t\n563\tif [[ \"$DRY_RUN\" == true ]]; then\n564\t warn \"DRY RUN — no files deleted\"\n565\telif [[ \"$TOTAL_REMOVED\" -eq 0 ]]; then\n566\t echo \"$ICON_DONE Clean — nothing to remove\"\n567\telse\n568\t warn \"$ICON_DONE Removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)\"\n569\t notify \"Sonarr cleanup on $(hostname) — removed $TOTAL_REMOVED files (orphans: $ORPHAN_HUMAN junk: $JUNK_HUMAN)\" \\\n570\t \"Sonarr Cleanup\" \"warning\"\n571\t # Notify Emby to clean missing files — removes ghost entries immediately\n572\t notify_emby_scan\n573\tfi\n574\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n575\t\n576\t# Write stats for sunday_morning_coffee_report.sh\n577\tif [[ \"$DRY_RUN\" == false ]] && [[ -n \"${ARR_CLEANUP_STATS:-}\" ]]; then\n578\t echo \"$(date '+%Y-%m-%d')|sonarr|${ORPHAN_COUNT}|${ORPHAN_BYTES}|${JUNK_COUNT}|${JUNK_BYTES}|${RECENT_COUNT}|${TRACKED_COUNT}\" \\\n579\t >> \"$ARR_CLEANUP_STATS\" 2>/dev/null || true\n580\tfi\n581\t\n582\texit 0"}]},"uuid":"1751ad9f-371f-4aa8-bcca-8e1b8bd6c2c5","timestamp":"2026-06-14T05:32:15.991Z","sourceToolAssistantUUID":"cf926a08-f6b3-44d9-94d5-d774c12d9461","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"1751ad9f-371f-4aa8-bcca-8e1b8bd6c2c5","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Pa6S5xANXCrvAu42hEV6G7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0117Ay2KqiYTL5oyeXC87YfJ","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Media/sonarr_tvdb_removed.sh"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2216,"cache_read_input_tokens":5697,"output_tokens":832,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":2216},"inference_geo":"not_available","iterations":[{"input_tokens":3,"output_tokens":832,"cache_read_input_tokens":5697,"cache_creation_input_tokens":2216,"cache_creation":{"ephemeral_5m_input_tokens":2216,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011Cc2c3cM6yPuEFWAbagz6g","attributionAgent":"Explore","type":"assistant","uuid":"92fad56d-4164-4954-8866-3ad08d346acf","timestamp":"2026-06-14T05:32:16.122Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"92fad56d-4164-4954-8866-3ad08d346acf","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"aa134018ac187b545","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0117Ay2KqiYTL5oyeXC87YfJ","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ============================= Sonarr — TVDB Removed ==========================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Remove series from Sonarr that TVDB has dropped. Sonarr marks these with\n9\t# status=\"deleted\" — they generate health errors and can never be monitored\n10\t# or downloaded.\n11\t#\n12\t# ==============================================================================================\n13\t# OPERATIONAL MODEL\n14\t# ==============================================================================================\n15\t#\n16\t# Queries Sonarr API for series with status=\"deleted\" (TVDB removal marker)\n17\t# Reports each entry with file count and total size\n18\t# Removes the series record from Sonarr\n19\t# Optionally deletes associated files (disabled by default)\n20\t# Optionally adds to Sonarr's import exclusion list (default: true — prevents re-add)\n21\t#\n22\t# Files are NOT deleted by default — use --delete-files to also remove from disk.\n23\t# Per-deletion output is always visible — deletions are never silently swallowed.\n24\t#\n25\t# ==============================================================================================\n26\t# OPERATIONAL SAFEGUARDS\n27\t# ==============================================================================================\n28\t#\n29\t# detect_hosts() — exits cleanly if SONARR_URL empty (Sonarr not on this host)\n30\t# API pre-flight — verifies Sonarr reachable before querying\n31\t# Dry-run mode — full preview without removing anything\n32\t#\n33\t# ==============================================================================================\n34\t# CONFIGURATION\n35\t# ==============================================================================================\n36\t#\n37\t# master.conf\n38\t#\n39\t# SONARR_DROPPED_ADD_EXCLUSION — add removed series to import exclusion (default: true)\n40\t#\n41\t# host*.conf\n42\t#\n43\t# HOST1_SONARR_URL / HOST1_SONARR_API_KEY\n44\t# Aliased by detect_hosts() — script uses SONARR_URL / SONARR_API_KEY\n45\t#\n46\t# ==============================================================================================\n47\t# RUNTIME MODES\n48\t# ==============================================================================================\n49\t#\n50\t# sonarr_tvdb_removed.sh — remove records, keep files, add exclusion\n51\t# sonarr_tvdb_removed.sh --delete-files — also delete files from disk\n52\t# sonarr_tvdb_removed.sh --dry-run — preview without removing anything\n53\t# sonarr_tvdb_removed.sh --log — verbose output\n54\t# sonarr_tvdb_removed.sh --status — show config and exit\n55\t#\n56\t# ==============================================================================================\n57\t\n58\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n59\t\n60\tsource \"$SCRIPT_DIR/../load_config.sh\"\n61\t\n62\t# ── Parse --delete-files before standard parse_args ───────────────────────────────────────────\n63\tDELETE_FILES=false\n64\tFILTERED_ARGS=()\n65\tfor arg in \"$@\"; do\n66\t if [[ \"$arg\" == \"--delete-files\" ]]; then\n67\t DELETE_FILES=true\n68\t else\n69\t FILTERED_ARGS+=(\"$arg\")\n70\t fi\n71\tdone\n72\t\n73\tparse_args \"${FILTERED_ARGS[@]}\"\n74\t\n75\t# ==============================================================================================\n76\t# ━━━ Setup ━━━\n77\t# ==============================================================================================\n78\tif [[ \"$EUID\" -ne 0 ]]; then\n79\t error \"Must be run as root\"\n80\t exit 1\n81\tfi\n82\t\n83\tif ! command -v curl >/dev/null 2>&1; then\n84\t error \"curl not found\"\n85\t exit 1\n86\tfi\n87\tif ! command -v jq >/dev/null 2>&1; then\n88\t error \"jq not found\"\n89\t exit 1\n90\tfi\n91\t\n92\tacquire_lock\n93\t\n94\tdetect_hosts\n95\t\n96\tif [[ -z \"$SONARR_URL\" ]]; then\n97\t echo \"Sonarr not configured for $MY_ID — skipping\"\n98\t exit 0\n99\tfi\n100\t\n101\tADD_EXCLUSION=\"${SONARR_DROPPED_ADD_EXCLUSION:-true}\"\n102\t\n103\tlog \"$ICON_GEAR Config: url=${SONARR_URL} add-exclusion=${ADD_EXCLUSION} delete-files=${DELETE_FILES:-false}\"\n104\techo \" $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_URL\"\n105\t[[ \"$DELETE_FILES\" == true ]] && warn \"DELETE FILES MODE — files will be removed from disk\"\n106\t[[ \"$DELETE_FILES\" == false ]] && echo \" Files: records only (use --delete-files to also remove from disk)\"\n107\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes will be made\"\n108\t\n109\t# ==============================================================================================\n110\t# ━━━ Status ━━━\n111\t# ==============================================================================================\n112\tif [[ \"$SHOW_STATUS\" == true ]]; then\n113\t echo \"\"\n114\t echo \"━━━━━ $ICON_SUMMARY STATUS ━━━━━\"\n115\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n116\t echo \"$ICON_GEAR Sonarr URL: $SONARR_URL\"\n117\t echo \"$ICON_GEAR Add exclusion: $ADD_EXCLUSION\"\n118\t echo \"$ICON_GEAR Delete files: $DELETE_FILES\"\n119\t echo \"$ICON_GEAR Dry Run: $DRY_RUN\"\n120\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n121\t exit 0\n122\tfi\n123\t\n124\t# ==============================================================================================\n125\t# ━━━ Query Sonarr ━━━\n126\t# ==============================================================================================\n127\techo \"\"\n128\techo \"━━━ $ICON_SYNC Querying Sonarr ━━━\"\n129\t\n130\tif ! curl -sf --connect-timeout 5 --max-time 10 \\\n131\t \"$SONARR_URL/api/v3/system/status?apikey=$SONARR_API_KEY\" | jq -e '.version' >/dev/null 2>&1; then\n132\t error \"Sonarr API unreachable at $SONARR_URL\"\n133\t exit 1\n134\tfi\n135\t\n136\tSERIES=$(curl -sf --connect-timeout 5 --max-time 30 \\\n137\t \"$SONARR_URL/api/v3/series?apikey=$SONARR_API_KEY\" 2>/dev/null)\n138\t\n139\tif [[ -z \"$SERIES\" || \"$SERIES\" == \"null\" ]]; then\n140\t error \"Sonarr series API returned empty\"\n141\t exit 1\n142\tfi\n143\t\n144\tTOTAL=$(echo \"$SERIES\" | jq '. | length')\n145\tDROPPED=$(echo \"$SERIES\" | jq '[.[] | select(.status == \"deleted\")] | length')\n146\techo \" $TOTAL series total — $DROPPED dropped from TVDB\"\n147\t\n148\tif [[ \"$DROPPED\" -eq 0 ]]; then\n149\t log \"No TVDB-removed series found — nothing to do\"\n150\t echo \"\"\n151\t echo \"━━━━━ $ICON_SUMMARY SONARR TVDB REMOVED SUMMARY ━━━━━\"\n152\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n153\t echo \"$ICON_DONE Status: nothing to remove\"\n154\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n155\t exit 0\n156\tfi\n157\t\n158\t# ==============================================================================================\n159\t# ━━━ Remove Dropped Series ━━━\n160\t# ==============================================================================================\n161\techo \"\"\n162\techo \"━━━ $ICON_TRASH Remove TVDB-Dropped Series ━━━\"\n163\t\n164\tSTART=$(date +%s)\n165\tREMOVED=()\n166\tFAILED=()\n167\tFILES_DELETED=0\n168\tFILES_SKIPPED=0\n169\t\n170\twhile IFS=$'\\t' read -r id title year tvdb_id episode_file_count size_on_disk; do\n171\t [[ -z \"$id\" ]] && continue\n172\t\n173\t SIZE_HUMAN=\"\"\n174\t if [[ \"$size_on_disk\" -gt 0 ]]; then\n175\t SIZE_HUMAN=$(awk \"BEGIN {printf \\\"%.1fGB\\\", $size_on_disk / 1073741824}\")\n176\t fi\n177\t\n178\t if [[ \"$episode_file_count\" -gt 0 ]]; then\n179\t log \"$ICON_WARN $title ($year) [tvdbid $tvdb_id] — $episode_file_count episode files${SIZE_HUMAN:+, $SIZE_HUMAN}\"\n180\t else\n181\t log \"$ICON_TRASH $title ($year) [tvdbid $tvdb_id] — no files\"\n182\t fi\n183\t\n184\t if [[ \"$DRY_RUN\" == true ]]; then\n185\t warn \"DRY RUN — would remove: $title\"\n186\t [[ \"$DELETE_FILES\" == true && \"$episode_file_count\" -gt 0 ]] && \\\n187\t warn \"DRY RUN — would delete $episode_file_count file(s)${SIZE_HUMAN:+, $SIZE_HUMAN}\"\n188\t REMOVED+=(\"$title\")\n189\t continue\n190\t fi\n191\t\n192\t DELETE_PARAM=\"false\"\n193\t if [[ \"$DELETE_FILES\" == true && \"$episode_file_count\" -gt 0 ]]; then\n194\t DELETE_PARAM=\"true\"\n195\t fi\n196\t\n197\t RESP=$(curl -sf -X DELETE --connect-timeout 5 --max-time 15 \\\n198\t \"$SONARR_URL/api/v3/series/${id}?deleteFiles=${DELETE_PARAM}&addImportListExclusion=${ADD_EXCLUSION}&apikey=$SONARR_API_KEY\" \\\n199\t 2>/dev/null)\n200\t CURL_EXIT=$?\n201\t\n202\t if [[ \"$CURL_EXIT\" -eq 0 ]]; then\n203\t log \" Removed from Sonarr ✅\"\n204\t REMOVED+=(\"$title\")\n205\t if [[ \"$DELETE_PARAM\" == \"true\" ]]; then\n206\t (( FILES_DELETED++ ))\n207\t elif [[ \"$episode_file_count\" -gt 0 ]]; then\n208\t (( FILES_SKIPPED++ ))\n209\t fi\n210\t else\n211\t warn \" Failed to remove $title (curl exit $CURL_EXIT)\"\n212\t FAILED+=(\"$title\")\n213\t fi\n214\t\n215\tdone < <(echo \"$SERIES\" | jq -r '\n216\t .[] | select(.status == \"deleted\") |\n217\t [\n218\t (.id | tostring),\n219\t .title,\n220\t (.year | tostring),\n221\t (.tvdbId | tostring),\n222\t (if .episodeFileCount? then (.episodeFileCount | tostring) else \"0\" end),\n223\t (if .sizeOnDisk? then (.sizeOnDisk | tostring) else \"0\" end)\n224\t ] | @tsv\n225\t')\n226\t\n227\tEND=$(date +%s)\n228\t\n229\t# ==============================================================================================\n230\t# ━━━ Summary ━━━\n231\t# ==============================================================================================\n232\techo \"\"\n233\techo \"━━━━━ $ICON_SUMMARY SONARR TVDB REMOVED SUMMARY ━━━━━\"\n234\techo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n235\techo \"$ICON_TIME Duration: $(format_duration $(( END - START )))\"\n236\techo \"$ICON_TRASH Removed: ${#REMOVED[@]} of $DROPPED\"\n237\t[[ ${#FAILED[@]} -gt 0 ]] && echo \"$ICON_ERROR Failed: ${FAILED[*]}\"\n238\t[[ \"$FILES_DELETED\" -gt 0 ]] && echo \"$ICON_TRASH Files deleted: $FILES_DELETED series worth\"\n239\t[[ \"$FILES_SKIPPED\" -gt 0 ]] && echo \"$ICON_WARN Files kept: $FILES_SKIPPED series (had files — use --delete-files to remove)\"\n240\t[[ \"$ADD_EXCLUSION\" == \"true\" ]] && echo \"$ICON_GEAR Import exclusion added for removed entries\"\n241\t\n242\tif [[ \"$DRY_RUN\" == true ]]; then\n243\t warn \"DRY RUN — no changes made\"\n244\telif [[ ${#FAILED[@]} -eq 0 ]]; then\n245\t echo \"$ICON_DONE Status: done ✅\"\n246\telse\n247\t warn \"Status: ${#FAILED[@]} removal(s) failed\"\n248\tfi\n249\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n250\t\n251\t[[ ${#FAILED[@]} -gt 0 ]] && exit 1\n252\texit 0\n253\t"}]},"uuid":"53156104-24df-41e4-a2b0-918316a7cbe7","timestamp":"2026-06-14T05:32:16.142Z","sourceToolAssistantUUID":"92fad56d-4164-4954-8866-3ad08d346acf","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"53156104-24df-41e4-a2b0-918316a7cbe7","isSidechain":true,"agentId":"aa134018ac187b545","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01FqBKSXZAK9Sx1cRkUv9nu9","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll analyze each script carefully against its header documentation.\n\n## Audit Report\n\n**✅ arr_sync.sh**\n\n**✅ arrs_failed_stalled_recovery.sh**\n\n**lidarr_cleanup.sh**\n- PURPOSE header claims \"Triggers an Emby library clean after each deletion run\" but the code at line 605 calls `notify_emby_scan` only if `$TOTAL_REMOVED` is greater than 0 and NOT in DRY_RUN mode. The header's claim of \"after each deletion run\" is misleading — it's only after actual deletions, not after every run.\n\n**✅ lidarr_missing_art.sh**\n\n**✅ media_cleaner.sh**\n\n**✅ media_shares_permissions.sh**\n\n**play_state_sync.sh**\n- CONFIGURATION header lists \"PLAY_SYNC_REMOTE\" with default \"true\" and description \"Sync across all hosts via Tailscale (default: true) false = local servers only\". Code at line 92 uses `\"${PLAY_SYNC_REMOTE:-true}\"` which matches the default documented. However, at line 125 the logic says `if [[ \"$_is_me\" == false && \"${PLAY_SYNC_REMOTE:-true}\" != \"true\" ]]; then` which skips remote hosts when PLAY_SYNC_REMOTE is false. The header description is correct and matches code — no issue here.\n- CONFIGURATION missing from header: `emby_api` function is called (lines 322, 505, 315, 329) but this helper is not documented anywhere in the header as a required/available function.\n\n**playback_aware_lidarr_discovery.sh**\n- CONFIGURATION header lists \"LIDARR_DISCOVERY_HISTORY\" with path description but does not mention it's auto-created in DATA_DIR if not specified (line 159: `\"${LIDARR_DISCOVERY_HISTORY:-${DATA_DIR}/lidarr_discovery_history.db}\"`). Not a mismatch, just incomplete.\n\n**playback_aware_radarr_discovery.sh**\n- CONFIGURATION header claims \"RADARR_DISCOVERY_SEED_LIBRARIES — Emby library names to draw seeds from (default: (\"Movies\"))\" but code at line 163 sets `SEED_LIBRARIES=(\"${RADARR_DISCOVERY_SEED_LIBRARIES[@]:-Movies}\")` which defaults to \"Movies\" string, not an array. Minor: the notation in header shows `(\"Movies\")` implying an array but the default is the string \"Movies\" which becomes a single-element array in bash. Technically correct but notation is slightly misleading.\n\n**playback_aware_sonarr_discovery.sh**\n- CONFIGURATION header claims \"SONARR_EMBY_LIBRARIES — Emby library names to draw seeds from\" but code at line 169 checks `if [[ \"${#SONARR_EMBY_LIBRARIES[@]}\" -eq 0 ]]; then error \"SONARR_EMBY_LIBRARIES not configured\"`. The header does NOT list this as required in CONFIGURATION section (lines 69-82), though it IS documented in the FLOW section. This is a documentation inconsistency — a required config should be listed in CONFIGURATION section.\n\n**radarr_cleanup.sh**\n- CONFIGURATION header does NOT list \"RADARR_IMPORT_SCAN_TIMEOUT\" in the master.conf section, but code at line 335 uses `POLL_TIMEOUT=${RADARR_IMPORT_SCAN_TIMEOUT:-600}`. The header documents this variable in lidarr_cleanup.sh (line 76 of that file) but NOT in radarr_cleanup.sh's header.\n\n**✅ radarr_tmdb_removed.sh**\n\n**sonarr_cleanup.sh**\n- Same issue as radarr_cleanup.sh: CONFIGURATION header does NOT list \"SONARR_IMPORT_SCAN_TIMEOUT\" in the master.conf section, but code at line 335 uses `POLL_TIMEOUT=${SONARR_IMPORT_SCAN_TIMEOUT:-600}`.\n\n**sonarr_tvdb_removed.sh**\n- CONFIGURATION header at line 38-39 lists \"SONARR_DROPPED_ADD_EXCLUSION\" but the parameter name used in code at line 198 is `addImportListExclusion` while in radarr_tvdb_removed.sh line 196 it's `addImportExclusion`. Both APIs are called, but the parameter names differ between arr types. This is not a documentation error but worth noting the APIs differ.\n\n## Summary\n\n**lidarr_cleanup.sh**\n- Emby library clean notification only happens after actual deletions, not \"after each deletion run\" as header claims\n\n**play_state_sync.sh**\n- Missing documentation: `emby_api` helper function is called but not documented as required/available\n\n**playback_aware_sonarr_discovery.sh**\n- SONARR_EMBY_LIBRARIES is required (script exits if empty) but not listed in CONFIGURATION section, only mentioned in FLOW section\n\n**radarr_cleanup.sh**\n- Missing from CONFIGURATION: RADARR_IMPORT_SCAN_TIMEOUT (used in code at line 335)\n\n**sonarr_cleanup.sh**\n- Missing from CONFIGURATION: SONARR_IMPORT_SCAN_TIMEOUT (used in code at line 335)"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":120253,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":120253,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c3uftw64vN6gcYLmPJ","attributionAgent":"Explore","type":"assistant","uuid":"2e3ea089-9df5-4540-b1d6-d450e7497314","timestamp":"2026-06-14T05:32:35.714Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|