Compare commits
2
Commits
61b7d2a96a
...
76faf0f9f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76faf0f9f8 | ||
|
|
879109e55d |
+12
@@ -43,3 +43,15 @@ Plugin/dist/
|
||||
*.swp
|
||||
*~
|
||||
.vscode/
|
||||
|
||||
# ── Local-only plugin surfaces (per-installation, never pushed) ───────────────
|
||||
# Varaverk.page discovers pages/local/*.php and registers each as a tab; api/local/ holds their
|
||||
# endpoints. Both are symlinks into a store outside this repo, so what they contain belongs to
|
||||
# one installation and is not part of the project. The tracked loader is deliberately generic —
|
||||
# it names no page — so the public mirror never learns what a given server runs here.
|
||||
#
|
||||
# No trailing slash on either pattern. These paths are symlinks, not directories, and git treats
|
||||
# a symlink as a blob — a "dir/" pattern does not match one, so the entries sat untracked rather
|
||||
# than ignored, which is the same near-miss the *.bak rule above documents.
|
||||
Plugin/unraid/pages/local
|
||||
Plugin/unraid/api/local
|
||||
|
||||
@@ -146,10 +146,21 @@
|
||||
# (default: 7)
|
||||
#
|
||||
# DOWNLOAD_ORPHAN_MIN_VIDEO_MB
|
||||
# An entry with no video file above this size is JUNK (default: 50)
|
||||
# An entry with no video file above this size is JUNK (default: 50). Sonarr/Radarr only.
|
||||
#
|
||||
# DOWNLOAD_ORPHAN_MIN_AUDIO_MB
|
||||
# The same test for Lidarr (default: 2). Separate because a 50M floor would mark
|
||||
# every album folder as JUNK — single tracks rarely reach it.
|
||||
#
|
||||
# DOWNLOAD_ORPHAN_KEEP_MARKER
|
||||
# A file with this name inside a download folder pins it — the folder is never
|
||||
# classified or deleted (default: .vv-keep). For lossless rips the library holds
|
||||
# only at lower quality, which REDUNDANT would otherwise sweep.
|
||||
#
|
||||
# DOWNLOAD_ORPHAN_MAX_DELETE_GB
|
||||
# Abort the delete pass if the run total exceeds this (default: 100)
|
||||
# Per-run delete budget in GB (default: 100). A backlog above this is drained
|
||||
# safest-first (JUNK, then REDUNDANT, then UNMATCHED) up to the budget, and the
|
||||
# remainder is deferred to the next run rather than aborting the pass.
|
||||
#
|
||||
# SONARR_EXTENSIONS / RADARR_EXTENSIONS
|
||||
# Video extensions used to decide whether an entry contains real media
|
||||
@@ -161,7 +172,7 @@
|
||||
# arr_download_orphan_cleaner.sh — daily orchestrator entry
|
||||
# arr_download_orphan_cleaner.sh --dry-run — classify and report only
|
||||
# arr_download_orphan_cleaner.sh --status — show config and exit
|
||||
# arr_download_orphan_cleaner.sh --i-know-what-im-doing — bypass MAX_DELETE_GB cap
|
||||
# arr_download_orphan_cleaner.sh --i-know-what-im-doing — bypass MAX_DELETE_GB budget
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
@@ -197,6 +208,8 @@ fi
|
||||
|
||||
DOWNLOAD_ORPHAN_AGE="${DOWNLOAD_ORPHAN_AGE:-7}"
|
||||
DOWNLOAD_ORPHAN_MIN_VIDEO_MB="${DOWNLOAD_ORPHAN_MIN_VIDEO_MB:-50}"
|
||||
DOWNLOAD_ORPHAN_MIN_AUDIO_MB="${DOWNLOAD_ORPHAN_MIN_AUDIO_MB:-2}"
|
||||
DOWNLOAD_ORPHAN_KEEP_MARKER="${DOWNLOAD_ORPHAN_KEEP_MARKER:-.vv-keep}"
|
||||
DOWNLOAD_ORPHAN_MAX_DELETE_GB="${DOWNLOAD_ORPHAN_MAX_DELETE_GB:-100}"
|
||||
|
||||
if [[ "${SHOW_STATUS:-false}" == true ]]; then
|
||||
@@ -204,9 +217,10 @@ if [[ "${SHOW_STATUS:-false}" == true ]]; then
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_GEAR Enabled: ${DOWNLOAD_ORPHAN_CLEANER_ENABLED}"
|
||||
echo "$ICON_TIME Age gate: ${DOWNLOAD_ORPHAN_AGE}d"
|
||||
echo "$ICON_DISK Junk threshold: ${DOWNLOAD_ORPHAN_MIN_VIDEO_MB}M"
|
||||
echo "$ICON_DISK Junk threshold: ${DOWNLOAD_ORPHAN_MIN_VIDEO_MB}M video / ${DOWNLOAD_ORPHAN_MIN_AUDIO_MB}M audio"
|
||||
echo "$ICON_SHIELD Delete cap: ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G"
|
||||
for arr in SONARR RADARR; do
|
||||
echo "$ICON_SHIELD Keep marker: ${DOWNLOAD_ORPHAN_KEEP_MARKER}"
|
||||
for arr in SONARR RADARR LIDARR; do
|
||||
dir_var="${MY_ID}_${arr}_DOWNLOAD_DIR"
|
||||
echo "$ICON_CLEAN ${arr}: ${!dir_var:-<not configured>}"
|
||||
done
|
||||
@@ -221,13 +235,16 @@ AGE_CUTOFF=$(( $(date +%s) - DOWNLOAD_ORPHAN_AGE * 86400 ))
|
||||
TOTAL_DELETED=0
|
||||
TOTAL_DELETED_MB=0
|
||||
TOTAL_HELD=0
|
||||
TOTAL_DEFERRED=0
|
||||
TOTAL_KEPT=0
|
||||
TOTAL_SCANS=0
|
||||
|
||||
echo "━━━━━ $ICON_CLEAN DOWNLOAD ORPHAN CLEANER ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
[[ "$DRY_RUN" == true ]] && echo "$ICON_SKIP DRY RUN — nothing will be deleted or imported"
|
||||
|
||||
for arr in sonarr radarr; do
|
||||
for arr in sonarr radarr lidarr; do
|
||||
api_ver="v3"; [[ "$arr" == "lidarr" ]] && api_ver="v1"
|
||||
url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY"
|
||||
arr_url="${!url_var:-}"; arr_key="${!key_var:-}"
|
||||
dir_var="${MY_ID}_${arr^^}_DOWNLOAD_DIR"
|
||||
@@ -247,24 +264,38 @@ for arr in sonarr radarr; do
|
||||
echo "━━━ $ICON_SYNC ${arr^} — $dl_dir ━━━"
|
||||
|
||||
ver_var="${arr^^}_VERSION_MAJOR"
|
||||
check_arr_version "$arr_url" "$arr_key" "v3" "${!ver_var}" "${arr^}" || {
|
||||
check_arr_version "$arr_url" "$arr_key" "$api_ver" "${!ver_var}" "${arr^}" || {
|
||||
warn "${arr^} version check failed — skipping this arr"
|
||||
continue
|
||||
}
|
||||
# min_mb is per-arr because the JUNK test is "contains no real media file". A 50MB floor
|
||||
# is right for video and catastrophic for audio — most single tracks never reach it, so
|
||||
# every music folder would classify as JUNK and be deleted regardless of import state.
|
||||
case "$arr" in
|
||||
sonarr)
|
||||
queue_endpoint="queue?pageSize=1000&includeUnknownSeriesItems=true"
|
||||
exts_var="SONARR_EXTENSIONS"
|
||||
scan_command="DownloadedEpisodesScan"
|
||||
library_endpoint="series"
|
||||
min_mb="$DOWNLOAD_ORPHAN_MIN_VIDEO_MB"
|
||||
;;
|
||||
radarr)
|
||||
queue_endpoint="queue?pageSize=1000&includeUnknownMovieItems=true"
|
||||
exts_var="RADARR_EXTENSIONS"
|
||||
scan_command="DownloadedMoviesScan"
|
||||
library_endpoint="movie"
|
||||
min_mb="$DOWNLOAD_ORPHAN_MIN_VIDEO_MB"
|
||||
;;
|
||||
lidarr)
|
||||
queue_endpoint="queue?pageSize=1000&includeUnknownArtistItems=true"
|
||||
exts_var="LIDARR_EXTENSIONS"
|
||||
scan_command="DownloadedAlbumsScan"
|
||||
library_endpoint="artist"
|
||||
min_mb="$DOWNLOAD_ORPHAN_MIN_AUDIO_MB"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$arr" == "sonarr" ]]; then
|
||||
queue_endpoint="queue?pageSize=1000&includeUnknownSeriesItems=true"
|
||||
exts_var="SONARR_EXTENSIONS"
|
||||
scan_command="DownloadedEpisodesScan"
|
||||
library_endpoint="series"
|
||||
else
|
||||
queue_endpoint="queue?pageSize=1000&includeUnknownMovieItems=true"
|
||||
exts_var="RADARR_EXTENSIONS"
|
||||
scan_command="DownloadedMoviesScan"
|
||||
library_endpoint="movie"
|
||||
fi
|
||||
|
||||
QUEUE_JSON=$(arr_api "$arr_url" "$arr_key" "v3" "$queue_endpoint" "${arr^}") || {
|
||||
QUEUE_JSON=$(arr_api "$arr_url" "$arr_key" "$api_ver" "$queue_endpoint" "${arr^}") || {
|
||||
error "${arr^} queue fetch failed — cannot tell tracked from orphaned, skipping this arr"
|
||||
continue
|
||||
}
|
||||
@@ -282,11 +313,21 @@ for arr in sonarr radarr; do
|
||||
SCAN_PATHS=()
|
||||
UNMATCHED_PATHS=()
|
||||
UNMATCHED_SIZES=()
|
||||
arr_tracked=0; arr_recent=0; arr_held=0; arr_delete_mb=0
|
||||
arr_tracked=0; arr_recent=0; arr_held=0; arr_delete_mb=0; arr_kept=0
|
||||
|
||||
while IFS= read -r entry; do
|
||||
base="${entry##*/}"
|
||||
|
||||
# An operator keep-marker outranks every verdict below. Needed because REDUNDANT only
|
||||
# asks "does the library hold this album", not "at what quality" — a lossless rip whose
|
||||
# library copy is MP3 is redundant by that test and would be swept on the next run.
|
||||
# The marker is a file inside the folder rather than a conf list so it survives renames
|
||||
# and cannot drift out of sync with what is actually on disk.
|
||||
if [[ -e "$entry/$DOWNLOAD_ORPHAN_KEEP_MARKER" ]]; then
|
||||
arr_kept=$((arr_kept + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ -n "${PROTECTED[$base]:-}" ]]; then
|
||||
arr_tracked=$((arr_tracked + 1))
|
||||
continue
|
||||
@@ -298,18 +339,31 @@ for arr in sonarr radarr; do
|
||||
continue
|
||||
fi
|
||||
|
||||
has_video=false
|
||||
# JUNK means "holds no real media". That verdict is only as good as the extension
|
||||
# list, and a missing extension turns real content into a delete — 2026-08-21 the
|
||||
# audio list had no "wv", which classified 23 folders of WavPack lossless (1.5G per
|
||||
# file) as junk. So a folder with large files that are merely *unrecognised* is held
|
||||
# for review, never deleted; only a folder with nothing big in it at all is junk.
|
||||
has_media=false
|
||||
big_unknown=0
|
||||
while IFS= read -r f; do
|
||||
if has_extension "$f" "${arr_exts[@]}"; then
|
||||
has_video=true
|
||||
has_media=true
|
||||
break
|
||||
fi
|
||||
done < <(find "$entry" -type f -size +"${DOWNLOAD_ORPHAN_MIN_VIDEO_MB}"M 2>/dev/null)
|
||||
big_unknown=$((big_unknown + 1))
|
||||
done < <(find "$entry" -type f -size +"${min_mb}"M 2>/dev/null)
|
||||
|
||||
size_mb=$(du -sm "$entry" 2>/dev/null | cut -f1)
|
||||
size_mb=${size_mb:-0}
|
||||
|
||||
if [[ "$has_video" == false ]]; then
|
||||
if [[ "$has_media" == false ]] && (( big_unknown > 0 )); then
|
||||
warn " no recognised media, but $big_unknown large file(s) of unknown type — holding: $base"
|
||||
arr_held=$((arr_held + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$has_media" == false ]]; then
|
||||
DELETE_PATHS+=("$entry")
|
||||
DELETE_SIZES+=("$size_mb")
|
||||
DELETE_LABELS+=("JUNK")
|
||||
@@ -318,20 +372,43 @@ for arr in sonarr radarr; do
|
||||
fi
|
||||
|
||||
enc_title=$(jq -rn --arg t "$base" '$t|@uri')
|
||||
parse=$(arr_api "$arr_url" "$arr_key" "v3" "parse?title=${enc_title}" "${arr^}") || {
|
||||
parse=$(arr_api "$arr_url" "$arr_key" "$api_ver" "parse?title=${enc_title}" "${arr^}") || {
|
||||
warn " parse failed for: $base — holding"
|
||||
arr_held=$((arr_held + 1))
|
||||
continue
|
||||
}
|
||||
|
||||
if [[ "$arr" == "sonarr" ]]; then
|
||||
matched=$(echo "$parse" | jq '(.series != null) and ((.episodes | length) > 0)')
|
||||
missing=$(echo "$parse" | jq '[.episodes[]? | select(.hasFile == false)] | length')
|
||||
else
|
||||
# Radarr's parse never populates hasFile — movieFileId is the reliable signal
|
||||
matched=$(echo "$parse" | jq '.movie != null')
|
||||
missing=$(echo "$parse" | jq 'if (.movie.movieFileId // 0) > 0 then 0 else 1 end')
|
||||
fi
|
||||
case "$arr" in
|
||||
sonarr)
|
||||
matched=$(echo "$parse" | jq '(.series != null) and ((.episodes | length) > 0)')
|
||||
missing=$(echo "$parse" | jq '[.episodes[]? | select(.hasFile == false)] | length')
|
||||
;;
|
||||
radarr)
|
||||
# Radarr's parse never populates hasFile — movieFileId is the reliable signal
|
||||
matched=$(echo "$parse" | jq '.movie != null')
|
||||
missing=$(echo "$parse" | jq 'if (.movie.movieFileId // 0) > 0 then 0 else 1 end')
|
||||
;;
|
||||
lidarr)
|
||||
# Lidarr's parse returns albums with statistics:null, so the track count has
|
||||
# to be read back from album/{id} — the same shape of gap as Radarr's hasFile.
|
||||
matched=$(echo "$parse" | jq '(.artist != null) and ((.albums | length) > 0)')
|
||||
missing=1
|
||||
if [[ "$matched" == true ]]; then
|
||||
album_id=$(echo "$parse" | jq -r '.albums[0].id // empty')
|
||||
if [[ -z "$album_id" ]]; then
|
||||
warn " parse matched but returned no album id: $base — holding"
|
||||
arr_held=$((arr_held + 1))
|
||||
continue
|
||||
fi
|
||||
album_json=$(arr_api "$arr_url" "$arr_key" "$api_ver" "album/$album_id" "${arr^}") || {
|
||||
warn " album lookup failed for: $base — holding"
|
||||
arr_held=$((arr_held + 1))
|
||||
continue
|
||||
}
|
||||
missing=$(echo "$album_json" | jq 'if ((.statistics.trackFileCount // 0) > 0) then 0 else 1 end')
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$matched" != true ]]; then
|
||||
UNMATCHED_PATHS+=("$entry")
|
||||
@@ -354,7 +431,7 @@ for arr in sonarr radarr; do
|
||||
# fails on a small batch that is legitimately all-unmatched, which is the normal case
|
||||
# once daily runs have caught up.
|
||||
if (( ${#UNMATCHED_PATHS[@]} > 0 )); then
|
||||
library_count=$(arr_api "$arr_url" "$arr_key" "v3" "$library_endpoint" "${arr^}" | jq 'length' 2>/dev/null)
|
||||
library_count=$(arr_api "$arr_url" "$arr_key" "$api_ver" "$library_endpoint" "${arr^}" | jq 'length' 2>/dev/null)
|
||||
if [[ ! "$library_count" =~ ^[0-9]+$ ]] || (( library_count == 0 )); then
|
||||
warn " ${arr^}: library reports ${library_count:-no} titles — cannot trust 'no match', holding ${#UNMATCHED_PATHS[@]} unmatched"
|
||||
arr_held=$((arr_held + ${#UNMATCHED_PATHS[@]}))
|
||||
@@ -368,26 +445,59 @@ for arr in sonarr radarr; do
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( arr_delete_mb / 1024 > DOWNLOAD_ORPHAN_MAX_DELETE_GB )) && [[ "$I_KNOW" != true ]]; then
|
||||
error "${arr^}: delete total $((arr_delete_mb / 1024))G exceeds cap of ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G — aborting delete pass"
|
||||
notify "${arr^} download orphan delete total $((arr_delete_mb / 1024))G exceeds ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G cap on $(hostname) — possible partial queue data, nothing deleted. Re-run with --i-know-what-im-doing if legitimate." \
|
||||
# The cap is a per-run risk budget, not a reason to do nothing. Aborting the whole pass
|
||||
# once the backlog exceeds it is self-defeating: the backlog can never shrink below the
|
||||
# cap on its own, so every later run aborts too and the pool fills anyway (exactly how
|
||||
# 347G accumulated here by 2026-08-21). Delete in ascending order of risk instead, stop
|
||||
# at the cap, and defer the rest to the next run so a backlog drains over days.
|
||||
#
|
||||
# Live downloads are already protected by DOWNLOAD_ORPHAN_AGE, not by this cap — anything
|
||||
# in flight is younger than the age gate and never reaches classification. That is what
|
||||
# makes draining safe: the partial-queue-data case the cap was written for cannot put a
|
||||
# still-downloading entry in these arrays.
|
||||
cap_mb=$((DOWNLOAD_ORPHAN_MAX_DELETE_GB * 1024))
|
||||
cap_active=true
|
||||
[[ "$I_KNOW" == true || "$DRY_RUN" == true ]] && cap_active=false
|
||||
|
||||
arr_deferred=0; arr_deferred_mb=0; arr_run_mb=0
|
||||
|
||||
if [[ "$cap_active" == true ]] && (( arr_delete_mb > cap_mb )); then
|
||||
warn " ${arr^}: $((arr_delete_mb / 1024))G classified vs ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G cap — deleting safest-first up to the cap, deferring the rest"
|
||||
notify "${arr^} download orphan backlog is $((arr_delete_mb / 1024))G on $(hostname), above the ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G per-run cap. Draining safest-first; the remainder follows on later runs. Re-run with --i-know-what-im-doing to clear it in one pass." \
|
||||
"Download Orphan Cleaner" "warning"
|
||||
unset PROTECTED
|
||||
continue
|
||||
fi
|
||||
|
||||
for i in "${!DELETE_PATHS[@]}"; do
|
||||
entry="${DELETE_PATHS[$i]}"
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo " $ICON_SKIP would delete [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)"
|
||||
else
|
||||
echo " $ICON_TRASH deleting [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)"
|
||||
rm -rf "$entry"
|
||||
fi
|
||||
TOTAL_DELETED=$((TOTAL_DELETED + 1))
|
||||
TOTAL_DELETED_MB=$((TOTAL_DELETED_MB + DELETE_SIZES[i]))
|
||||
# JUNK first (no media at all), then REDUNDANT (parse-verified already in the library),
|
||||
# then UNMATCHED last — it rests on "the arr does not know this title", the weakest of
|
||||
# the three signals, so it is the first thing the cap defers.
|
||||
for pass in JUNK REDUNDANT UNMATCHED; do
|
||||
for i in "${!DELETE_PATHS[@]}"; do
|
||||
[[ "${DELETE_LABELS[$i]}" == "$pass" ]] || continue
|
||||
entry="${DELETE_PATHS[$i]}"
|
||||
|
||||
if [[ "$cap_active" == true ]] && (( arr_run_mb + DELETE_SIZES[i] > cap_mb )); then
|
||||
arr_deferred=$((arr_deferred + 1))
|
||||
arr_deferred_mb=$((arr_deferred_mb + DELETE_SIZES[i]))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo " $ICON_SKIP would delete [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)"
|
||||
else
|
||||
echo " $ICON_TRASH deleting [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)"
|
||||
rm -rf "$entry"
|
||||
fi
|
||||
arr_run_mb=$((arr_run_mb + DELETE_SIZES[i]))
|
||||
TOTAL_DELETED=$((TOTAL_DELETED + 1))
|
||||
TOTAL_DELETED_MB=$((TOTAL_DELETED_MB + DELETE_SIZES[i]))
|
||||
done
|
||||
done
|
||||
|
||||
if (( arr_deferred > 0 )); then
|
||||
echo " $ICON_WARN ${arr^}: deferred $arr_deferred entries ($((arr_deferred_mb / 1024))G) to the next run — cap reached"
|
||||
TOTAL_DEFERRED=$((TOTAL_DEFERRED + arr_deferred))
|
||||
fi
|
||||
|
||||
for base in "${SCAN_PATHS[@]}"; do
|
||||
if [[ -z "$container_dir" ]]; then
|
||||
echo " $ICON_WARN IMPORTABLE but ${cdir_var} not set — holding: $base"
|
||||
@@ -406,8 +516,9 @@ for arr in sonarr radarr; do
|
||||
fi
|
||||
done
|
||||
|
||||
echo " $ICON_SUMMARY ${arr^}: $arr_tracked tracked, $arr_recent recent, ${#DELETE_PATHS[@]} deleted ($((arr_delete_mb / 1024))G), ${#SCAN_PATHS[@]} import scans, $arr_held held"
|
||||
echo " $ICON_SUMMARY ${arr^}: $arr_tracked tracked, $arr_kept kept, $arr_recent recent, $((${#DELETE_PATHS[@]} - arr_deferred)) deleted ($((arr_run_mb / 1024))G), $arr_deferred deferred ($((arr_deferred_mb / 1024))G), ${#SCAN_PATHS[@]} import scans, $arr_held held"
|
||||
TOTAL_HELD=$((TOTAL_HELD + arr_held))
|
||||
TOTAL_KEPT=$((TOTAL_KEPT + arr_kept))
|
||||
unset PROTECTED
|
||||
done
|
||||
|
||||
@@ -416,6 +527,8 @@ echo "━━━━━ $ICON_DONE SUMMARY ━━━━━"
|
||||
echo "$ICON_TRASH Deleted: $TOTAL_DELETED ($((TOTAL_DELETED_MB / 1024))G)"
|
||||
echo "$ICON_RUN Import scans: $TOTAL_SCANS"
|
||||
echo "$ICON_WARN Held: $TOTAL_HELD"
|
||||
echo "$ICON_SKIP Deferred: $TOTAL_DEFERRED"
|
||||
echo "$ICON_SHIELD Kept (marker): $TOTAL_KEPT"
|
||||
|
||||
# Held alone never notifies — there is always something awaiting review, and on a daily
|
||||
# schedule that would be a notification every morning saying nothing happened.
|
||||
|
||||
@@ -477,8 +477,42 @@ $validTabs = ['monitor', 'scheduler', 'watchdog', 'partnership', 'fallback', 'ar
|
||||
$_vv_ai = vv_ai_owner_ui_on();
|
||||
if ($_vv_ai) $validTabs[] = 'ai';
|
||||
|
||||
// ── Local pages ───────────────────────────────────────────────────────────────────────────────
|
||||
// pages/local/ is gitignored, so whatever is in it belongs to this installation alone and never
|
||||
// reaches the public mirror. This loader is the tracked half: a generic extension point that
|
||||
// knows nothing about what it is loading.
|
||||
//
|
||||
// It exists because the alternative — a tracked `if (file_exists(pages/thing.php))` per private
|
||||
// page — puts the name and purpose of every private page into the public repo, which defeats the
|
||||
// point of keeping the page out of it.
|
||||
//
|
||||
// Discovered rather than configured: a conf key listing local pages would itself be a tracked
|
||||
// file naming them, and an untracked one would be a second thing to keep in sync with the
|
||||
// directory. The directory is the declaration.
|
||||
//
|
||||
// The label comes from a `// vv-local-page: Name` line in the first 2KB of the file, falling back
|
||||
// to the capitalised id. Reading it out of the file keeps the page self-describing — nothing
|
||||
// outside it has to be edited to add one.
|
||||
$localPages = [];
|
||||
foreach (glob("$pluginDir/pages/local/*.php") ?: [] as $_lp) {
|
||||
$_id = basename($_lp, '.php');
|
||||
// Ids are restricted and collisions rejected: $tab is user input that becomes an include
|
||||
// path below, and a local page must never be able to shadow a real tab.
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_-]{0,31}$/', $_id)) continue;
|
||||
if (in_array($_id, $validTabs, true)) continue;
|
||||
$_lbl = ucfirst($_id);
|
||||
if (preg_match('/^\s*(?:\/\/|#)\s*vv-local-page:\s*(.+)$/m', (string)@file_get_contents($_lp, false, null, 0, 2048), $_m)) {
|
||||
$_lbl = trim($_m[1]);
|
||||
}
|
||||
$localPages[$_id] = ['path' => $_lp, 'label' => $_lbl];
|
||||
$validTabs[] = $_id;
|
||||
}
|
||||
unset($_lp, $_id, $_lbl, $_m);
|
||||
|
||||
if (!in_array($tab, $validTabs)) $tab = 'monitor';
|
||||
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Media Stack', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings', 'ai' => 'AI'];
|
||||
foreach ($localPages as $_id => $_lp) $tabLabels[$_id] = $_lp['label'];
|
||||
unset($_id, $_lp);
|
||||
|
||||
// Cache stamp for the stylesheet and script below. Both are served straight off the plugin
|
||||
// directory at a path that never changes, so a browser holding an old copy keeps using it after
|
||||
@@ -521,9 +555,12 @@ foreach (['css/varaverk.css', 'js/varaverk.js'] as $_vv_a) {
|
||||
<!-- Tab content -->
|
||||
<div id="vv-content">
|
||||
<?php
|
||||
$page = "$pluginDir/pages/$tab.php";
|
||||
// Local pages resolve from the map built above, never by composing a path out of $tab —
|
||||
// the map's keys were validated against a strict pattern, so nothing user-supplied
|
||||
// reaches an include.
|
||||
$page = isset($localPages[$tab]) ? $localPages[$tab]['path'] : "$pluginDir/pages/$tab.php";
|
||||
if (file_exists($page)) include $page;
|
||||
else echo "<p>Page not found: $tab</p>";
|
||||
else echo "<p>Page not found: " . htmlspecialchars($tab, ENT_QUOTES) . "</p>";
|
||||
?>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user