diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 7f1596e..3bac4be 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -640,6 +640,14 @@ # 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 +# MEDIA_SEED_ENABLED=false ← the onboard first-fill never starts +# +# MEDIA_SEED_ENABLED is the odd one out: it does not gate a schedule, it gates a single +# multi-week transfer. Rsync/media_seed.sh pushes every DAILY_SYNC_SHARES entry to a newly +# onboarded partner — on HOST1 that is ~28 TB against BW_LIMIT, so weeks. Turn it off when the +# partner is going to be filled some other way (a physically moved disk, an existing library), +# and onboard will finish without ever starting it. Turning it back on does not start anything +# by itself; dispatch it from the Partnership tab or run the script. # → Run individual: bash Rsync/rsync.sh /mnt/user/Movies # → When ready: INTERMEDIATE_RSYNC_ENABLED=true DAILY_RSYNC_ENABLED=true # NOTE ON THE DEFAULT: Tier 1 ships OFF. This template is what a brand-new node is seeded from @@ -655,6 +663,7 @@ 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 + MEDIA_SEED_ENABLED=true # Tier 2 — Rsync/media_seed.sh, the onboard first-fill # ━━━ Download Webhook ━━━ # Immediate push to remote nodes on every Sonarr/Radarr/Lidarr Download event. diff --git a/Partnership/partnership_onboard.sh b/Partnership/partnership_onboard.sh index 36ee3a5..1baeefa 100755 --- a/Partnership/partnership_onboard.sh +++ b/Partnership/partnership_onboard.sh @@ -1089,8 +1089,20 @@ _seed_job="Rsync/media_seed.sh" _seed_script="$SCRIPTS_ROOT/Rsync/media_seed.sh" _runner="$SCRIPTS_ROOT/Plugin/$PLATFORM/run_job.sh" +# Read from disk, not from the sourced value: Step 9c rewrote master.conf a few steps ago. +# Unset reads as on — the toggle postdates the seed, so a conf that has not been through a +# conf_upgrade must keep the behaviour it had. media_seed.sh checks this again itself; the +# check here exists so the summary can say "disabled" instead of dispatching a job whose only +# act is to exit. +_seed_gate=$(grep -m1 -E '^[[:space:]]*MEDIA_SEED_ENABLED=' "$SCRIPTS_ROOT/Configurations/master.conf" 2>/dev/null \ + | cut -d= -f2- | cut -d'#' -f1 | tr -d '"'"'" | tr -d '[:space:]') + if [[ "$SKIP_MEDIA_SEED" == true ]]; then warn "Skipping (--skip-media-seed)" +elif [[ -n "$_seed_gate" && "$_seed_gate" != "true" ]]; then + warn "MEDIA_SEED_ENABLED is '$_seed_gate' — not dispatching the seed" + warn "$MIRROR will need its library filled another way, or arm the toggle and start it" + warn "from the Partnership tab" elif [[ "$ONBOARD_OK" == false ]]; then warn "Skipping — onboard did not complete" elif [[ "$DRY_RUN" == true ]]; then @@ -1158,8 +1170,9 @@ echo " Step 10 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "s echo " Step 11 — Discovery: $( [[ "$POPULATE_OK" == skipped ]] && echo "skipped (unreachable)" || _ok "$POPULATE_OK" )" echo " Step 12 — Grouping: $( [[ "$FOLDER_OK" == skipped ]] && echo "skipped" || _ok "$FOLDER_OK" )" echo " Step 13 — Media seed: $( [[ "$SKIP_MEDIA_SEED" == true ]] && echo "skipped" \ + || { [[ -n "${_seed_gate:-}" && "${_seed_gate:-}" != "true" ]] && echo "off (MEDIA_SEED_ENABLED=$_seed_gate)" \ || { [[ "$MEDIA_SEED_DISPATCHED" == true ]] && echo "dispatched — runs in background ✅" \ - || echo "not dispatched ❌"; } )" + || echo "not dispatched ❌"; }; } )" echo "" if [[ "$ONBOARD_OK" == true ]]; then diff --git a/Plugin/unraid/include/partnership.php b/Plugin/unraid/include/partnership.php index cc33d00..54a1768 100644 --- a/Plugin/unraid/include/partnership.php +++ b/Plugin/unraid/include/partnership.php @@ -245,10 +245,22 @@ function vv_pt_remote_system(string $ip, string $sshKey): array { function vv_pt_media_seed(): array { $stat = '/var/log/varaverk/Rsync/media_seed.json'; $log = '/var/log/varaverk/Rsync/media_seed.log'; - if (!is_file($stat)) return []; + + // MEDIA_SEED_ENABLED is reported alongside the record rather than instead of it, because + // the two answer different questions: the toggle says whether a seed may start, the record + // says what the last one did. media_seed.sh exits 0 when the toggle is off — correct, it is + // a decision and not a failure — so a record reading "ok" beside a disabled toggle would + // otherwise render as "Seed complete" over a partner that was never seeded. + // + // Unset reads as enabled, matching media_seed.sh: the toggle postdates the script. + $raw = vv_read_conf_raw('master.conf'); + $toggle = vv_arr_scalar($raw, 'MEDIA_SEED_ENABLED'); + $enabled = ($toggle === '' || strtolower($toggle) === 'true'); + + if (!is_file($stat)) return ['enabled' => $enabled]; $j = json_decode((string)file_get_contents($stat), true); - if (!is_array($j)) return []; + if (!is_array($j)) return ['enabled' => $enabled]; $status = (string)($j['status'] ?? ''); $pid = (int)($j['pid'] ?? 0); @@ -280,11 +292,12 @@ function vv_pt_media_seed(): array { } return array_filter([ - 'status' => $status, - 'start' => isset($j['start']) ? (int)$j['start'] : null, - 'end' => isset($j['end']) ? (int)$j['end'] : null, - 'share' => $share, - 'line' => mb_substr($line, 0, 160), + 'enabled' => $enabled, + 'status' => $status, + 'start' => isset($j['start']) ? (int)$j['start'] : null, + 'end' => isset($j['end']) ? (int)$j['end'] : null, + 'share' => $share, + 'line' => mb_substr($line, 0, 160), ], fn($v) => $v !== null && $v !== ''); } diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php index 2ddf7ba..fb90586 100644 --- a/Plugin/unraid/pages/partnership.php +++ b/Plugin/unraid/pages/partnership.php @@ -381,6 +381,29 @@ function vvPtHostChanged(el) { // Stop the background media seed. Safe to press: rsync.sh runs --inplace --partial, so this // costs the file in flight and a later start resumes the share rather than restarting it. +// Start the background seed. Nothing here decides whether it may run — MEDIA_SEED_ENABLED is +// checked by the script, and the button is not rendered when the toggle is off. +async function vvPtStartSeed(btn) { + if (!await vvConfirm('Start the media seed?\n\nEvery DAILY_SYNC_SHARES entry is pushed to the partner. A first seed of a full library runs for days.')) return; + btn.disabled = true; btn.textContent = '⟳'; + try { + const r = await fetch('/plugins/varaverk/api/media_seed.php', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: new URLSearchParams({ + csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '', + action: 'start' + }) + }); + const d = await r.json(); + if (!d.ok) { vvAlert('Seed did not start: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = '▶ Seed'; return; } + if (typeof vvPtLoad === 'function') vvPtLoad(); + } catch (e) { + btn.disabled = false; btn.textContent = '▶ Seed'; + vvAlert('Error: ' + e); + } +} + async function vvPtStopSeed(btn) { if (!await vvConfirm('Stop the media seed?\n\nThe transfer resumes from where it stopped when restarted.')) return; btn.disabled = true; btn.textContent = '⟳'; @@ -1151,27 +1174,39 @@ function _renderActions(nodes, cfg) { // Media seed strip. The seed is dispatched detached by onboard Step 13 and runs for // days, so without this the page would show a finished onboard and no sign that // terabytes are still moving underneath it. + // The toggle is read first and the record second. media_seed.sh exits 0 when + // MEDIA_SEED_ENABLED is false, so the record can read "ok" for a seed that never + // moved a byte — showing that as "Seed complete" would be exactly the kind of + // outcome-from-a-toggle claim this codebase keeps having to unpick. const ms = remote.media_seed || {}; - if (ms.status) { - const seedRunning = ms.status === 'running'; - const seedColor = seedRunning ? '#ff9800' - : ms.status === 'ok' ? '#4caf50' - : ms.status === 'stopped' ? '#666' : '#f44336'; - const seedLabel = seedRunning ? 'Seeding' - : ms.status === 'ok' ? 'Seed complete' - : ms.status === 'stopped' ? 'Seed stopped' : 'Seed failed'; - html += `
-
- ${seedLabel} - ${ms.share ? `${vvEscHtml(ms.share)}` : ''} - ${seedRunning ? `` : ''} -
- ${ms.line ? `
${vvEscHtml(ms.line)}
` : ''} -
`; - } + const seedRunning = ms.status === 'running'; + let seedColor, seedLabel; + if (ms.enabled === false && !seedRunning) { + seedColor = '#555'; seedLabel = 'Seed off'; + } else if (seedRunning) { seedColor = '#ff9800'; seedLabel = 'Seeding'; } + else if (ms.status === 'ok') { seedColor = '#4caf50'; seedLabel = 'Seed complete'; } + else if (ms.status === 'stopped'){ seedColor = '#666'; seedLabel = 'Seed stopped'; } + else if (ms.status) { seedColor = '#f44336'; seedLabel = 'Seed failed'; } + else { seedColor = '#555'; seedLabel = 'Not seeded'; } + + const seedOff = ms.enabled === false; + const showLine = seedRunning || (ms.line && !seedOff); + html += `
+
+ ${seedLabel} + ${seedOff ? `MEDIA_SEED_ENABLED=false` : ''} + ${!seedOff && ms.share ? `${vvEscHtml(ms.share)}` : ''} + ${seedRunning + ? `` + : (seedOff ? '' : ``)} +
+ ${showLine ? `
${vvEscHtml(ms.line || '')}
` : ''} +
`; } // ── Delete Keys panel ────────────────────────────────────────────────── diff --git a/Plugin/unraid/pages/readme/ui-map.md b/Plugin/unraid/pages/readme/ui-map.md index 449176b..b190d01 100644 --- a/Plugin/unraid/pages/readme/ui-map.md +++ b/Plugin/unraid/pages/readme/ui-map.md @@ -1788,6 +1788,7 @@ Saved into `master.conf`, which does not need to be opened by hand. | `WEEKLY_RSYNC_ENABLED` | a switch | in this section | Tier 2 — weekly_sync_maintenance.sh rsync section | | `MONTHLY_RSYNC_ENABLED` | a switch | in this section | Tier 2 — monthly_maintenance.sh rsync section | | `FALLBACK_RSYNC_ENABLED` | a switch | Fallback tab | Tier 2 — fallback.sh writeback jobs on handback | +| `MEDIA_SEED_ENABLED` | a switch | Partnership tab | Tier 2 — Rsync/media_seed.sh, the onboard first-fill of a new partner | ## Rsync Merge Auto-Promote diff --git a/Rsync/README-Rsync.md b/Rsync/README-Rsync.md index 617d360..2b4a94f 100644 --- a/Rsync/README-Rsync.md +++ b/Rsync/README-Rsync.md @@ -111,6 +111,11 @@ HOST1's library is ~28 TB against a 12.5 MB/s `--bwlimit`, which is why onboard rather than waiting on it, and why the Partnership tab gives it a Stop button — `rsync.sh` runs `--inplace --partial`, so stopping costs the file in flight, not the share. +It answers to `MEDIA_SEED_ENABLED` in `master.conf` — a Tier 2 toggle beside the per-orchestrator +ones, still under Tier 1 `RSYNC_ENABLED`. Off means onboard finishes without ever dispatching it, +for a partner being filled from a moved disk or one that already holds the library. An unset +`MEDIA_SEED_ENABLED` reads as on, so a conf that predates the toggle keeps its behaviour. + --- ## ━━━ HOW THE SCRIPTS RELATE ━━━ diff --git a/Rsync/media_seed.sh b/Rsync/media_seed.sh index 439aaef..1864f41 100755 --- a/Rsync/media_seed.sh +++ b/Rsync/media_seed.sh @@ -41,12 +41,19 @@ # Interrupting this script costs the current file, not the current share, and a # re-run picks up where it stopped. Stopping it is cheap; that is deliberate. # +# Two Gates, And They Mean Different Things +# MEDIA_SEED_ENABLED (master.conf, Tier 2) switches off this transfer and only +# this transfer — for a partner being filled from a physically moved disk, or +# one that already holds the library. Off is a decision, so it exits 0. +# RSYNC_ENABLED (Tier 1) switches off every rsync on the host. Reaching this +# script with Tier 1 closed is a misconfiguration, so it exits 1. +# # Gate Read From Disk -# RSYNC_ENABLED is read out of master.conf here rather than trusted from the -# sourced environment. Onboard's Step 9c rewrites that file moments before -# dispatching this script, and each rsync.sh below sources it fresh anyway. -# With the gate closed rsync.sh moves nothing and still exits 0, so every share -# would be counted as seeded — refuse once instead of reporting fourteen no-ops. +# Both are read out of master.conf here rather than trusted from the sourced +# environment. Onboard's Step 9c rewrites that file moments before dispatching +# this script, and each rsync.sh below sources it fresh anyway. With Tier 1 +# closed rsync.sh moves nothing and still exits 0, so every share would be +# counted as seeded — refuse once instead of reporting fourteen no-ops. # # One Failed Share Is Not A Failed Seed # A share whose backing disk is unmounted on the partner fails its own rsync and @@ -58,10 +65,16 @@ # ============================================================================================== # # acquire_lock "skip" — a second seed cannot run beside the first -# Gate check — refuses when RSYNC_ENABLED is not true +# Gate checks — MEDIA_SEED_ENABLED then RSYNC_ENABLED, in that order # Empty list check — refuses when DAILY_SYNC_SHARES is empty # Per-share accounting — failures are listed by name, not summed away # +# An absent MEDIA_SEED_ENABLED reads as on, never as off. +# The toggle was added after this script shipped, so a master.conf that has not been +# through a conf_upgrade does not have the key. Defaulting an unset toggle to off would +# silently stop seeding on every node that has not upgraded — a change in behaviour +# delivered by a missing line, which is the hardest kind to notice. +# # ============================================================================================== # RUNTIME MODES # ============================================================================================== @@ -94,11 +107,24 @@ echo "━━━ $ICON_SYNC Media Share Seed — $MY_ID ($LOCAL_SERVER_NAME) — echo "" # See "Gate Read From Disk" above. The trailing comment is cut before the value is compared: -# master.conf writes this as `RSYNC_ENABLED=true # Tier 1 — global gate, overrides everything +# master.conf writes these as `RSYNC_ENABLED=true # Tier 1 — global gate, overrides everything # below`, so stopping at `cut -d= -f2` yields "true#Tier1—globalgate,…" and never matches. -_gate=$(grep -m1 -E '^[[:space:]]*RSYNC_ENABLED=' "$SCRIPTS_ROOT/Configurations/master.conf" 2>/dev/null \ - | cut -d= -f2- | cut -d'#' -f1 | tr -d '"'"'" | tr -d '[:space:]') +_read_gate() { + grep -m1 -E "^[[:space:]]*$1=" "$SCRIPTS_ROOT/Configurations/master.conf" 2>/dev/null \ + | cut -d= -f2- | cut -d'#' -f1 | tr -d '"'"'" | tr -d '[:space:]' +} +# Tier 2 first, because it is the more specific answer and the operator deserves to be told +# which switch stopped this. An unset MEDIA_SEED_ENABLED is treated as on: it was added after +# the seed already existed, so a conf that predates it must keep behaving the way it did. +_seed_gate=$(_read_gate MEDIA_SEED_ENABLED) +if [[ "$DRY_RUN" == false && -n "$_seed_gate" && "$_seed_gate" != "true" ]]; then + warn "MEDIA_SEED_ENABLED is '$_seed_gate' — the media seed is switched off in master.conf" + warn "Set it true there if the partner should be filled by rsync rather than by hand" + exit 0 +fi + +_gate=$(_read_gate RSYNC_ENABLED) if [[ "$DRY_RUN" == false && "$_gate" != "true" ]]; then error "RSYNC_ENABLED is '${_gate:-unset}' — rsync.sh would move nothing" error "Arm it in master.conf, then re-run this script"