diff --git a/Configurations/master.conf b/Configurations/master.conf index 6e95723..f78ae30 100644 --- a/Configurations/master.conf +++ b/Configurations/master.conf @@ -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 diff --git a/Deployment/host.conf.template b/Deployment/host.conf.template index e708145..1aec028 100644 --- a/Deployment/host.conf.template +++ b/Deployment/host.conf.template @@ -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=( diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index ae4c7f2..d6fbd7c 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -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 diff --git a/Media/play_state_sync.sh b/Media/play_state_sync.sh index 4718523..77ef7a2 100755 --- a/Media/play_state_sync.sh +++ b/Media/play_state_sync.sh @@ -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 diff --git a/Partnership/share_setup.sh b/Partnership/share_setup.sh old mode 100644 new mode 100755 diff --git a/Plugin/unraid/api/docker_action.php b/Plugin/unraid/api/docker_action.php index 0cb47ca..6f07364 100644 --- a/Plugin/unraid/api/docker_action.php +++ b/Plugin/unraid/api/docker_action.php @@ -1,21 +1,88 @@ 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']); diff --git a/Plugin/unraid/api/docker_pull_worker.php b/Plugin/unraid/api/docker_pull_worker.php new file mode 100644 index 0000000..787ab37 --- /dev/null +++ b/Plugin/unraid/api/docker_pull_worker.php @@ -0,0 +1,34 @@ + +[$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'] +); diff --git a/Plugin/unraid/api/rsync.php b/Plugin/unraid/api/rsync.php index ab21918..190c1e4 100644 --- a/Plugin/unraid/api/rsync.php +++ b/Plugin/unraid/api/rsync.php @@ -48,7 +48,8 @@ if ($action === 'rsync_log') { $base = vv_rsync_status(); $vars = vv_conf_vars(); -$base['windows']['fallback'] = ($vars['FALLBACK_RSYNC_ENABLED'] ?? 'true') !== 'false'; +$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 = []; diff --git a/Plugin/unraid/include/common.php b/Plugin/unraid/include/common.php index 8984535..4afbb03 100644 --- a/Plugin/unraid/include/common.php +++ b/Plugin/unraid/include/common.php @@ -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 [ diff --git a/Plugin/unraid/include/monitor.php b/Plugin/unraid/include/monitor.php index de80e6f..a610529 100644 --- a/Plugin/unraid/include/monitor.php +++ b/Plugin/unraid/include/monitor.php @@ -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); diff --git a/Plugin/unraid/include/scheduler.php b/Plugin/unraid/include/scheduler.php index e0dde99..d93c45e 100644 --- a/Plugin/unraid/include/scheduler.php +++ b/Plugin/unraid/include/scheduler.php @@ -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; diff --git a/Plugin/unraid/pages/docker.php b/Plugin/unraid/pages/docker.php index 85681ad..61e3b12 100644 --- a/Plugin/unraid/pages/docker.php +++ b/Plugin/unraid/pages/docker.php @@ -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; }
@@ -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 ? `` : ''; + const jobBadge = !_editMode ? `` : ''; // Icon or placeholder const iconHtml = c.icon @@ -161,17 +179,21 @@ function _ctrRow(c, folderId) { `
`; } - return `
+ return `
${iconHtml}
${nameHtml} ${statusLbl} + ${jobBadge}${actBtn}
${_esc(_shortImage(c.image||''))}
${(netBadges || portBadges) ? `
${netBadges}${portBadges}${morePorts}
` : ''} ${pathsHtml}
+
+ `; } @@ -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 += `
Stop
`; + html += `
Restart
`; + } else { + html += `
Start
`; + } + html += `
Update (Pull & Rebuild)
`; + html += `
View Logs
`; + 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 => { diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index 91110d1..a3702bd 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -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 ? `CPU${stab.cpu_temp}°C` : ''; let statsHtml = `
@@ -1117,9 +1159,13 @@ function vvPollMonitor() { Load${load} ${cpuRow} Zombies${zombies} + FD open${fdStr} ${stab.nic??'nic'}● ${stab.nic_state??'?'} sshd${sshdOk?'● ok':'✗ down'} NPM${npmStrikes>0?npmStrikes+'× strikes':'● ok'} + Uptime${uptimeStr} + Reboots${reboots}/12h + Strikes${stabCount>0?stabCount+' active':'none'}
`; html += statsHtml; @@ -1317,13 +1363,24 @@ function vvPollMonitor() { let html = `
● ${enabled ? 'ENABLED' : 'DISABLED'}
`; - [['C','critical'],['D','daily'],['I','intermediate'],['W','weekly']].forEach(([s,k]) => { - const on = windows[k] ?? false; - const run = active.some(a => a.profile?.includes(k)); - const bg = run ? '#1a2a0a' : on ? '#0f1a0f' : '#111'; - const brd = run ? '#3a6a1a' : on ? '#1a3a1a' : '#222'; - const col = run ? '#8bc34a' : on ? '#4caf50' : '#333'; - html += `${s}`; }); html += `
`; @@ -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 += '
No containers found
'; 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 += `
${allFolders.map(renderFolder).join('')}
`; + html += `
${_items.map(renderItem).join('')}
`; } else { - const perCol = Math.ceil(allFolders.length / _cols); + const perCol = Math.ceil(_items.length / _cols); const colDivs = Array.from({length: _cols}, (_, i) => - `
${allFolders.slice(i * perCol, (i + 1) * perCol).map(renderFolder).join('')}
` + `
${_items.slice(i * perCol, (i + 1) * perCol).map(renderItem).join('')}
` ).join(''); html += `
${colDivs}
`; } diff --git a/Plugin/unraid/pages/rsync.php b/Plugin/unraid/pages/rsync.php index a9b2565..f3d5f7b 100644 --- a/Plugin/unraid/pages/rsync.php +++ b/Plugin/unraid/pages/rsync.php @@ -531,10 +531,11 @@ function _vvRyViewPanelInner(key, cfg) { (function() { const WIN_META = { - critical: { label: 'Critical', cadence: '30 min' }, - intermediate: { label: 'Intermediate', cadence: '4 hr' }, - daily: { label: 'Daily', cadence: 'nightly' }, - weekly: { label: 'Weekly', cadence: 'weekly' }, + critical: { label: 'Critical', cadence: '30 min' }, + 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' }, }; @@ -829,7 +830,8 @@ function _settingsSection(data) { ${_toggle('CRITICAL_RSYNC_ENABLED', w.critical, 'Critical')} ${_toggle('INTERMEDIATE_RSYNC_ENABLED', w.intermediate, 'Intermediate')} ${_toggle('DAILY_RSYNC_ENABLED', w.daily, 'Daily')} - ${_toggle('WEEKLY_RSYNC_ENABLED', w.weekly, 'Weekly')} + ${_toggle('WEEKLY_RSYNC_ENABLED', w.weekly, 'Weekly')} + ${_toggle('MONTHLY_RSYNC_ENABLED', w.monthly, 'Monthly')} ${_toggle('FALLBACK_RSYNC_ENABLED', w.fallback, 'Fallback')}
diff --git a/Tools/arr_profile_enforcer.sh b/Tools/arr_profile_enforcer.sh new file mode 100755 index 0000000..388b706 --- /dev/null +++ b/Tools/arr_profile_enforcer.sh @@ -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