Compare commits
2
Commits
e5169b241e
...
f42ecc8464
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f42ecc8464 | ||
|
|
ac2986b141 |
@@ -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
|
||||
|
||||
+152
-19
@@ -16,16 +16,23 @@
|
||||
# AUTO-DETECTED FIELDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# HOST CONF (Configurations/<hostid>.conf)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# HOSTN_OWNER from hostname (strip unRAID- prefix, lowercase)
|
||||
# HOSTN_SSH_KEY from hostname convention (/root/.ssh/<owner>_rsync_automation)
|
||||
# HOSTN_STORAGE_MODE_INTERNAL from boot device transport (NVMe/SSD=true, USB=false)
|
||||
# HOSTN_RADARR_API_KEY from Radarr config.xml (found via docker volume mount)
|
||||
# HOSTN_RADARR_URL from Radarr config.xml port
|
||||
# HOSTN_RADARR_MOVIES_ROOT from Radarr rootFolder API
|
||||
# HOSTN_RADARR_PATH_MAP from Radarr docker volume mounts vs root folder path
|
||||
# HOSTN_SONARR_API_KEY from Sonarr config.xml
|
||||
# HOSTN_SONARR_URL from Sonarr config.xml port
|
||||
# HOSTN_SONARR_TV_ROOT from Sonarr rootFolder API
|
||||
# HOSTN_SONARR_PATH_MAP from Sonarr docker volume mounts vs root folder path
|
||||
# HOSTN_LIDARR_API_KEY from Lidarr config.xml
|
||||
# HOSTN_LIDARR_URL from Lidarr config.xml port
|
||||
# HOSTN_RADARR_MOVIE_ROOT from Radarr rootFolder API
|
||||
# HOSTN_SONARR_TV_ROOT from Sonarr rootFolder API
|
||||
# HOSTN_LIDARR_MUSIC_ROOT from Lidarr rootFolder API
|
||||
# HOSTN_LIDARR_PATH_MAP from Lidarr docker volume mounts vs root folder path
|
||||
# HOSTN_SABNZBD_API_KEY from sabnzbd.ini
|
||||
# HOSTN_SABNZBD_URL from sabnzbd.ini port
|
||||
# HOSTN_SLSKD_API_KEY from slskd config.yml
|
||||
@@ -38,7 +45,14 @@
|
||||
# HOSTN_JELLYFIN_CONTAINER fuzzy match from docker ps
|
||||
# HOSTN_JELLYFIN_URL from docker port binding
|
||||
# HOSTN_TRANSCODE_SSD from Emby/Jellyfin container /transcode volume mount
|
||||
# HOSTN_SYS_WATCHDOG_NIC from ip route default gateway interface
|
||||
# HOSTN_AUTHELIA_CONTAINER fuzzy match from docker ps
|
||||
# HOSTN_AUTHELIA_CONFIG from Authelia container /config volume mount
|
||||
# HOSTN_SYS_WATCHDOG_NIC from ip route default gateway interface
|
||||
#
|
||||
# MASTER CONF (Configurations/master.conf)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# HOST1 / HOST2 local hostname written to MY_ID slot
|
||||
# GITEA_CONTAINER fuzzy match from docker ps
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
@@ -74,6 +88,8 @@ detect_hosts
|
||||
CONF_FILE="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
|
||||
[[ ! -f "$CONF_FILE" ]] && { error "Conf file not found: $CONF_FILE"; exit 1; }
|
||||
|
||||
MASTER_CONF="$SCRIPTS_ROOT/Configurations/master.conf"
|
||||
|
||||
log "$ICON_GEAR Config: conf=${CONF_FILE} overwrite=${OVERWRITE:-false} no-push=${NO_PUSH:-false}"
|
||||
|
||||
UPDATED=0
|
||||
@@ -85,13 +101,14 @@ echo ""
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
[[ "$OVERWRITE" == true ]] && warn "OVERWRITE mode — existing values will be replaced"
|
||||
|
||||
# ── Helper: write a var into conf if empty (or --overwrite) ──────────────────
|
||||
# ── Helper: write a var into a conf file if empty (or --overwrite) ────────────
|
||||
# Optional 4th arg: target file (defaults to $CONF_FILE)
|
||||
_set_conf_var() {
|
||||
local var_name="$1" value="$2" label="$3"
|
||||
local var_name="$1" value="$2" label="$3" target="${4:-$CONF_FILE}"
|
||||
[[ -z "$value" ]] && return
|
||||
|
||||
local current
|
||||
current=$(grep -oP "(?<=^\s*${var_name}=\")[^\"]*" "$CONF_FILE" 2>/dev/null | head -1)
|
||||
current=$(grep -oP "(?<=^\s*${var_name}=\")[^\"]*" "$target" 2>/dev/null | head -1)
|
||||
|
||||
if [[ -n "$current" ]] && [[ "$OVERWRITE" == false ]]; then
|
||||
log "$label: already set (${current:0:8}…) — skipping"
|
||||
@@ -104,15 +121,59 @@ _set_conf_var() {
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -q "^\s*${var_name}=" "$CONF_FILE"; then
|
||||
sed -i "s|^\(\s*${var_name}\s*=\s*\)\"[^\"]*\"|\1\"${value}\"|" "$CONF_FILE"
|
||||
if grep -q "^\s*${var_name}=" "$target"; then
|
||||
sed -i "s|^\(\s*${var_name}\s*=\s*\)\"[^\"]*\"|\1\"${value}\"|" "$target"
|
||||
else
|
||||
printf '\n %s="%s"\n' "$var_name" "$value" >> "$CONF_FILE"
|
||||
printf '\n %s="%s"\n' "$var_name" "$value" >> "$target"
|
||||
fi
|
||||
info "$label: set ✅"
|
||||
(( UPDATED++ ))
|
||||
}
|
||||
|
||||
# ── Helper: add or update an entry in a declare -A map block ──────────────────
|
||||
# Inserts [key]="value" before the closing ) of the named map in $CONF_FILE.
|
||||
# Skips if an identical uncommented entry already exists (unless --overwrite).
|
||||
_set_conf_map_entry() {
|
||||
local map_name="$1" key="$2" value="$3" label="$4"
|
||||
[[ -z "$key" || -z "$value" ]] && return
|
||||
|
||||
if grep -qP "^\s*\[\"${key//\//\\/}\"\]=" "$CONF_FILE" 2>/dev/null; then
|
||||
if [[ "$OVERWRITE" == false ]]; then
|
||||
log "$label: already set — skipping"
|
||||
(( SKIPPED++ ))
|
||||
return
|
||||
fi
|
||||
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would update ${map_name}[\"${key}\"]"; return; }
|
||||
sed -i "s|^\(\s*\)\[\"${key}\"\]=\"[^\"]*\"|\1[\"${key}\"]=\"${value}\"|" "$CONF_FILE"
|
||||
info "$label: set ✅"
|
||||
(( UPDATED++ ))
|
||||
return
|
||||
fi
|
||||
|
||||
if ! grep -q "declare -A ${map_name}=" "$CONF_FILE" 2>/dev/null; then
|
||||
warn "$label: ${map_name} not found in conf — skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would set ${map_name}[\"${key}\"] = \"${value}\""; return; }
|
||||
|
||||
awk -v mapname="${map_name}" -v key="$key" -v val="$value" '
|
||||
BEGIN { in_map=0; done=0 }
|
||||
!done && index($0, "declare -A " mapname) { in_map=1 }
|
||||
in_map && !done && /^\s*\)/ {
|
||||
printf " [\"%s\"]=\"%s\"\n", key, val
|
||||
done=1; in_map=0
|
||||
}
|
||||
{ print }
|
||||
' "$CONF_FILE" > "${CONF_FILE}.tmp" && mv "${CONF_FILE}.tmp" "$CONF_FILE" || {
|
||||
rm -f "${CONF_FILE}.tmp"
|
||||
warn "$label: failed to update ${map_name}"
|
||||
return 1
|
||||
}
|
||||
info "$label: set ✅"
|
||||
(( UPDATED++ ))
|
||||
}
|
||||
|
||||
# ── Helper: find arr config dir via docker volume mount ───────────────────────
|
||||
_arr_config_dir() {
|
||||
local pattern="$1"
|
||||
@@ -150,19 +211,35 @@ _docker_volume_host() {
|
||||
'.[0].Mounts[]? | select(.Destination == $d) | .Source' 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# ── Helper: find host↔container path mapping for a given container path ───────
|
||||
# Finds the most specific mount whose destination is a prefix of container_path
|
||||
# and where source != destination (i.e. an actual remapping exists).
|
||||
# Outputs "source|destination" or nothing if no remapping found.
|
||||
_docker_path_map() {
|
||||
local container="$1" container_path="$2"
|
||||
docker inspect "$container" 2>/dev/null | \
|
||||
jq -r '.[0].Mounts[]? | select(.Destination != "/config") | "\(.Source)|\(.Destination)"' \
|
||||
2>/dev/null | \
|
||||
awk -F'|' -v target="$container_path" '
|
||||
$1 != $2 && length($2) > 0 && index(target, $2) == 1 { print length($2), $0 }
|
||||
' | sort -rn | head -1 | cut -d' ' -f2-
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Owner short name ──────────────────────────────────────────────────────────────────────────
|
||||
# ── Owner short name + SSH key ────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
owner=$(echo "$LOCAL_SERVER_NAME" | sed 's/^[Uu][Nn][Rr][Aa][Ii][Dd]-//i' | tr '[:upper:]' '[:lower:]')
|
||||
_set_conf_var "${MY_ID}_OWNER" "$owner" "Owner short name"
|
||||
_set_conf_var "${MY_ID}_OWNER" "$owner" "Owner short name"
|
||||
_set_conf_var "${MY_ID}_SSH_KEY" "/root/.ssh/${owner}_rsync_automation" "SSH key path"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Arr API keys + URLs + root paths ─────────────────────────────────────────────────────────
|
||||
# ── Arr API keys + URLs + root paths + path maps ──────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
for arr in radarr sonarr lidarr; do
|
||||
arr_upper="${arr^^}"
|
||||
arr_container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${arr}")
|
||||
config_dir=$(_arr_config_dir "$arr") || {
|
||||
log "${arr_upper}: no running container found — skipping"
|
||||
continue
|
||||
@@ -189,10 +266,23 @@ for arr in radarr sonarr lidarr; do
|
||||
root_path=$(echo "$root_json" | jq -r '.[0].path // empty' 2>/dev/null)
|
||||
|
||||
case "$arr" in
|
||||
radarr) _set_conf_var "${MY_ID}_RADARR_MOVIE_ROOT" "$root_path" "Radarr movie root" ;;
|
||||
sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT" "$root_path" "Sonarr TV root" ;;
|
||||
lidarr) _set_conf_var "${MY_ID}_LIDARR_MUSIC_ROOT" "$root_path" "Lidarr music root" ;;
|
||||
radarr) _set_conf_var "${MY_ID}_RADARR_MOVIES_ROOT" "$root_path" "Radarr movies root" ;;
|
||||
sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT" "$root_path" "Sonarr TV root" ;;
|
||||
lidarr) _set_conf_var "${MY_ID}_LIDARR_MUSIC_ROOT" "$root_path" "Lidarr music root" ;;
|
||||
esac
|
||||
|
||||
if [[ -n "$root_path" && -n "$arr_container" ]]; then
|
||||
map_entry=$(_docker_path_map "$arr_container" "$root_path")
|
||||
if [[ -n "$map_entry" ]]; then
|
||||
map_src="${map_entry%%|*}"
|
||||
map_dest="${map_entry##*|}"
|
||||
case "$arr" in
|
||||
radarr) _set_conf_map_entry "${MY_ID}_RADARR_PATH_MAP" "$map_dest" "$map_src" "Radarr path map" ;;
|
||||
sonarr) _set_conf_map_entry "${MY_ID}_SONARR_PATH_MAP" "$map_dest" "$map_src" "Sonarr path map" ;;
|
||||
lidarr) _set_conf_map_entry "${MY_ID}_LIDARR_PATH_MAP" "$map_dest" "$map_src" "Lidarr path map" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -205,7 +295,7 @@ sab_dir=$(_arr_config_dir "sabnzbd") && {
|
||||
if [[ -f "$sab_ini" ]]; then
|
||||
sab_key=$(grep -oP '(?<=^api_key\s*=\s*)\S+' "$sab_ini" 2>/dev/null | head -1)
|
||||
sab_port=$(grep -oP '(?<=^port\s*=\s*)\d+' "$sab_ini" 2>/dev/null | head -1)
|
||||
_set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key" "SABnzbd API key"
|
||||
_set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key" "SABnzbd API key"
|
||||
_set_conf_var "${MY_ID}_SABNZBD_URL" "http://localhost:${sab_port:-8080}" "SABnzbd URL"
|
||||
fi
|
||||
}
|
||||
@@ -221,7 +311,7 @@ slskd_dir=$(_arr_config_dir "slskd") && {
|
||||
[[ -z "$slskd_key" ]] && \
|
||||
slskd_key=$(grep -oP '(?<=apikey:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
|
||||
slskd_port=$(grep -oP '(?<=port:\s)\d+' "$slskd_yml" 2>/dev/null | head -1)
|
||||
_set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key" "slskd API key"
|
||||
_set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key" "slskd API key"
|
||||
_set_conf_var "${MY_ID}_SLSKD_URL" "http://localhost:${slskd_port:-5030}" "slskd URL"
|
||||
fi
|
||||
}
|
||||
@@ -257,8 +347,8 @@ for pattern in "emby" "jellyfin"; do
|
||||
case "$pattern" in
|
||||
emby)
|
||||
host_port=$(_docker_host_port "$container" "8096")
|
||||
_set_conf_var "${MY_ID}_EMBY_CONTAINER" "$container" "Emby container name"
|
||||
_set_conf_var "${MY_ID}_EMBY_URL" "http://localhost:${host_port:-8096}" "Emby URL"
|
||||
_set_conf_var "${MY_ID}_EMBY_CONTAINER" "$container" "Emby container name"
|
||||
_set_conf_var "${MY_ID}_EMBY_URL" "http://localhost:${host_port:-8096}" "Emby URL"
|
||||
;;
|
||||
jellyfin)
|
||||
host_port=$(_docker_host_port "$container" "8096")
|
||||
@@ -276,6 +366,18 @@ done
|
||||
[[ -n "$transcode_dir" ]] && \
|
||||
_set_conf_var "${MY_ID}_TRANSCODE_SSD" "${transcode_dir%/}/" "Transcode SSD path"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Authelia container + config path ──────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
authelia_container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "authelia")
|
||||
if [[ -n "$authelia_container" ]]; then
|
||||
_set_conf_var "${MY_ID}_AUTHELIA_CONTAINER" "$authelia_container" "Authelia container"
|
||||
authelia_cfg_dir=$(_docker_volume_host "$authelia_container" "/config")
|
||||
[[ -n "$authelia_cfg_dir" ]] && \
|
||||
_set_conf_var "${MY_ID}_AUTHELIA_CONFIG" "${authelia_cfg_dir}/configuration.yml" "Authelia config path"
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Network interface ─────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
@@ -283,6 +385,37 @@ done
|
||||
nic=$(ip route show default 2>/dev/null | grep -oP '(?<=dev )\S+' | head -1)
|
||||
_set_conf_var "${MY_ID}_SYS_WATCHDOG_NIC" "$nic" "Default NIC"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Storage mode (internal NVMe/SSD vs USB flash boot) ───────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
boot_src=$(findmnt -n -o SOURCE /boot 2>/dev/null)
|
||||
if [[ -n "$boot_src" ]]; then
|
||||
boot_dev=$(lsblk -no PKNAME "$boot_src" 2>/dev/null | head -1)
|
||||
if [[ "$boot_dev" =~ ^nvme ]]; then
|
||||
storage_mode=true
|
||||
else
|
||||
boot_transport=$(cat "/sys/block/${boot_dev}/device/transport" 2>/dev/null)
|
||||
[[ "$boot_transport" == "usb" ]] && storage_mode=false || storage_mode=true
|
||||
fi
|
||||
_set_conf_var "${MY_ID}_STORAGE_MODE_INTERNAL" "$storage_mode" \
|
||||
"Storage mode (NVMe/SSD=true, USB=false)"
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Master conf: host identity + Gitea container ─────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
if [[ -f "$MASTER_CONF" ]]; then
|
||||
echo ""
|
||||
echo " ── Master conf ──"
|
||||
_set_conf_var "$MY_ID" "$LOCAL_SERVER_NAME" "master.conf ${MY_ID} hostname" "$MASTER_CONF"
|
||||
|
||||
gitea_container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "gitea")
|
||||
[[ -n "$gitea_container" ]] && \
|
||||
_set_conf_var "GITEA_CONTAINER" "$gitea_container" "Gitea container" "$MASTER_CONF"
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Summary + push ────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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
@@ -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
@@ -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']);
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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 => {
|
||||
|
||||
@@ -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 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 += `<span title="${k}" style="font-size:9px;padding:2px 5px;border-radius:2px;
|
||||
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';
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Executable
+194
@@ -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"
|
||||
Reference in New Issue
Block a user