Add docker actions, arr profile enforcer, monitor caching, and web file symlink

Web files now served via symlink to the git repo so git pull changes survive
reboots without rebuilding the txz. Also includes: docker pull/rebuild/restart
with live log streaming, arr_profile_enforcer for Sonarr/Radarr quality
profiles, monitor page cache fix (background writer now in cron), and
ARR_KIDS/SONARR/RADARR profile name vars in master.conf.
This commit is contained in:
Gmer4Lfe
2026-06-19 11:09:40 -04:00
parent ac2986b141
commit f42ecc8464
15 changed files with 657 additions and 62 deletions
+8
View File
@@ -500,6 +500,7 @@
INTERMEDIATE_RSYNC_ENABLED=true # Tier 2 — intermediate_sync_maintenance.sh rsync section
DAILY_RSYNC_ENABLED=true # Tier 2 — daily_sync_maintenance.sh rsync section
WEEKLY_RSYNC_ENABLED=true # Tier 2 — weekly_sync_maintenance.sh rsync section
MONTHLY_RSYNC_ENABLED=true # Tier 2 — monthly_maintenance.sh rsync section
FALLBACK_RSYNC_ENABLED=true # Tier 2 — fallback.sh writeback jobs on handback
# ━━━ Download Webhook ━━━
@@ -1038,6 +1039,13 @@
RADARR_VERSION_MAJOR=6
LIDARR_VERSION_MAJOR=3
# arr_profile_enforcer.sh — quality profile names by root folder type
# Root folder paths containing "kids" or "anime" → ARR_KIDS_PROFILE_NAME
# All other root folders → ARR_*_DEFAULT_PROFILE
ARR_KIDS_PROFILE_NAME="Kids shows"
ARR_SONARR_DEFAULT_PROFILE="Any"
ARR_RADARR_DEFAULT_PROFILE="Any (mine)"
# Lidarr shared settings
LIDARR_LOCK_WARN_AGE=3600 # 1hr — large libraries take time, not stuck
LIDARR_ORPHAN_AGE=7 # days — files must be older than this before eligible for deletion
+6
View File
@@ -202,6 +202,12 @@
# "/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile
)
# ━━━ Monthly Sync Shares ━━━
# Shares synced by monthly_maintenance.sh. Add here when ready.
HOSTN_MONTHLY_SYNC_SHARES=(
# Add shares here
)
# ━━━ Intermediate Sync Shares ━━━
# Shares synced every 4 hours. Leave empty to skip mid-day rsync.
HOSTN_INTERMEDIATE_SYNC_SHARES=(
+9
View File
@@ -485,6 +485,7 @@
# INTERMEDIATE_RSYNC_ENABLED=false ← skip 4h arr/mid-day rsync during rebuild
# DAILY_RSYNC_ENABLED=false ← skip daily HDD syncs during rebuild
# WEEKLY_RSYNC_ENABLED=true ← Emby + Critical-Data still sync (NVMe)
# MONTHLY_RSYNC_ENABLED=true ← monthly_maintenance.sh rsync section
# FALLBACK_RSYNC_ENABLED=true ← handback writeback still works
# → Run individual: bash Rsync/rsync.sh /mnt/user/Movies
# → When ready: INTERMEDIATE_RSYNC_ENABLED=true DAILY_RSYNC_ENABLED=true
@@ -493,6 +494,7 @@
INTERMEDIATE_RSYNC_ENABLED=true # Tier 2 — intermediate_sync_maintenance.sh rsync section
DAILY_RSYNC_ENABLED=true # Tier 2 — daily_sync_maintenance.sh rsync section
WEEKLY_RSYNC_ENABLED=true # Tier 2 — weekly_sync_maintenance.sh rsync section
MONTHLY_RSYNC_ENABLED=true # Tier 2 — monthly_maintenance.sh rsync section
FALLBACK_RSYNC_ENABLED=true # Tier 2 — fallback.sh writeback jobs on handback
# ━━━ Download Webhook ━━━
@@ -1031,6 +1033,13 @@
RADARR_VERSION_MAJOR=6
LIDARR_VERSION_MAJOR=3
# arr_profile_enforcer.sh — quality profile names by root folder type
# Root folder paths containing "kids" or "anime" → ARR_KIDS_PROFILE_NAME
# All other root folders → ARR_*_DEFAULT_PROFILE
ARR_KIDS_PROFILE_NAME="Kids shows"
ARR_SONARR_DEFAULT_PROFILE="Any"
ARR_RADARR_DEFAULT_PROFILE="Any (mine)"
# Lidarr shared settings
LIDARR_LOCK_WARN_AGE=3600 # 1hr — large libraries take time, not stuck
LIDARR_ORPHAN_AGE=7 # days — files must be older than this before eligible for deletion
+59 -23
View File
@@ -270,6 +270,32 @@ for i in $(seq 0 $(( _srv_count - 1 ))); do
done < <(echo "${SRV_USERS[$i]}" | jq -r '.[] | [.Id, .Name] | @tsv' 2>/dev/null)
done
# ── Step 2.5: Pre-build provider ID → item ID lookup map ─────────────────────
# AnyProviderIdEquals is broken in Jellyfin 10.11+ (ignores the filter entirely).
# Pre-fetching all items' provider IDs once and using a local map avoids the broken
# per-item API call and is faster overall.
declare -A PROV_LOOKUP # "si|tvdb.{id}" | "si|imdb.{id}" | "si|mb.{id}" → item_id
for _psi in $(seq 0 $(( _srv_count - 1 ))); do
[[ -z "${SRV_URL[$_psi]}" ]] && continue
log "Building provider ID map for ${SRV_NAME[$_psi]}..."
_raw_prov=$(_api_get "${SRV_URL[$_psi]}" "${SRV_KEY[$_psi]}" \
"Items?Recursive=true&IncludeItemTypes=${SYNC_TYPES}&Fields=ProviderIds&ExcludeLocationTypes=Virtual" 2>/dev/null)
[[ -z "$_raw_prov" ]] && continue
while IFS=$'\t' read -r _pid _ptvdb _pimdb _ptmdb _pmbtrack; do
[[ "$_ptvdb" != "null" && -n "$_ptvdb" ]] && PROV_LOOKUP["${_psi}|tvdb.${_ptvdb}"]="$_pid"
[[ "$_pimdb" != "null" && -n "$_pimdb" ]] && PROV_LOOKUP["${_psi}|imdb.${_pimdb}"]="$_pid"
[[ "$_ptmdb" != "null" && -n "$_ptmdb" ]] && PROV_LOOKUP["${_psi}|tmdb.${_ptmdb}"]="$_pid"
[[ "$_pmbtrack" != "null" && -n "$_pmbtrack" ]] && PROV_LOOKUP["${_psi}|mb.${_pmbtrack}"]="$_pid"
done < <(echo "$_raw_prov" | jq -r '.Items[] | [
.Id,
(.ProviderIds.Tvdb // "null"),
(.ProviderIds.Imdb // "null"),
(.ProviderIds.Tmdb // "null"),
(.ProviderIds.MusicBrainzTrackId // "null")
] | @tsv' 2>/dev/null)
done
# ── Step 3: Sync per matched user ────────────────────────────────────────────
_date_filter=""
if [[ "$SYNC_DAYS" -gt 0 ]]; then
@@ -386,23 +412,28 @@ for lname in "${!USER_MAP[@]}"; do
[[ "$_has_entries" == false ]] && continue
# Find the authoritative server: newest LastPlayedDate epoch
# Tie-break: higher PlayCount, then higher Ticks
# Tie-break: higher PlayCount, then higher Ticks, then Played=true
# Init at -1 so servers with epoch=0 (batch-marks with null LastPlayedDate) can win
_auth_si=""
_auth_epoch=0
_auth_pcount=0
_auth_ticks=0
_auth_epoch=-1
_auth_pcount=-1
_auth_ticks=-1
_auth_pf="false"
for _si in "${!E_SIDX[@]}"; do
_e="${E_EPOCH[$_si]:-0}"
_pc="${E_PCOUNT[$_si]:-0}"
_tk="${E_TICKS[$_si]:-0}"
_pf="${E_PLAYED[$_si]:-false}"
if [[ "$_e" -gt "$_auth_epoch" ]] || \
[[ "$_e" -eq "$_auth_epoch" && "$_pc" -gt "$_auth_pcount" ]] || \
[[ "$_e" -eq "$_auth_epoch" && "$_pc" -eq "$_auth_pcount" && "$_tk" -gt "$_auth_ticks" ]]; then
[[ "$_e" -eq "$_auth_epoch" && "$_pc" -eq "$_auth_pcount" && "$_tk" -gt "$_auth_ticks" ]] || \
[[ "$_e" -eq "$_auth_epoch" && "$_pc" -eq "$_auth_pcount" && "$_tk" -eq "$_auth_ticks" && "$_pf" == "true" && "$_auth_pf" != "true" ]]; then
_auth_si="$_si"
_auth_epoch="$_e"
_auth_pcount="$_pc"
_auth_ticks="$_tk"
_auth_pf="$_pf"
fi
done
@@ -422,12 +453,16 @@ for lname in "${!USER_MAP[@]}"; do
# Skip if they already have the same/newer state
_skip=false
if [[ "$_auth_played" == "true" ]]; then
# Played: skip if target is already marked played with same/newer date
[[ "$_their_epoch" -ge "$_auth_epoch" && "$_their_played" == "true" ]] && _skip=true
# Both servers already have this played — nothing to propagate regardless of dates.
# Date-based comparison caused a ping-pong: syncing without a DatePlayed param lets
# the target server stamp the current time, making it the new authority next cycle.
[[ "$_their_played" == "true" ]] && _skip=true
else
# Resume only: skip if target already has same or more ticks
# (posting ticks via /UserData doesn't set LastPlayedDate, so epoch comparison is useless here)
[[ "${E_TICKS[$_si]:-0}" -ge "${_auth_ticks:-0}" && "$_their_played" == "false" ]] && _skip=true
# Resume only: skip if target already has same or more ticks.
# Allow 5-second tolerance (50_000_000 ticks) — Emby may round tick values slightly
# differently on read, causing an exact-match check to miss and re-sync every cycle.
_tick_gap=$(( ${_auth_ticks:-0} - ${E_TICKS[$_si]:-0} ))
[[ "$_tick_gap" -le 50000000 && "${E_TICKS[$_si]:-0}" -gt 0 && "$_their_played" == "false" ]] && _skip=true
fi
if [[ "$_skip" == true ]]; then
log " SKIP $_pkey${SRV_NAME[$_si]} already up to date"
@@ -441,24 +476,25 @@ for lname in "${!USER_MAP[@]}"; do
# Find item ID on target server by provider key if not in our map
if [[ -z "$_iid" ]]; then
_ptype="${_pkey%%:*}"
_pval="${_pkey##*:}"
case "$_ptype" in
imdb) _search_field="imdb.${_pval}" ;;
tmdb) _search_field="tmdb.${_pval##movie:}" ;;
imdb)
_pval="${_pkey#imdb:}"
_iid="${PROV_LOOKUP[${_si}|imdb.${_pval}]:-}"
;;
tmdb)
_pval="${_pkey#tmdb:movie:}"
_iid="${PROV_LOOKUP[${_si}|tmdb.${_pval}]:-}"
;;
tvdb)
# _pkey format: tvdb:ep:{tvdb_id}:s{season}e{ep}
# ##*: gives "s7e2" (wrong); strip prefix then first :
# pkey format: tvdb:ep:{tvdb_id}:s{season}e{ep}
_tvdb_num="${_pkey#tvdb:ep:}"; _tvdb_num="${_tvdb_num%%:*}"
_search_field="tvdb.${_tvdb_num}"
_iid="${PROV_LOOKUP[${_si}|tvdb.${_tvdb_num}]:-}"
;;
mb)
_pval="${_pkey#mb:track:}"
_iid="${PROV_LOOKUP[${_si}|mb.${_pval}]:-}"
;;
mb) _search_field="" ;; # skip music if not found
esac
if [[ -n "$_search_field" ]]; then
_iid=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Items?AnyProviderIdEquals=${_search_field}&Recursive=true&Fields=ProviderIds&ExcludeLocationTypes=Virtual&Limit=1" 2>/dev/null \
| jq -r '.Items[0].Id // empty' 2>/dev/null)
fi
[[ -z "$_iid" ]] && log " SKIP $_pkey${SRV_NAME[$_si]} item not found on server" && continue
fi
Regular → Executable
View File
+76 -9
View File
@@ -1,21 +1,88 @@
<?php
header('Content-Type: application/json');
define('VV_JOB_DIR', '/tmp/varaverk_dk_jobs');
$action = trim($_POST['action'] ?? '');
$name = trim($_POST['name'] ?? '');
$jobId = trim($_POST['job_id'] ?? '');
if (!$name || !in_array($action, ['start', 'stop'], true)) {
echo json_encode(['ok' => false, 'error' => 'Invalid request']);
exit;
// ── Job status (no name required) ─────────────────────────────────────────────
if ($action === 'job_status') {
if (!$jobId || !preg_match('/^[0-9a-f]+$/', $jobId)) {
echo json_encode(['ok' => false, 'error' => 'invalid job_id']); exit;
}
$file = VV_JOB_DIR . '/' . $jobId . '.json';
if (!file_exists($file)) {
echo json_encode(['ok' => true, 'status' => 'pending']); exit;
}
echo file_get_contents($file); exit;
}
// Confirm container exists
$check = trim(shell_exec('docker ps -a --filter ' . escapeshellarg('name=^' . $name . '$') . " --format '{{.Names}}' 2>/dev/null") ?? '');
// ── Logs ──────────────────────────────────────────────────────────────────────
if ($action === 'logs') {
if (!$name || !preg_match('/^[a-zA-Z0-9_.-]+$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'invalid name']); exit;
}
$out = shell_exec('docker logs --tail 200 --timestamps ' . escapeshellarg($name) . ' 2>&1');
echo json_encode(['ok' => true, 'logs' => $out ?? '']); exit;
}
// ── Container-scoped actions ──────────────────────────────────────────────────
if (!$name || !preg_match('/^[a-zA-Z0-9_.-]+$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'invalid name']); exit;
}
$check = trim(shell_exec(
'docker ps -a --filter ' . escapeshellarg('name=^' . $name . '$') . " --format '{{.Names}}' 2>/dev/null"
) ?? '');
if ($check !== $name) {
echo json_encode(['ok' => false, 'error' => 'Container not found']);
exit;
echo json_encode(['ok' => false, 'error' => 'Container not found']); exit;
}
exec(($action === 'start' ? 'docker start' : 'docker stop') . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
if ($action === 'start' || $action === 'stop') {
exec(($action === 'start' ? 'docker start' : 'docker stop') . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit;
}
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]);
if ($action === 'restart') {
$rebuild = '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container';
if (is_executable($rebuild)) {
exec($rebuild . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
} else {
exec('docker stop ' . escapeshellarg($name) . ' 2>&1', $o1, $rc1);
exec('docker start ' . escapeshellarg($name) . ' 2>&1', $o2, $rc2);
$out = array_merge($o1, $o2);
$rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1;
}
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit;
}
if ($action === 'pull_rebuild') {
@mkdir(VV_JOB_DIR, 0700, true);
$jobId = bin2hex(random_bytes(8));
$jobFile = VV_JOB_DIR . '/' . $jobId . '.json';
$worker = __DIR__ . '/docker_pull_worker.php';
$rebuild = '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container';
$oldId = trim(shell_exec('docker inspect --format={{.Image}} ' . escapeshellarg($name) . ' 2>/dev/null') ?: '');
$image = trim(shell_exec('docker inspect --format={{.Config.Image}} ' . escapeshellarg($name) . ' 2>/dev/null') ?: '');
if (!$image) {
echo json_encode(['ok' => false, 'error' => 'Could not determine image']); exit;
}
file_put_contents($jobFile, json_encode(['ok' => true, 'status' => 'pulling', 'container' => $name]));
$cmd = 'php ' . escapeshellarg($worker) . ' ' .
escapeshellarg($name) . ' ' .
escapeshellarg($jobFile) . ' ' .
escapeshellarg($oldId) . ' ' .
escapeshellarg($image) . ' ' .
escapeshellarg($rebuild) . ' >/dev/null 2>&1 &';
exec($cmd);
echo json_encode(['ok' => true, 'status' => 'started', 'job_id' => $jobId]); exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
+34
View File
@@ -0,0 +1,34 @@
<?php
// Background worker: docker pull → compare image ID → rebuild if updated.
// Called via: php docker_pull_worker.php <name> <jobFile> <oldId> <image> <rebuild>
[$name, $jobFile, $oldId, $image, $rebuild] = array_slice($argv, 1, 5);
if (!$name || !$jobFile || !$image) exit(1);
function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); }
shell_exec('docker pull ' . escapeshellarg($image) . ' 2>&1');
$rawInfo = shell_exec('docker image inspect ' . escapeshellarg($image) . ' 2>/dev/null') ?: '[]';
$info = json_decode($rawInfo, true) ?: [];
$newId = $info[0]['Id'] ?? '';
if ($oldId && $newId && $oldId === $newId) {
jw($jobFile, ['ok' => true, 'status' => 'done', 'updated' => false, 'message' => 'Already up to date']);
exit;
}
jw($jobFile, ['ok' => true, 'status' => 'rebuilding']);
if ($rebuild && is_executable($rebuild)) {
exec($rebuild . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
} else {
exec('docker stop ' . escapeshellarg($name) . ' 2>&1', $o1, $rc1);
exec('docker start ' . escapeshellarg($name) . ' 2>&1', $o2, $rc2);
$rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1;
}
jw($jobFile, $rc === 0
? ['ok' => true, 'status' => 'done', 'updated' => true, 'message' => 'Updated and rebuilt']
: ['ok' => false, 'status' => 'done', 'error' => 'Rebuild failed after pull']
);
+2
View File
@@ -49,6 +49,7 @@ $base = vv_rsync_status();
$vars = vv_conf_vars();
$base['windows']['fallback'] = ($vars['FALLBACK_RSYNC_ENABLED'] ?? 'true') !== 'false';
$base['windows']['monthly'] = ($vars['MONTHLY_RSYNC_ENABLED'] ?? 'true') !== 'false';
// Bandwidth history — last 30 days
$bwLog = DATA_DIR . '/bandwidth_history.db';
@@ -82,6 +83,7 @@ $winArrayDefs = [
'intermediate' => ['INTERMEDIATE_MAINTENANCE_SCRIPTS', "{$myId}_INTERMEDIATE_SYNC_SHARES"],
'daily' => ['DAILY_MAINTENANCE_SCRIPTS', "{$myId}_DAILY_SYNC_SHARES"],
'weekly' => ['WEEKLY_MAINTENANCE_SCRIPTS', "{$myId}_WEEKLY_SYNC_SHARES"],
'monthly' => ['MONTHLY_MAINTENANCE_SCRIPTS', "{$myId}_MONTHLY_SYNC_SHARES"],
'fallback' => [null, null],
];
$winArrays = [];
+3 -4
View File
@@ -19,15 +19,14 @@ function vv_system_info(): array {
$os = $api['info']['os'] ?? [];
$cpu = $api['info']['cpu'] ?? [];
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
// uptime is a String in this schema — try numeric (seconds) first, else fall back to /proc/uptime
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$uptime = vv_format_uptime($uptimeSec);
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
}
$uptime = vv_format_uptime($uptimeSec);
$load = sys_getloadavg();
return [
+12 -2
View File
@@ -233,6 +233,11 @@ function vv_watchdog_summary(): array {
$zombies = (int)trim(shell_exec("ps -eo stat 2>/dev/null | grep -c '^Z'") ?: '0');
$fileNr = explode("\t", trim(@file_get_contents('/proc/sys/fs/file-nr') ?: '0 0 1'));
$fdOpen = max(0, (int)($fileNr[0] ?? 0) - (int)($fileNr[1] ?? 0));
$fdMax = max(1, (int)($fileNr[2] ?? 1));
$fdPct = round($fdOpen / $fdMax * 100, 1);
$nic = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: 'eth0') ?: 'eth0';
$nicState = trim(@file_get_contents("/sys/class/net/$nic/operstate") ?: 'unknown');
$sshdOk = (int)trim(shell_exec('pgrep -c sshd 2>/dev/null') ?: '0') > 0;
@@ -260,6 +265,9 @@ function vv_watchdog_summary(): array {
'load_1min' => $load1,
'cpu_temp' => $cpuTemp,
'zombies' => $zombies,
'fd_open' => $fdOpen,
'fd_max' => $fdMax,
'fd_pct' => $fdPct,
'nic' => $nic,
'nic_state' => $nicState,
'sshd_ok' => $sshdOk,
@@ -330,6 +338,7 @@ function vv_rsync_status(): array {
'daily' => ($vars['DAILY_RSYNC_ENABLED'] ?? 'false') !== 'false',
'intermediate' => ($vars['INTERMEDIATE_RSYNC_ENABLED'] ?? 'true') !== 'false',
'weekly' => ($vars['WEEKLY_RSYNC_ENABLED'] ?? 'false') !== 'false',
'monthly' => ($vars['MONTHLY_RSYNC_ENABLED'] ?? 'true') !== 'false',
];
// Active rsync profiles — from lock files
@@ -353,10 +362,11 @@ function vv_rsync_status(): array {
'daily' => 'daily_sync_maintenance',
'intermediate' => 'intermediate_sync_maintenance',
'weekly' => 'weekly_sync_maintenance',
'monthly' => 'monthly_maintenance',
];
$lastSync = [];
foreach ($scriptMap as $key => $scriptName) {
$logFile = LOG_DIR . "/$scriptName.json";
$logFile = LOG_DIR . "/Orchestrators/$scriptName.json";
if (!file_exists($logFile)) continue;
$stat = json_decode(@file_get_contents($logFile) ?: '{}', true) ?: [];
$lastSync[$key] = [
@@ -376,7 +386,7 @@ function vv_rsync_status(): array {
$p = explode('|', $line);
if (count($p) < 4 || ($p[0] ?? '') < $cutoff7) continue;
$name = $p[2] ?? '';
if (!$name) continue;
if (!$name || str_ends_with($name, '-fallback')) continue;
if (!isset($profiles[$name])) $profiles[$name] = ['runs' => 0, 'dur' => 0, 'bytes' => 0];
$profiles[$name]['runs']++;
$profiles[$name]['dur'] += (int)($p[3] ?? 0);
+12 -1
View File
@@ -121,6 +121,17 @@ function vv_cron_rebuild(array $schedule): bool {
}
$lines[] = "";
// Background writers — always injected, never user-configurable (excluded from scheduler UI).
$toolsDir = SCRIPTS_DIR . '/Plugin/unraid/Tools';
foreach ([
['* * * * *', 'api_cache_writer.sh'],
['0 */2 * * *', 'remote_arr_cache_writer.sh'],
] as [$cron, $script]) {
$path = "$toolsDir/$script";
if (file_exists($path)) $lines[] = "$cron bash \"$runner\" \"Plugin/unraid/Tools/$script\" \"$path\"";
}
$lines[] = "";
// Write to the plugin cron file; update_cron merges all plugin *.cron files into /etc/cron.d/root.
if (file_put_contents(CRON_FILE, implode("\n", $lines)) === false) return false;
exec('/usr/local/sbin/update_cron');
@@ -483,7 +494,7 @@ function vv_conf_script_map(): array {
// Read a boolean flag value (e.g. INTERMEDIATE_RSYNC_ENABLED) from master.conf.
function vv_conf_flag_value(string $name): bool {
$conf = file_get_contents(CONF_DIR . '/master.conf') ?: '';
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=\s*(true|false)\s*$/m', $conf, $m)) {
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=\s*(true|false)\s*(?:#.*)?$/m', $conf, $m)) {
return $m[1] === 'true';
}
return false;
+157 -1
View File
@@ -71,6 +71,21 @@
.vv-dk-pop-item.current { color:#4caf50; }
.vv-dk-pop-item.sep { border-top:1px solid #222;margin-top:4px;padding-top:8px; }
.vv-dk-pop-item.blue { color:#5c9fd4; }
.vv-dk-pop-item.red { color:#ef5350; }
/* Action button + job badge */
.vv-dk-ctr-act-btn { font-size:11px;padding:0 5px;border-radius:3px;border:1px solid #222;background:transparent;color:#333;cursor:pointer;line-height:1.7;margin-left:auto;flex-shrink:0; }
.vv-dk-ctr-act-btn:hover { background:#1e1e1e;color:#777; }
.vv-dk-job-badge { font-size:10px;padding:1px 6px;border-radius:2px;white-space:nowrap;flex-shrink:0; }
.vv-dk-job-badge.pulling { background:#0d1a2a;color:#5c7cfa; }
.vv-dk-job-badge.rebuilding { background:#1a1a0a;color:#cddc39; }
.vv-dk-job-badge.restarting { background:#1a1a2a;color:#9c89d4; }
.vv-dk-job-badge.done-ok { background:#0d1a0d;color:#4caf50; }
.vv-dk-job-badge.done-err { background:#1a0d0d;color:#ef5350; }
/* Log panel */
.vv-dk-log-panel { padding:8px 12px;background:#080808;border-top:1px solid #161616; }
.vv-dk-log-pre { margin:0;font-size:10px;color:#4a4a4a;font-family:monospace;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow-y:auto; }
</style>
<div class="vv-dk-toolbar">
@@ -125,6 +140,9 @@ function _ctrRow(c, folderId) {
const statusLbl = c.running ? 'RUNNING' : (c.status || 'STOPPED').toUpperCase();
const rowCls = 'vv-dk-ctr' + (c.running ? '' : ' stopped') + (_editMode ? ' edit-mode' : '');
const editAttr = _editMode ? `data-ctr="${_esc(c.name)}" data-folder="${folderId||''}" title="Move ${c.name}"` : '';
const ctrAttr = `data-ctr-name="${_esc(c.name)}"`;
const actBtn = !_editMode ? `<button class="vv-dk-ctr-act-btn" data-act-ctr="${_esc(c.name)}" title="Actions">···</button>` : '';
const jobBadge = !_editMode ? `<span class="vv-dk-job-badge" id="vv-dk-job-${_esc(c.name)}" style="display:none"></span>` : '';
// Icon or placeholder
const iconHtml = c.icon
@@ -161,17 +179,21 @@ function _ctrRow(c, folderId) {
`</div>`;
}
return `<div class="${rowCls}" ${editAttr}>
return `<div class="${rowCls}" ${editAttr} ${ctrAttr}>
${iconHtml}
<div>
<div class="vv-dk-ctr-name-row">
<span class="vv-dk-ctr-name">${nameHtml}</span>
<span class="vv-dk-ctr-status ${statusCls}">${statusLbl}</span>
${jobBadge}${actBtn}
</div>
<div class="vv-dk-ctr-image">${_esc(_shortImage(c.image||''))}</div>
${(netBadges || portBadges) ? `<div class="vv-dk-meta-row">${netBadges}${portBadges}${morePorts}</div>` : ''}
${pathsHtml}
</div>
</div>
<div class="vv-dk-log-panel" id="vv-dk-log-${_esc(c.name)}" style="display:none">
<pre class="vv-dk-log-pre"></pre>
</div>`;
}
@@ -255,9 +277,143 @@ function _render(data) {
_bindEvents();
}
// ── Container actions ─────────────────────────────────────────────────────────
let _activeJobs = {}; // { ctrName: intervalId }
function _actApi(params, cb) {
const fd = new FormData();
for (const [k, v] of Object.entries(params)) fd.append(k, v);
fetch('/plugins/varaverk/api/docker_action.php', {method: 'POST', body: fd})
.then(r => r.json()).then(cb)
.catch(() => cb({ok: false, error: 'Request failed'}));
}
function _jobBadgeEl(name) {
return document.getElementById('vv-dk-job-' + name);
}
function _setBadge(name, cls, text, autohide) {
const el = _jobBadgeEl(name);
if (!el) return;
el.className = 'vv-dk-job-badge ' + cls;
el.textContent = text;
el.style.display = '';
if (autohide) setTimeout(() => { if (el.parentNode) el.style.display = 'none'; }, 4000);
}
function _pollJob(name, jobId) {
_actApi({action: 'job_status', job_id: jobId}, data => {
const st = data.status;
if (st === 'pulling') _setBadge(name, 'pulling', 'Pulling…', false);
if (st === 'rebuilding') _setBadge(name, 'rebuilding', 'Rebuilding…', false);
if (st === 'done') {
clearInterval(_activeJobs[name]);
delete _activeJobs[name];
if (data.ok) {
_setBadge(name, 'done-ok', data.message || 'Done', true);
} else {
_setBadge(name, 'done-err', data.error || 'Failed', true);
}
setTimeout(_reload, 1500);
}
});
}
function _startJob(name, jobId) {
if (_activeJobs[name]) clearInterval(_activeJobs[name]);
_setBadge(name, 'pulling', 'Pulling…', false);
_activeJobs[name] = setInterval(() => _pollJob(name, jobId), 2000);
}
function _doRestart(name) {
_setBadge(name, 'restarting', 'Restarting…', false);
_actApi({action: 'restart', name}, data => {
if (data.ok) {
_setBadge(name, 'done-ok', 'Restarted', true);
setTimeout(_reload, 1000);
} else {
_setBadge(name, 'done-err', 'Failed', true);
}
});
}
function _doStartStop(name, start) {
_actApi({action: start ? 'start' : 'stop', name}, data => {
if (data.ok) setTimeout(_reload, 800);
else alert((start ? 'Start' : 'Stop') + ' failed: ' + (data.output || data.error || '?'));
});
}
function _doPullRebuild(name) {
_actApi({action: 'pull_rebuild', name}, data => {
if (data.ok && data.job_id) {
_startJob(name, data.job_id);
} else {
alert('Update failed: ' + (data.error || '?'));
}
});
}
function _toggleLog(name) {
const panel = document.getElementById('vv-dk-log-' + name);
if (!panel) return;
if (panel.style.display !== 'none') {
panel.style.display = 'none';
return;
}
const pre = panel.querySelector('.vv-dk-log-pre');
pre.textContent = 'Loading…';
panel.style.display = '';
_actApi({action: 'logs', name}, data => {
pre.textContent = data.ok ? (data.logs || '(no output)') : ('Error: ' + (data.error || '?'));
pre.scrollTop = pre.scrollHeight;
});
}
function _showActionsMenu(name, running, x, y) {
const pop = document.getElementById('vv-dk-popover');
let html = '';
if (running) {
html += `<div class="vv-dk-pop-item red" data-act="stop" data-act-n="${_esc(name)}">Stop</div>`;
html += `<div class="vv-dk-pop-item" data-act="restart" data-act-n="${_esc(name)}">Restart</div>`;
} else {
html += `<div class="vv-dk-pop-item current" data-act="start" data-act-n="${_esc(name)}">Start</div>`;
}
html += `<div class="vv-dk-pop-item sep blue" data-act="pull_rebuild" data-act-n="${_esc(name)}">Update (Pull &amp; Rebuild)</div>`;
html += `<div class="vv-dk-pop-item sep" data-act="logs" data-act-n="${_esc(name)}">View Logs</div>`;
pop.innerHTML = html;
pop.style.display = 'block';
const vw = window.innerWidth, vh = window.innerHeight;
pop.style.left = Math.min(x, vw - 200) + 'px';
pop.style.top = Math.min(y + 8, vh - 160) + 'px';
pop.querySelectorAll('[data-act]').forEach(el => {
el.addEventListener('click', () => {
const act = el.dataset.act, n = el.dataset.actN;
_hidePopover();
if (act === 'start' || act === 'stop') _doStartStop(n, act === 'start');
else if (act === 'restart') _doRestart(n);
else if (act === 'pull_rebuild') _doPullRebuild(n);
else if (act === 'logs') _toggleLog(n);
});
});
}
// ── Events ────────────────────────────────────────────────────────────────────
function _bindEvents() {
// Action menu button (non-edit mode)
document.querySelectorAll('[data-act-ctr]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const name = btn.dataset.actCtr;
const allCtrs = [...(_data.folders||[]).flatMap(f=>f.containers), ...(_data.ungrouped||[])];
const c = allCtrs.find(x => x.name === name);
_showActionsMenu(name, c?.running ?? false, e.clientX, e.clientY);
});
});
// Container click in edit mode → folder picker
document.querySelectorAll('.vv-dk-ctr.edit-mode').forEach(el => {
el.addEventListener('click', e => {
+72 -11
View File
@@ -545,6 +545,33 @@ function vvIoSum(devices) {
devices.forEach(dev => { const io = vvDiskIo[dev]; if (io) { r += io.r ?? 0; w += io.w ?? 0; } });
return [r, w];
}
function vvWdRsyncToggle(el) {
const flag = el.dataset.flag;
const on = el.dataset.enabled !== '1';
el.dataset.enabled = on ? '1' : '0';
el.style.color = on ? '#4caf50' : '#333';
el.style.background = on ? '#0f1a0f' : '#111';
el.style.borderColor = on ? '#1a3a1a' : '#222';
const fd = new FormData();
fd.append('name', flag);
fd.append('enabled', on ? '1' : '0');
fetch('/plugins/varaverk/api/flag_toggle.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
if (!d.ok) {
el.dataset.enabled = on ? '0' : '1';
el.style.color = on ? '#333' : '#4caf50';
el.style.background = on ? '#111' : '#0f1a0f';
el.style.borderColor = on ? '#222' : '#1a3a1a';
}
})
.catch(() => {
el.dataset.enabled = on ? '0' : '1';
el.style.color = on ? '#333' : '#4caf50';
el.style.background = on ? '#111' : '#0f1a0f';
el.style.borderColor = on ? '#222' : '#1a3a1a';
});
}
function vvIoTotalSum(devices) {
let tr = 0, tw = 0;
devices.forEach(dev => { const io = vvDiskIo[dev]; if (io) { tr += io.tr ?? 0; tw += io.tw ?? 0; } });
@@ -1102,11 +1129,26 @@ function vvPollMonitor() {
const ramFree = stab.ram_free_gb ?? 0;
const ramColor = ramFree < 6 ? '#f44336' : ramFree < 12 ? '#ff9800' : '#4caf50';
const load = stab.load_1min ?? 0;
const loadColor = load > 6 ? '#f44336' : load > 3 ? '#ff9800' : '#4caf50';
const _wdCores = sys.cpu_cores || 0;
const loadColor = _wdCores > 0
? (load > _wdCores * 2 ? '#f44336' : load > _wdCores ? '#ff9800' : '#4caf50')
: (load > 6 ? '#f44336' : load > 3 ? '#ff9800' : '#4caf50');
const nicOk = (stab.nic_state ?? '') === 'up';
const sshdOk = stab.sshd_ok ?? true;
const zombies = stab.zombies ?? 0;
const uptimeSec = sys.uptime_sec ?? 0;
const uptimeDays = Math.floor(uptimeSec / 86400);
const uptimeHrs = Math.floor((uptimeSec % 86400) / 3600);
const uptimeStr = uptimeDays > 0 ? `${uptimeDays}d ${uptimeHrs}h` : `${uptimeHrs}h`;
const uptimeColor = uptimeDays === 0 ? '#ff9800' : '#4caf50';
const stabCount = stabNames.length;
const fdOpen = stab.fd_open ?? 0;
const fdPct = stab.fd_pct ?? 0;
const fdStr = fdOpen >= 1e6 ? (fdOpen/1e6).toFixed(1)+'M' : fdOpen >= 1000 ? (fdOpen/1000).toFixed(1)+'k' : String(fdOpen);
const fdColor = fdPct >= 50 ? '#f44336' : fdPct >= 20 ? '#ff9800' : '#4caf50';
const cpuRow = stab.cpu_temp != null
? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : '';
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:11px;margin-top:8px;margin-bottom:6px;">
@@ -1117,9 +1159,13 @@ function vvPollMonitor() {
<span style="color:#444;">Load</span><span style="color:${loadColor};">${load}</span>
${cpuRow}
<span style="color:#444;">Zombies</span><span style="color:${zombies>0?'#ff9800':'#4caf50'};">${zombies}</span>
<span style="color:#444;">FD open</span><span style="color:${fdColor};">${fdStr}</span>
<span style="color:#444;">${stab.nic??'nic'}</span><span style="color:${nicOk?'#4caf50':'#f44336'};">● ${stab.nic_state??'?'}</span>
<span style="color:#444;">sshd</span><span style="color:${sshdOk?'#4caf50':'#f44336'};">${sshdOk?'● ok':'✗ down'}</span>
<span style="color:#444;">NPM</span><span style="color:${npmStrikes>0?'#ff9800':'#4caf50'};">${npmStrikes>0?npmStrikes+'× strikes':'● ok'}</span>
<span style="color:#444;">Uptime</span><span style="color:${uptimeColor};">${uptimeStr}</span>
<span style="color:#444;">Reboots</span><span style="color:${reboots>0?'#f44336':'#4caf50'};">${reboots}/12h</span>
<span style="color:#444;">Strikes</span><span style="color:${stabCount>0?'#ff9800':'#4caf50'};">${stabCount>0?stabCount+' active':'none'}</span>
</div>`;
html += statsHtml;
@@ -1317,13 +1363,24 @@ function vvPollMonitor() {
let html = `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<span style="font-size:10px;font-weight:600;color:${gCol};">● ${enabled ? 'ENABLED' : 'DISABLED'}</span>
<div style="display:flex;gap:3px;">`;
[['C','critical'],['D','daily'],['I','intermediate'],['W','weekly']].forEach(([s,k]) => {
const _flagMap = {
critical: 'CRITICAL_RSYNC_ENABLED',
intermediate: 'INTERMEDIATE_RSYNC_ENABLED',
daily: 'DAILY_RSYNC_ENABLED',
weekly: 'WEEKLY_RSYNC_ENABLED',
monthly: 'MONTHLY_RSYNC_ENABLED',
};
[['C','critical'],['I','intermediate'],['D','daily'],['W','weekly'],['M','monthly']].forEach(([s,k]) => {
const on = windows[k] ?? false;
const run = active.some(a => a.profile?.includes(k));
const flag = _flagMap[k];
const bg = run ? '#1a2a0a' : on ? '#0f1a0f' : '#111';
const brd = run ? '#3a6a1a' : on ? '#1a3a1a' : '#222';
const col = run ? '#8bc34a' : on ? '#4caf50' : '#333';
html += `<span title="${k}" style="font-size:9px;padding:2px 5px;border-radius:2px;
const tog = flag ? `data-flag="${flag}" data-enabled="${on?'1':'0'}" onclick="vvWdRsyncToggle(this)"` : '';
const cur = flag ? 'cursor:pointer;' : '';
const tip = `Toggle ${k} rsync`;
html += `<span title="${tip}" ${tog} style="${cur}font-size:9px;padding:2px 5px;border-radius:2px;
background:${bg};border:1px solid ${brd};color:${col};font-weight:600;">${s}</span>`;
});
html += `</div></div>`;
@@ -2180,27 +2237,31 @@ function vvRenderDockerFolders(data) {
return out;
}
// Build folder list — ungrouped gets a 📁 emoji icon
const allFolders = (data.folders ?? []).map(f => ({ ...f, isEmoji: false }));
const ug = data.ungrouped ?? [];
if (ug.length) allFolders.push({ id:'__ungrouped__', name:'Ungrouped', icon:'📁', isEmoji:true, containers:ug });
// Build item list — folders first, then ungrouped containers as individual rows
const _folders = (data.folders ?? []).map(f => ({ ...f, _type: 'folder', isEmoji: false }));
const _solo = (data.ungrouped ?? []).map(c => ({ ...c, _type: 'container' }));
const _items = [..._folders, ..._solo];
if (!allFolders.length) {
if (!_items.length) {
html += '<div class="vv-df-empty">No containers found</div>';
el.innerHTML = html;
return;
}
function renderItem(item) {
return item._type === 'folder' ? renderFolder(item) : renderContainer(item);
}
// Column count: 3 big / 2 intermediate / 1 small
const _w = window.innerWidth;
const _cols = _w > 1400 ? 3 : _w > 640 ? 2 : 1;
if (_cols === 1) {
html += `<div class="vv-df-col">${allFolders.map(renderFolder).join('')}</div>`;
html += `<div class="vv-df-col">${_items.map(renderItem).join('')}</div>`;
} else {
const perCol = Math.ceil(allFolders.length / _cols);
const perCol = Math.ceil(_items.length / _cols);
const colDivs = Array.from({length: _cols}, (_, i) =>
`<div class="vv-df-col">${allFolders.slice(i * perCol, (i + 1) * perCol).map(renderFolder).join('')}</div>`
`<div class="vv-df-col">${_items.slice(i * perCol, (i + 1) * perCol).map(renderItem).join('')}</div>`
).join('');
html += `<div class="vv-df-cols">${colDivs}</div>`;
}
+2
View File
@@ -535,6 +535,7 @@ const WIN_META = {
intermediate: { label: 'Intermediate', cadence: '4 hr' },
daily: { label: 'Daily', cadence: 'nightly' },
weekly: { label: 'Weekly', cadence: 'weekly' },
monthly: { label: 'Monthly', cadence: '30-day gate' },
fallback: { label: 'Fallback', cadence: 'on handback' },
};
@@ -830,6 +831,7 @@ function _settingsSection(data) {
${_toggle('INTERMEDIATE_RSYNC_ENABLED', w.intermediate, 'Intermediate')}
${_toggle('DAILY_RSYNC_ENABLED', w.daily, 'Daily')}
${_toggle('WEEKLY_RSYNC_ENABLED', w.weekly, 'Weekly')}
${_toggle('MONTHLY_RSYNC_ENABLED', w.monthly, 'Monthly')}
${_toggle('FALLBACK_RSYNC_ENABLED', w.fallback, 'Fallback')}
</div>
</div>
+194
View File
@@ -0,0 +1,194 @@
#!/bin/bash
# ==============================================================================================
# ========================= Arr Profile Enforcer ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Ensures every series/movie in Sonarr and Radarr is on the correct quality
# profile based on its root folder. Safe to re-run — only touches items whose
# current profile is wrong.
#
# RULES
# ─────────────────────────────────────────────────────────────────────────────
# Root folder path contains "kids" OR "anime" → kids profile
# All other root folders → default profile
#
# Profile names are looked up by name from the API at runtime, so profile IDs
# do not need to be hardcoded and work across hosts.
#
# ── USAGE ────────────────────────────────────────────────────────────────────
# arr_profile_enforcer.sh [--dry-run] [--sonarr-only] [--radarr-only]
#
# ── CONFIGURATION ────────────────────────────────────────────────────────────
# master.conf
# ARR_KIDS_PROFILE_NAME — profile name for kids/anime (default: "Kids shows")
# ARR_SONARR_DEFAULT_PROFILE — default Sonarr profile name (default: "Any")
# ARR_RADARR_DEFAULT_PROFILE — default Radarr profile name (default: "Any (mine)")
#
# ==============================================================================================
DRY_RUN=false
RUN_SONARR=true
RUN_RADARR=true
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--sonarr-only) RUN_RADARR=false ;;
--radarr-only) RUN_SONARR=false ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
detect_hosts
KIDS_PROFILE_NAME="${ARR_KIDS_PROFILE_NAME:-Kids shows}"
SONARR_DEFAULT="${ARR_SONARR_DEFAULT_PROFILE:-Any}"
RADARR_DEFAULT="${ARR_RADARR_DEFAULT_PROFILE:-Any (mine)}"
# ── Helpers ──────────────────────────────────────────────────────────────────
_arr_get() {
local url="$1" key="$2" endpoint="$3"
curl -sf --max-time 30 -H "X-Api-Key: $key" "$url/api/v3/$endpoint"
}
_arr_put() {
local url="$1" key="$2" endpoint="$3" body="$4"
curl -sf --max-time 60 -X PUT \
-H "X-Api-Key: $key" \
-H "Content-Type: application/json" \
-d "$body" \
"$url/api/v3/$endpoint"
}
_profile_id_by_name() {
local profiles_json="$1" name="$2"
php -r '
$profiles = json_decode(file_get_contents("php://stdin"), true);
$name = $argv[1];
foreach ($profiles as $p) {
if (strcasecmp($p["name"], $name) === 0) { echo $p["id"]; exit; }
}
exit(1);
' "$name" <<< "$profiles_json"
}
_is_kids_path() {
local path="$1"
local dir
dir=$(basename "$(dirname "$path")")
[[ "$dir" == *kids* || "$dir" == *anime* ]]
}
# ── Core enforcer ─────────────────────────────────────────────────────────────
# _enforce <label> <url> <api_key> <items_endpoint> <id_field> <editor_endpoint>
# <kids_profile_id> <default_profile_id>
_enforce() {
local label="$1" url="$2" key="$3" items_ep="$4"
local id_field="$5" editor_ep="$6"
local kids_id="$7" default_id="$8"
local items
items=$(_arr_get "$url" "$key" "$items_ep") || {
error "[$label] Cannot reach $url"
return 1
}
local to_kids=() to_default=()
while IFS='|' read -r id profile_id path; do
if _is_kids_path "$path"; then
[[ "$profile_id" -ne "$kids_id" ]] && to_kids+=("$id")
else
[[ "$profile_id" -ne "$default_id" ]] && to_default+=("$id")
fi
done < <(php -r '
$items = json_decode(file_get_contents("php://stdin"), true);
foreach ($items as $r) echo $r["id"]."|".$r["qualityProfileId"]."|".$r["path"]."\n";
' <<< "$items")
local total=$(( ${#to_kids[@]} + ${#to_default[@]} ))
log "arr_profile_enforcer" "[$label] ${#to_kids[@]}$KIDS_PROFILE_NAME | ${#to_default[@]} → default | $total to fix"
if [[ "$DRY_RUN" == true ]]; then
echo " [$label] DRY RUN — would set $KIDS_PROFILE_NAME on ${#to_kids[@]}, default on ${#to_default[@]}"
return 0
fi
_bulk_update() {
local profile_id="$1" prof_label="$2"
shift 2
local ids=("$@")
[[ ${#ids[@]} -eq 0 ]] && return 0
local ids_json
ids_json=$(printf '%s,' "${ids[@]}")
ids_json="[${ids_json%,}]"
local result
result=$(_arr_put "$url" "$key" "$editor_ep" \
"{\"${id_field}\":${ids_json},\"qualityProfileId\":${profile_id}}") || {
error "[$label] Bulk update failed for $prof_label"
return 1
}
local updated
updated=$(php -r 'echo count(json_decode(file_get_contents("php://stdin"), true));' <<< "$result")
log "arr_profile_enforcer" "[$label] Set $prof_label on $updated items"
}
_bulk_update "$kids_id" "$KIDS_PROFILE_NAME" "${to_kids[@]+"${to_kids[@]}"}"
_bulk_update "$default_id" "default" "${to_default[@]+"${to_default[@]}"}"
}
# ── Sonarr ────────────────────────────────────────────────────────────────────
if [[ "$RUN_SONARR" == true ]]; then
require_var SONARR_URL
require_var SONARR_API_KEY
SONARR_PROFILES=$(_arr_get "$SONARR_URL" "$SONARR_API_KEY" "qualityprofile") || {
error "Cannot reach Sonarr at $SONARR_URL"
exit 1
}
SONARR_KIDS_ID=$(_profile_id_by_name "$SONARR_PROFILES" "$KIDS_PROFILE_NAME") || {
error "Sonarr profile not found: '$KIDS_PROFILE_NAME'"
exit 1
}
SONARR_DEFAULT_ID=$(_profile_id_by_name "$SONARR_PROFILES" "$SONARR_DEFAULT") || {
error "Sonarr profile not found: '$SONARR_DEFAULT'"
exit 1
}
_enforce "Sonarr" "$SONARR_URL" "$SONARR_API_KEY" \
"series" "seriesIds" "series/editor" \
"$SONARR_KIDS_ID" "$SONARR_DEFAULT_ID"
fi
# ── Radarr ────────────────────────────────────────────────────────────────────
if [[ "$RUN_RADARR" == true ]]; then
require_var RADARR_URL
require_var RADARR_API_KEY
RADARR_PROFILES=$(_arr_get "$RADARR_URL" "$RADARR_API_KEY" "qualityprofile") || {
error "Cannot reach Radarr at $RADARR_URL"
exit 1
}
RADARR_KIDS_ID=$(_profile_id_by_name "$RADARR_PROFILES" "$KIDS_PROFILE_NAME") || {
error "Radarr profile not found: '$KIDS_PROFILE_NAME'"
exit 1
}
RADARR_DEFAULT_ID=$(_profile_id_by_name "$RADARR_PROFILES" "$RADARR_DEFAULT") || {
error "Radarr profile not found: '$RADARR_DEFAULT'"
exit 1
}
_enforce "Radarr" "$RADARR_URL" "$RADARR_API_KEY" \
"movie" "movieIds" "movie/editor" \
"$RADARR_KIDS_ID" "$RADARR_DEFAULT_ID"
fi
log "arr_profile_enforcer" "Done"