From 1a16ca02a920252a445c2690246edb2be1552e1f Mon Sep 17 00:00:00 2001 From: FailedProxy Date: Mon, 20 Apr 2026 18:17:13 -0400 Subject: [PATCH] transcode manager now handles multiple media servers --- Master.conf | 59 ++++++++++-- Tools/emby_database_repair.sh | 20 +++- Transcodes/README-Transcoding.md | 49 +++++++++- Transcodes/transcode_manager.sh | 158 ++++++++++++++++++++----------- common.sh | 38 +++++++- 5 files changed, 255 insertions(+), 69 deletions(-) diff --git a/Master.conf b/Master.conf index 08338e9..3857cf1 100644 --- a/Master.conf +++ b/Master.conf @@ -444,11 +444,11 @@ HOST2_DDNS_CONTAINERS=( # Own DDNS handled separately above — list additional containers here if needed # These stop when this server loses internet — regardless of remote state FAILOVER_HOST1_STOP_ON_NO_NET=( - "Gmer4Lfe.com" # add containers that should stop without internet + # "container-name" # add containers that should stop without internet ) FAILOVER_HOST2_STOP_ON_NO_NET=( - "Gmer4Lfe.us" + # "container-name" ) # ━━━ HOST1 runs these for HOST2 when HOST2 goes down ━━━ @@ -498,8 +498,7 @@ FAILOVER_HOST2_RUNS_FOR_HOST1_IMMEDIATE=( "Dispatcharr" # Live TV — people are watching, cannot wait "Dispatcharr-Basic" # Live TV basic profile "Dispatcharr-Iptv-Users" # Live TV IPTV users - "ErsatzTV-Emby" - # "container-placeholder" # Live TV scheduling and channel management + "ErsatzTV-Emby" # Live TV scheduling and channel management ) # Tier 2 — starts after HOST1_TIER2_DELAY minutes @@ -1044,10 +1043,56 @@ RADARR_PROTECTED_PATTERNS=("*.jpg" "*.jpeg" "*.png" "*.nfo" "*.srt" "*.sub" "*.a # switch to this mode to drain ramdisk sessions gracefully TRANSCODE_MANAGER_MODE="smart" # smart | ramdisk | ssd -# Emby container check — skips threshold checks when Emby is not running -# Prevents unnecessary symlink flips when no transcoding is happening +# ━━━ Transcode Server Array ━━━ +# All media servers that share the ramdisk transcode space. +# transcode_manager.sh reads this array and queries each server's API for active sessions. +# Session display, storage detection, and threshold decisions cover ALL servers combined. +# +# One entry → works exactly as before — single server behavior +# Many entries → aggregates sessions from all servers, one threshold on total ramdisk usage +# +# Each server writes to its own subfolder inside transcoding-temp: +# /mnt/ram-transcode/transcoding-temp/ABC123/ ← Emby session +# /mnt/ram-transcode/transcoding-temp/XYZ789/ ← Jellyfin session +# They never touch each other's files — the ramdisk is shared scratch space. +# +# Docker Extra Parameters — each media server container that uses the ramdisk needs: +# --mount type=bind,source=/mnt/ram-transcode,target=/ext-ram-transcode,bind-propagation=shared +# The target path (/ext-ram-transcode) must match what's configured in each server's +# transcoding settings. Use a different target path per container if needed. +# +# Format: "ContainerName|URL|APIKey|Type" +# ContainerName — exact Docker container name (used for running check) +# URL — API base URL including port +# APIKey — server API key (Emby/Jellyfin token, Plex token etc.) +# Type — emby | jellyfin | plex (controls API endpoint format) +# +# The first entry is the primary server — used for container running check +# (TRANSCODE_CHECK_EMBY still applies to first entry). +# Additional entries are queried if their container is running. +# Entries with placeholder APIKey values are skipped automatically. +# +# ⚠️ Tdarr does NOT belong here — Tdarr encodes full files, not HLS segments. +# Large working files would fill the ramdisk rapidly and cause constant flips. +# Keep Tdarr on SSD. Use tdarr_cleanup.sh for Tdarr orphan management. + +TRANSCODE_SERVERS=( + "Emby|http://localhost:8096|0c27448d93a7431f9ac63569f7655829|emby" + # "Emby-Jayred365|http://HOST2-TAILSCALE-IP:8096|his-api-key|emby" # his Emby temporarily + # "Jellyfin|http://localhost:8097|jellyfin-api-key|jellyfin" # test Jellyfin instance + # "Plex|http://localhost:32400|plex-token|plex" # Plex if needed +) + +# Container running check — still applies — skips threshold checks when no servers active +# Checked against the first entry in TRANSCODE_SERVERS automatically TRANSCODE_CHECK_EMBY=true - TRANSCODE_EMBY_CONTAINER="Emby" # exact Docker container name — case sensitive + +# Per-host Emby container names for database repair +# HOST1 repairs its own personal Emby + the shared production Emby +# HOST2 repairs its own personal Emby +# detect_hosts() selects the correct container at runtime +HOST1_EMBY_REPAIR_CONTAINER="Emby" # shared production Emby lives on HOST1 +HOST2_EMBY_REPAIR_CONTAINER="Emby-Jayred365" # HOST2's personal Emby instance # Daily transcode statistics log — read by weekly_health_digest.sh # Tracks peak usage, flip count, session ratio, files cleaned per day diff --git a/Tools/emby_database_repair.sh b/Tools/emby_database_repair.sh index 5619970..8c59014 100644 --- a/Tools/emby_database_repair.sh +++ b/Tools/emby_database_repair.sh @@ -22,7 +22,10 @@ # authentication.db — API keys and sessions # activity.db — activity log # -# All configuration in Master.conf — uses TRANSCODE_EMBY_CONTAINER and EMBY_URL. +# HOST1 repairs the shared production Emby (HOST1_EMBY_REPAIR_CONTAINER). +# HOST2 repairs its own personal Emby (HOST2_EMBY_REPAIR_CONTAINER). +# detect_hosts() selects the correct container at runtime. +# Config path is detected from Docker inspect — no hardcoded paths needed. # Supports --dry-run to show what would be checked without stopping Emby. # ----------------------------------------------------------------------------------------------- @@ -33,10 +36,6 @@ source "$SCRIPT_DIR/../common.sh" parse_args "$@" -# Emby config path — inside the container it's /config, map to host path -# Detected from Docker inspect at runtime -EMBY_CONTAINER="$TRANSCODE_EMBY_CONTAINER" - # ----------------------------------------------------------------------------------------------- # ━━━ $ICON_GEAR Setup ━━━ # ----------------------------------------------------------------------------------------------- @@ -52,6 +51,17 @@ success "Running as root" acquire_lock +# Select correct Emby container based on which server is running this script +detect_hosts + +if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then + EMBY_CONTAINER="$HOST1_EMBY_REPAIR_CONTAINER" +else + EMBY_CONTAINER="$HOST2_EMBY_REPAIR_CONTAINER" +fi + +info "Emby container: $LOCAL_SERVER_NAME → $EMBY_CONTAINER" + if ! command -v sqlite3 >/dev/null 2>&1; then error "sqlite3 not found — install sqlite package" exit 1 diff --git a/Transcodes/README-Transcoding.md b/Transcodes/README-Transcoding.md index 3186f53..283e884 100644 --- a/Transcodes/README-Transcoding.md +++ b/Transcodes/README-Transcoding.md @@ -223,13 +223,58 @@ TRANSCODE_CHMOD="755" TRANSCODE_MANAGER_MODE="smart" # smart | ramdisk | ssd -TRANSCODE_CHECK_EMBY=true -TRANSCODE_EMBY_CONTAINER="Emby" +TRANSCODE_CHECK_EMBY=true # skips threshold checks when no servers active TRANSCODE_DAILY_LOG="/boot/config/transcode_daily.db" TRANSCODE_LOG_RETENTION=90 ``` +### Multi-Server Configuration — `TRANSCODE_SERVERS` + +The transcode manager supports any number of media servers sharing the same ramdisk scratch space. One server or ten — the same script handles it. The threshold decision is based on total ramdisk usage across all servers combined. + +```bash +TRANSCODE_SERVERS=( + "Emby|http://localhost:8096|your-api-key|emby" + # "Emby-Jayred365|http://HOST2-IP:8096|his-api-key|emby" # temporarily cover his Emby + # "Jellyfin|http://localhost:8097|jellyfin-api-key|jellyfin" # test Jellyfin instance + # "Plex|http://localhost:32400|plex-token|plex" # Plex if needed +) +``` + +**Format:** `"ContainerName|URL|APIKey|Type"` + +| Field | Description | +|-------|-------------| +| `ContainerName` | Exact Docker container name — used for running check | +| `URL` | API base URL including port | +| `APIKey` | Server API key or token | +| `Type` | `emby` \| `jellyfin` \| `plex` — controls API endpoint format | + +**How it works with multiple servers:** + +Each media server writes its transcode sessions to its own subfolder inside `transcoding-temp`: + +``` +/mnt/ram-transcode/transcoding-temp/ + E0D8DC/ ← Emby session (Live TV segment files) + abc123/ ← Jellyfin session + xyz789/ ← his Emby session +``` + +They never touch each other's files. The ramdisk is shared scratch space — each server is unaware of the others. The manager aggregates session counts from all servers and makes one threshold decision based on total ramdisk usage. + +**Each server container needs the shared propagation mount in Extra Parameters:** +``` +--mount type=bind,source=/mnt/ram-transcode,target=/ext-ram-transcode,bind-propagation=shared +``` + +Set the transcode path in each server's settings to `/ext-ram-transcode` (or whatever target path you use). + +**Entries with placeholder API key values are skipped automatically** — comment out entries that aren't in use rather than removing them. The placeholders make it clear what's available to enable. + +**⚠️ Tdarr does NOT belong in this array.** Tdarr encodes full files — large working files would fill the ramdisk rapidly and cause constant flips. Keep Tdarr on SSD. A dedicated `tdarr_cleanup.sh` handles Tdarr orphan management separately. + ### Sizing the Ramdisk `tmpfs` only uses RAM actually needed — `RAMDISK_SIZE` is a ceiling, not a reservation. diff --git a/Transcodes/transcode_manager.sh b/Transcodes/transcode_manager.sh index 1f60c98..fa4f17d 100644 --- a/Transcodes/transcode_manager.sh +++ b/Transcodes/transcode_manager.sh @@ -49,15 +49,11 @@ success "Running as root" acquire_lock "wait" -# Select correct Emby instance for session display -detect_hosts - -if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then - EMBY_URL="${HOST1_EMBY_URL}" - EMBY_API_KEY="${HOST1_EMBY_API_KEY}" -else - EMBY_URL="${HOST2_EMBY_URL}" - EMBY_API_KEY="${HOST2_EMBY_API_KEY}" +# Primary server for container running check — first entry in TRANSCODE_SERVERS +# Full session display loops over all entries in the array +if [[ "${#TRANSCODE_SERVERS[@]}" -gt 0 ]]; then + IFS='|' read -r PRIMARY_CONTAINER _ _ _ <<< "${TRANSCODE_SERVERS[0]}" + TRANSCODE_EMBY_CONTAINER="${PRIMARY_CONTAINER:-Emby}" fi case "$TRANSCODE_MANAGER_MODE" in @@ -335,63 +331,93 @@ FLIP_COUNT=$(get_flip_count) echo "" echo "━━━ $ICON_EMBY Active Emby Sessions ━━━" +# ----------------------------------------------------------------------------------------------- +# ━━━ $ICON_EMBY Active Transcode Sessions ━━━ +# Queries all configured servers in TRANSCODE_SERVERS array +# Aggregates sessions, counts, and storage state across all servers +# ----------------------------------------------------------------------------------------------- +echo "" +echo "━━━ $ICON_EMBY Active Transcode Sessions ━━━" + TOTAL_SESSIONS=0 LIVE_TV=0 TRANSCODING=0 DIRECT=0 +ANY_SERVER_RUNNING=false -if [[ -z "$EMBY_API_KEY" ]]; then - info "EMBY_API_KEY not set in Master.conf — skipping session display" -elif [[ "$EMBY_RUNNING" == false ]]; then - info "Emby not running — no sessions" -else - SESSION_DATA=$(emby_api "Sessions" 2>/dev/null) +for server_entry in "${TRANSCODE_SERVERS[@]}"; do + # Parse entry — ContainerName|URL|APIKey|Type + IFS='|' read -r SRV_CONTAINER SRV_URL SRV_KEY SRV_TYPE <<< "$server_entry" + + # Skip placeholder entries + if [[ "$SRV_KEY" == *"api-key"* ]] || [[ "$SRV_KEY" == *"token"* && ${#SRV_KEY} -lt 20 ]]; then + log "Skipping $SRV_CONTAINER — placeholder API key" + continue + fi + + # Check if container is running + if ! docker inspect "$SRV_CONTAINER" --format '{{.State.Running}}' 2>/dev/null | grep -q "true"; then + info "$ICON_CONTAINERS $SRV_CONTAINER — not running, skipping" + continue + fi + + ANY_SERVER_RUNNING=true + + # Check API reachability + if ! check_api "$SRV_URL" "$SRV_CONTAINER" 5; then + warn "$SRV_CONTAINER — API unreachable, skipping (container up but API not responding)" + continue + fi + + # Query sessions based on server type + case "$SRV_TYPE" in + emby|jellyfin) + SESSION_DATA=$(curl -sf --max-time 10 \ + -H "X-Emby-Token: $SRV_KEY" \ + "${SRV_URL}/Sessions" 2>/dev/null) + ;; + plex) + SESSION_DATA=$(curl -sf --max-time 10 \ + -H "X-Plex-Token: $SRV_KEY" \ + "${SRV_URL}/status/sessions" 2>/dev/null) + ;; + *) + warn "Unknown server type '$SRV_TYPE' for $SRV_CONTAINER — skipping" + continue + ;; + esac if [[ -z "$SESSION_DATA" ]] || ! command -v jq >/dev/null 2>&1; then - warn "Could not retrieve session data — check EMBY_URL and EMBY_API_KEY" - else - # Count sessions - TOTAL_SESSIONS=$(echo "$SESSION_DATA" | \ + warn "Could not retrieve session data from $SRV_CONTAINER" + continue + fi + + # Count sessions (Emby/Jellyfin format) + if [[ "$SRV_TYPE" == "emby" || "$SRV_TYPE" == "jellyfin" ]]; then + SRV_TOTAL=$(echo "$SESSION_DATA" | \ jq '[.[] | select(.NowPlayingItem != null)] | length' 2>/dev/null || echo 0) - LIVE_TV=$(echo "$SESSION_DATA" | \ + SRV_LIVE=$(echo "$SESSION_DATA" | \ jq '[.[] | select(.NowPlayingItem != null) | select(.NowPlayingItem.Type == "LiveTv" or .NowPlayingItem.Type == "TvChannel")] | length' \ 2>/dev/null || echo 0) - TRANSCODING=$(echo "$SESSION_DATA" | \ + SRV_TRANSCODE=$(echo "$SESSION_DATA" | \ jq '[.[] | select(.NowPlayingItem != null) | select(.PlayState.PlayMethod == "Transcode")] | length' \ 2>/dev/null || echo 0) - DIRECT=$(echo "$SESSION_DATA" | \ + SRV_DIRECT=$(echo "$SESSION_DATA" | \ jq '[.[] | select(.NowPlayingItem != null) | select(.PlayState.PlayMethod != "Transcode")] | length' \ 2>/dev/null || echo 0) - echo " $ICON_EMBY Total: $TOTAL_SESSIONS | $ICON_RAM Live TV: $LIVE_TV | $ICON_SYNC Transcoding: $TRANSCODING | $ICON_DONE Direct: $DIRECT" - echo "" + TOTAL_SESSIONS=$(( TOTAL_SESSIONS + SRV_TOTAL )) + LIVE_TV=$(( LIVE_TV + SRV_LIVE )) + TRANSCODING=$(( TRANSCODING + SRV_TRANSCODE )) + DIRECT=$(( DIRECT + SRV_DIRECT )) - if [[ "$TOTAL_SESSIONS" -gt 0 ]]; then + # Display server header if more than one server configured and running + if [[ "${#TRANSCODE_SERVERS[@]}" -gt 1 ]]; then + echo " $ICON_EMBY $SRV_CONTAINER — $SRV_TOTAL streams" + fi - # Storage state — count session folders on each location - # Emby folder names don't match API session IDs — per-session location not possible - # Split state shows when symlink has flipped mid-session - TRANSCODE_TEMP="transcoding-temp" - RAM_SESSION_COUNT=$(find "$RAMDISK_PATH/$TRANSCODE_TEMP" \ - -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) - SSD_SESSION_COUNT=$(find "$TRANSCODE_SSD/$TRANSCODE_TEMP" \ - -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) - - if [[ "$RAM_SESSION_COUNT" -gt 0 && "$SSD_SESSION_COUNT" -gt 0 ]]; then - warn "$ICON_WARN Split state — $RAM_SESSION_COUNT folder(s) on ramdisk / $SSD_SESSION_COUNT on SSD" - warn "Older sessions remain on original location until they end naturally" - echo " $ICON_LINK Storage: $ICON_RAM ramdisk ($RAM_SESSION_COUNT) + $ICON_DISK SSD ($SSD_SESSION_COUNT)" - elif [[ "$RAM_SESSION_COUNT" -gt 0 ]]; then - echo " $ICON_LINK Storage: $ICON_RAM ramdisk" - elif [[ "$SSD_SESSION_COUNT" -gt 0 ]]; then - echo " $ICON_LINK Storage: $ICON_DISK SSD" - else - echo " $ICON_LINK Storage: $ICON_INFO no transcode folders found" - fi - - echo "" - - # List each active session + # List each active session + if [[ "$SRV_TOTAL" -gt 0 ]]; then while IFS= read -r session; do USER=$(echo "$session" | jq -r '.UserName // "Unknown"' 2>/dev/null) TITLE=$(echo "$session" | jq -r '.NowPlayingItem.Name // "Unknown"' 2>/dev/null) @@ -402,13 +428,39 @@ else METHOD_LABEL=$(get_play_method_label "$PLAY_METHOD") echo " $ICON_EMBY $(printf '%-12s' "$USER") — $(printf '%-30s' "$TITLE") — $(printf '%-10s' "$MEDIA_LABEL") — $METHOD_LABEL" - done < <(echo "$SESSION_DATA" | \ jq -c '.[] | select(.NowPlayingItem != null)' 2>/dev/null) - else - info "No active streams" fi fi + +done + +# Totals header — shown after all servers +echo "" +echo " $ICON_EMBY Total: $TOTAL_SESSIONS | $ICON_RAM Live TV: $LIVE_TV | $ICON_SYNC Transcoding: $TRANSCODING | $ICON_DONE Direct: $DIRECT" + +# Storage state — based on total ramdisk/SSD session folders +if [[ "$TOTAL_SESSIONS" -gt 0 ]]; then + TRANSCODE_TEMP="transcoding-temp" + RAM_SESSION_COUNT=$(find "$RAMDISK_PATH/$TRANSCODE_TEMP" \ + -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) + SSD_SESSION_COUNT=$(find "$TRANSCODE_SSD/$TRANSCODE_TEMP" \ + -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) + + if [[ "$RAM_SESSION_COUNT" -gt 0 && "$SSD_SESSION_COUNT" -gt 0 ]]; then + warn "$ICON_WARN Split state — $RAM_SESSION_COUNT folder(s) on ramdisk / $SSD_SESSION_COUNT on SSD" + warn "Older sessions remain on original location until they end naturally" + echo " $ICON_LINK Storage: $ICON_RAM ramdisk ($RAM_SESSION_COUNT) + $ICON_DISK SSD ($SSD_SESSION_COUNT)" + elif [[ "$RAM_SESSION_COUNT" -gt 0 ]]; then + echo " $ICON_LINK Storage: $ICON_RAM ramdisk" + elif [[ "$SSD_SESSION_COUNT" -gt 0 ]]; then + echo " $ICON_LINK Storage: $ICON_DISK SSD" + fi +fi + +if [[ "$ANY_SERVER_RUNNING" == false && "$TRANSCODE_CHECK_EMBY" == true ]]; then + info "No configured media servers are running — skipping threshold checks" + EMBY_RUNNING=false fi # ----------------------------------------------------------------------------------------------- @@ -455,7 +507,7 @@ case "$TRANSCODE_MANAGER_MODE" in info "$ICON_GEAR Mode: SMART — auto threshold management" if [[ "$EMBY_RUNNING" == false ]]; then - info "Emby not running — skipping threshold checks" + info "No media servers running — skipping threshold checks" elif [[ "$RAMDISK_HEALTHY" == false ]]; then info "Ramdisk unavailable — staying on SSD until ramdisk recovers" diff --git a/common.sh b/common.sh index 08ccebc..5f6cc08 100644 --- a/common.sh +++ b/common.sh @@ -21,12 +21,45 @@ # v1.5 — Full icon set expanded — each operation and state has its own distinct icon # All function output updated to use correct icon per context # Icons grouped and commented by category for clarity +# v1.6 — ICON_CONTAINERS added — 📦 anchors all container sections for visual consistency +# ICON_NOT_RUNNING changed to ⭕ — distinct from ICON_STOPPED 🔴 +# Section dividers updated from --- to ━━━ for cleaner log readability +# Summary passed/failed lines use ICON_SUCCESS and ICON_ERROR consistently +# v1.7 — ICON_MOVER added for mover operations +# ICON_CONTAINERS replaces ICON_DOCKER for docker/container operations +# validate_int added — reusable integer validation for any script +# v1.8 — ICON_PHP added for PHP-FPM operations +# v1.9 — ICON_REBOOT added for server reboot operations +# v2.0 — ICON_PLUGIN added for User Scripts plugin operations +# v2.1 — ICON_ZFS and ICON_MEM added for ZFS and memory diagnostics +# Diagnostics icon group added to icon block +# v2.2 — ICON_WATCHDOG added for Docker watchdog monitoring operations +# v2.3 — ICON_NOTIFY added for notification operations +# notify() added — shared notification function supporting unRAID native and Discord +# NOTIFY_UNRAID and DISCORD_WEBHOOK configured in Master.conf +# v2.4 — ICON_CLEAN, ICON_TRASH added for media cleaner operations +# ICON_PERMS, ICON_UNLOCKED added for media permissions operations +# ICON_REBOOT_SMART added for smart conditional reboot +# v2.5 — ICON_RAM added for ramdisk operations +# ICON_LINK added for symlink state and management +# Transcode scripts group added to ecosystem +# v2.6 — ICON_FAILOVER added for failover operations +# check_local_array added — verifies local /mnt/user is mounted and healthy +# check_remote_array added — verifies remote /mnt/user is mounted and healthy +# check_remote_docker added — verifies remote Docker daemon is responding +# ping_remote added — non-fatal ping returning status for failover use +# ping_internet added — non-fatal external ping for failover use +# v2.7 — ICON_WEBGUI added for WebGUI watchdog operations +# ICON_DOCKER_NET added for Docker network connect operations +# v2.8 — ICON_CERT added for SSL certificate monitoring operations +# v2.9 — ICON_MONITOR added for monitoring section headers # v3.0 — Script locking system added — prevents concurrent execution conflicts # acquire_lock() — create PID lock file, register EXIT trap # release_lock() — remove lock file on exit # acquire_rsync_lock() — per-profile lock + global concurrent limit # release_rsync_lock() — decrement global counter, remove profile lock # check_api() — pre-flight API reachability check +# ICON_LOCK added — 🔏 script instance lock acquired/released # ICON_SMART added for drive SMART health operations # ICON_BANDWIDTH added for bandwidth tracking operations # ICON_DIGEST added for health digest operations @@ -89,6 +122,7 @@ ICON_CLEAN="🧹" # media cleaner operations ICON_TRASH="🗑️" # files being deleted ICON_PERMS="🔐" # permissions operation / section header ICON_UNLOCKED="🔓" # permissions successfully applied to a share +ICON_LOCK="🔏" # script instance lock — acquired/released # Transcode Operations ICON_RAM="💨" # ramdisk operations — fast ephemeral storage @@ -660,7 +694,7 @@ acquire_lock() { # Register EXIT trap to always release lock trap "_release_on_exit '$lockfile'" EXIT - log "Lock acquired: $script_name (PID $$)" + log "$ICON_LOCK Lock acquired: $script_name (PID $$)" } # ----------------------------------------------------------------------------------------------- @@ -719,7 +753,7 @@ acquire_rsync_lock() { # Register EXIT trap trap "_release_rsync_on_exit '$profile_lock'" EXIT - log "rsync lock acquired: profile '$profile' (PID $$, active: $(( current_count + 1 ))/$RSYNC_MAX_CONCURRENT)" + log "$ICON_LOCK rsync lock acquired: profile '$profile' (PID $$, active: $(( current_count + 1 ))/$RSYNC_MAX_CONCURRENT)" } # Internal — release rsync lock on exit