#!/bin/bash # ----------------------------------------------------------------------------------------------- # --------------------------------- Transcode Manager ------------------------------------------ # ----------------------------------------------------------------------------------------------- # Manages Emby transcode storage using filesystem symlink indirection. # ffmpeg resolves the symlink ONCE at session start — existing sessions are never affected. # Only new sessions care about where the symlink currently points. # # Three operating modes — set TRANSCODE_MANAGER_MODE in Master.conf: # smart — auto-flips between ramdisk and SSD based on usage thresholds (default) # ramdisk — always uses ramdisk, never flips to SSD regardless of usage # ssd — always uses SSD, never uses ramdisk # # Safety checks on every run regardless of mode: # Ramdisk disappeared → auto-flip to SSD, notify warning # SSD path missing → disable SSD fallback, notify warning # Symlink broken → auto-recreate, notify # Symlink missing → auto-recreate, notify # Permissions drift → fix silently # Emby not running → skip threshold checks, verify symlink only # # Session display shows active Emby streams with storage state. # Split state detected when sessions exist on both ramdisk and SSD simultaneously — # this happens naturally when symlink flips mid-session. # # All configuration in Master.conf under Transcode Manager section. # Supports --dry-run and --status. # ----------------------------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../Master.conf" source "$SCRIPT_DIR/../common.sh" parse_args "$@" # ----------------------------------------------------------------------------------------------- # ━━━ $ICON_GEAR Setup ━━━ # ----------------------------------------------------------------------------------------------- echo "" echo "━━━ $ICON_GEAR Setup ━━━" if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi success "Running as root" case "$TRANSCODE_MANAGER_MODE" in smart|ramdisk|ssd) info "$ICON_GEAR Mode: $TRANSCODE_MANAGER_MODE" ;; *) warn "Unknown TRANSCODE_MANAGER_MODE: $TRANSCODE_MANAGER_MODE — defaulting to smart" TRANSCODE_MANAGER_MODE="smart" ;; esac [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made" # ----------------------------------------------------------------------------------------------- # ━━━ $ICON_SUMMARY Status ━━━ # ----------------------------------------------------------------------------------------------- if [[ "$SHOW_STATUS" == true ]]; then echo "" echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━" echo "$ICON_GEAR Mode: $TRANSCODE_MANAGER_MODE" echo "$ICON_RAM Ramdisk: $RAMDISK_PATH ($RAMDISK_SIZE)" echo "$ICON_LINK Symlink: $TRANSCODE_LINK" echo "$ICON_DISK SSD: $TRANSCODE_SSD" echo "$ICON_RAM Warn at: ${RAMDISK_WARN_GB}GB" echo "$ICON_RAM Low at: ${RAMDISK_LOW_GB}GB" echo "$ICON_DISK SSD min free: ${RAMDISK_SSD_MIN_GB}GB" echo "$ICON_RAM Flip warn: $TRANSCODE_FLIP_WARN per hour" echo "$ICON_GEAR Check Emby: $TRANSCODE_CHECK_EMBY ($TRANSCODE_EMBY_CONTAINER)" echo "$ICON_GEAR Dry Run: $DRY_RUN" CURRENT_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "missing") echo "$ICON_LINK Symlink now: $TRANSCODE_LINK → $CURRENT_TARGET" if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then USED_KB=$(df "$RAMDISK_PATH" --output=used | tail -1 | tr -d ' ') USED_GB=$(awk "BEGIN {printf \"%.2f\", $USED_KB / 1048576}") echo "$ICON_RAM Ramdisk now: ${USED_GB}GB used" else echo "$ICON_RAM Ramdisk: NOT MOUNTED" fi echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ----------------------------------------------------------------------------------------------- # HELPERS # ----------------------------------------------------------------------------------------------- flip_symlink() { local target="$1" reason="$2" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would flip symlink to: $target ($reason)" return 0 fi ln -sfn "$target" "$TRANSCODE_LINK" success "$ICON_LINK Symlink flipped to: $target ($reason)" } fix_permissions() { local path="$1" [[ ! -d "$path" ]] && return chown -R "$TRANSCODE_OWNER" "$path" 2>/dev/null chmod -R "$TRANSCODE_CHMOD" "$path" 2>/dev/null log "Permissions fixed on $path" } get_ramdisk_used_gb() { local used_kb used_kb=$(df "$RAMDISK_PATH" --output=used 2>/dev/null | tail -1 | tr -d ' ') awk "BEGIN {printf \"%.2f\", ${used_kb:-0} / 1048576}" } get_ramdisk_avail_gb() { local avail_kb avail_kb=$(df "$RAMDISK_PATH" --output=avail 2>/dev/null | tail -1 | tr -d ' ') awk "BEGIN {printf \"%.2f\", ${avail_kb:-0} / 1048576}" } get_ssd_free_gb() { local free_kb free_kb=$(df "$TRANSCODE_SSD" --output=avail 2>/dev/null | tail -1 | tr -d ' ') awk "BEGIN {printf \"%.2f\", ${free_kb:-0} / 1048576}" } get_flip_count() { local state_file="/tmp/transcode_flip_state.db" local current_hour current_hour=$(date '+%Y-%m-%d-%H') if [[ ! -f "$state_file" ]]; then echo "0"; return; fi local stored_hour stored_count stored_hour=$(awk -F'|' 'NR==1{print $1}' "$state_file" 2>/dev/null) stored_count=$(awk -F'|' 'NR==1{print $2}' "$state_file" 2>/dev/null) [[ "$stored_hour" == "$current_hour" ]] && echo "${stored_count:-0}" || echo "0" } increment_flip_count() { local state_file="/tmp/transcode_flip_state.db" local current_hour current_hour=$(date '+%Y-%m-%d-%H') local current_count current_count=$(get_flip_count) current_count=$((current_count + 1)) echo "${current_hour}|${current_count}" > "$state_file" echo "$current_count" } emby_api() { local endpoint="$1" [[ -z "$EMBY_API_KEY" ]] && return 1 curl -sf \ --max-time 10 \ -H "X-Emby-Token: $EMBY_API_KEY" \ "${EMBY_URL}/${endpoint}" 2>/dev/null } get_media_type_label() { local type="$1" case "$type" in LiveTv) echo "Live TV" ;; TvChannel) echo "Live TV" ;; Episode) echo "TV Show" ;; Movie) echo "Movie" ;; Audio) echo "Music" ;; MusicVideo) echo "Music Video" ;; *) echo "$type" ;; esac } get_play_method_label() { local method="$1" case "$method" in Transcode) echo "Transcode" ;; DirectStream) echo "Direct Stream" ;; DirectPlay) echo "Direct Play" ;; *) echo "$method" ;; esac } # ----------------------------------------------------------------------------------------------- # ━━━ $ICON_RAM Transcode Manager ━━━ # ----------------------------------------------------------------------------------------------- echo "" echo "━━━ $ICON_RAM Transcode Manager — $(date '+%Y-%m-%d %H:%M:%S') ━━━" echo "$ICON_GEAR Mode: $TRANSCODE_MANAGER_MODE" echo "" START=$(date +%s) RAMDISK_HEALTHY=true SSD_HEALTHY=true EMBY_RUNNING=true # ----------------------------------------------------------------------------------------------- # CHECK 1 — Emby running # ----------------------------------------------------------------------------------------------- if [[ "$TRANSCODE_CHECK_EMBY" == true ]]; then info "$ICON_CONTAINERS Checking $TRANSCODE_EMBY_CONTAINER..." if ! docker inspect "$TRANSCODE_EMBY_CONTAINER" \ --format '{{.State.Running}}' 2>/dev/null | grep -q "true"; then warn "$TRANSCODE_EMBY_CONTAINER is not running — skipping threshold checks" EMBY_RUNNING=false else success "$TRANSCODE_EMBY_CONTAINER is running" fi fi # ----------------------------------------------------------------------------------------------- # CHECK 2 — Symlink integrity # ----------------------------------------------------------------------------------------------- echo "" info "$ICON_LINK Checking symlink integrity..." CURRENT_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null) if [[ -z "$CURRENT_TARGET" ]]; then warn "$ICON_LINK Symlink missing — recreating pointing to ramdisk" if [[ "$DRY_RUN" == false ]]; then ln -sfn "$RAMDISK_PATH" "$TRANSCODE_LINK" CURRENT_TARGET="$RAMDISK_PATH" notify "Transcode symlink was missing on $(hostname) — recreated" "Transcode Manager" "warning" fi elif [[ ! -e "$CURRENT_TARGET" ]]; then warn "$ICON_LINK Symlink target missing: $CURRENT_TARGET — resetting to ramdisk" if [[ "$DRY_RUN" == false ]]; then ln -sfn "$RAMDISK_PATH" "$TRANSCODE_LINK" CURRENT_TARGET="$RAMDISK_PATH" notify "Transcode symlink target was missing on $(hostname) — reset to ramdisk" "Transcode Manager" "warning" fi else success "$ICON_LINK Symlink valid: $TRANSCODE_LINK → $CURRENT_TARGET" fi # ----------------------------------------------------------------------------------------------- # CHECK 3 — Ramdisk health # ----------------------------------------------------------------------------------------------- echo "" info "$ICON_RAM Checking ramdisk..." if ! mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then error "$ICON_RAM Ramdisk not mounted at $RAMDISK_PATH" RAMDISK_HEALTHY=false if [[ "$DRY_RUN" == false ]]; then warn "Flipping symlink to SSD — ramdisk unavailable" flip_symlink "$TRANSCODE_SSD" "ramdisk disappeared" CURRENT_TARGET="$TRANSCODE_SSD" notify "Ramdisk disappeared on $(hostname) — transcodes falling back to SSD. Run ramdisk_setup.sh to restore." "Transcode Manager" "warning" fi else RAMDISK_SIZE_ACTUAL=$(df "$RAMDISK_PATH" --output=size -h | tail -1 | tr -d ' ') success "$ICON_RAM Ramdisk mounted — size: $RAMDISK_SIZE_ACTUAL" fix_permissions "$RAMDISK_PATH" fi # ----------------------------------------------------------------------------------------------- # CHECK 4 — SSD health # ----------------------------------------------------------------------------------------------- echo "" info "$ICON_DISK Checking SSD fallback..." if [[ ! -d "$TRANSCODE_SSD" ]]; then warn "$ICON_DISK SSD fallback path missing: $TRANSCODE_SSD" SSD_HEALTHY=false if [[ "$TRANSCODE_MANAGER_MODE" == "ssd" ]]; then error "Mode is 'ssd' but SSD path is missing — cannot continue" notify "Transcode SSD path missing on $(hostname) — mode is 'ssd', manual intervention needed" "Transcode Manager" "warning" exit 1 else warn "SSD fallback disabled — will stay on ramdisk" fi else SSD_FREE_GB=$(get_ssd_free_gb) success "$ICON_DISK SSD available — ${SSD_FREE_GB}GB free" fix_permissions "$TRANSCODE_SSD" fi # ----------------------------------------------------------------------------------------------- # USAGE STATS # ----------------------------------------------------------------------------------------------- echo "" RAMDISK_USED_GB="0.00" RAMDISK_AVAIL_GB="0.00" if [[ "$RAMDISK_HEALTHY" == true ]]; then RAMDISK_USED_GB=$(get_ramdisk_used_gb) RAMDISK_AVAIL_GB=$(get_ramdisk_avail_gb) RAMDISK_FILES=$(find "$RAMDISK_PATH" -type f 2>/dev/null | wc -l) RAMDISK_SIZE_H=$(du -sh "$RAMDISK_PATH" 2>/dev/null | cut -f1) fi SSD_FILES=$(find "$TRANSCODE_SSD" -type f 2>/dev/null | wc -l) SSD_SIZE_H=$(du -sh "$TRANSCODE_SSD" 2>/dev/null | cut -f1) info "$ICON_RAM Ramdisk usage: ${RAMDISK_USED_GB}GB used / ${RAMDISK_AVAIL_GB}GB available" info "$ICON_RAM Ramdisk files: ${RAMDISK_FILES:-0} ($RAMDISK_SIZE_H)" info "$ICON_DISK SSD files: $SSD_FILES ($SSD_SIZE_H)" FLIP_COUNT=$(get_flip_count) # ----------------------------------------------------------------------------------------------- # ━━━ $ICON_EMBY Active Emby Sessions ━━━ # ----------------------------------------------------------------------------------------------- echo "" echo "━━━ $ICON_EMBY Active Emby Sessions ━━━" TOTAL_SESSIONS=0 LIVE_TV=0 TRANSCODING=0 DIRECT=0 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) 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" | \ jq '[.[] | select(.NowPlayingItem != null)] | length' 2>/dev/null || echo 0) LIVE_TV=$(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" | \ jq '[.[] | select(.NowPlayingItem != null) | select(.PlayState.PlayMethod == "Transcode")] | length' \ 2>/dev/null || echo 0) 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 "" if [[ "$TOTAL_SESSIONS" -gt 0 ]]; then # 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 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) MEDIA_TYPE=$(echo "$session" | jq -r '.NowPlayingItem.Type // "Unknown"' 2>/dev/null) PLAY_METHOD=$(echo "$session" | jq -r '.PlayState.PlayMethod // "Unknown"' 2>/dev/null) MEDIA_LABEL=$(get_media_type_label "$MEDIA_TYPE") 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 fi # ----------------------------------------------------------------------------------------------- # MODE LOGIC # ----------------------------------------------------------------------------------------------- echo "" case "$TRANSCODE_MANAGER_MODE" in ramdisk) info "$ICON_RAM Mode: RAMDISK — forcing symlink to ramdisk" if [[ "$RAMDISK_HEALTHY" == false ]]; then error "Ramdisk mode selected but ramdisk is not available" notify "Transcode ramdisk mode failed on $(hostname) — ramdisk not mounted" "Transcode Manager" "warning" else if [[ "$CURRENT_TARGET" != "$RAMDISK_PATH" ]]; then flip_symlink "$RAMDISK_PATH" "ramdisk mode" increment_flip_count > /dev/null else log "Symlink already points to ramdisk — no change" fi if (( $(awk "BEGIN {print ($RAMDISK_USED_GB >= $RAMDISK_WARN_GB) ? 1 : 0}") )); then warn "$ICON_RAM Ramdisk usage ${RAMDISK_USED_GB}GB above threshold ${RAMDISK_WARN_GB}GB — consider switching to smart mode" notify "Ramdisk usage high on $(hostname) — ${RAMDISK_USED_GB}GB used in ramdisk-only mode" "Transcode Manager" "warning" fi fi ;; ssd) info "$ICON_DISK Mode: SSD — forcing symlink to SSD" if [[ "$SSD_HEALTHY" == false ]]; then error "SSD mode selected but SSD path is not available" else if [[ "$CURRENT_TARGET" != "$TRANSCODE_SSD" ]]; then flip_symlink "$TRANSCODE_SSD" "ssd mode" increment_flip_count > /dev/null else log "Symlink already points to SSD — no change" fi fi ;; smart) info "$ICON_GEAR Mode: SMART — auto threshold management" if [[ "$EMBY_RUNNING" == false ]]; then info "Emby not running — skipping threshold checks" elif [[ "$RAMDISK_HEALTHY" == false ]]; then info "Ramdisk unavailable — staying on SSD until ramdisk recovers" elif [[ "$CURRENT_TARGET" == "$RAMDISK_PATH" ]]; then if (( $(awk "BEGIN {print ($RAMDISK_USED_GB >= $RAMDISK_WARN_GB) ? 1 : 0}") )); then if [[ "$SSD_HEALTHY" == false ]]; then error "Ramdisk above threshold but SSD unavailable — cannot flip" notify "Transcode ramdisk full on $(hostname) and SSD unavailable — intervention needed" "Transcode Manager" "warning" else SSD_FREE_GB=$(get_ssd_free_gb) if (( $(awk "BEGIN {print ($SSD_FREE_GB < $RAMDISK_SSD_MIN_GB) ? 1 : 0}") )); then warn "$ICON_DISK SSD only ${SSD_FREE_GB}GB free — below ${RAMDISK_SSD_MIN_GB}GB minimum, not flipping" else warn "$ICON_RAM Ramdisk ${RAMDISK_USED_GB}GB — above ${RAMDISK_WARN_GB}GB, flipping to SSD" flip_symlink "$TRANSCODE_SSD" "threshold exceeded" CURRENT_TARGET="$TRANSCODE_SSD" NEW_COUNT=$(increment_flip_count) FLIP_COUNT=$NEW_COUNT if [[ "$NEW_COUNT" -ge "$TRANSCODE_FLIP_WARN" ]]; then notify "Transcode flipped to SSD on $(hostname) — ${NEW_COUNT} flips this hour. Consider increasing RAMDISK_SIZE." "Transcode Manager" "warning" fi fi fi else log "Symlink already points to ramdisk — no change" fi elif [[ "$CURRENT_TARGET" == "$TRANSCODE_SSD" ]]; then if (( $(awk "BEGIN {print ($RAMDISK_USED_GB <= $RAMDISK_LOW_GB) ? 1 : 0}") )); then info "$ICON_RAM Ramdisk ${RAMDISK_USED_GB}GB — below ${RAMDISK_LOW_GB}GB, flipping back to ramdisk" flip_symlink "$RAMDISK_PATH" "usage recovered" CURRENT_TARGET="$RAMDISK_PATH" NEW_COUNT=$(increment_flip_count) FLIP_COUNT=$NEW_COUNT else log "On SSD — ramdisk ${RAMDISK_USED_GB}GB still above low threshold ${RAMDISK_LOW_GB}GB" fi fi ;; esac END=$(date +%s) CURRENT_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "unknown") # ----------------------------------------------------------------------------------------------- # ━━━ $ICON_SUMMARY Summary ━━━ # ----------------------------------------------------------------------------------------------- echo "" echo "━━━━━ $ICON_SUMMARY TRANSCODE MANAGER SUMMARY ━━━━━" echo "$ICON_GEAR Mode: $TRANSCODE_MANAGER_MODE" echo "$ICON_RAM Ramdisk: ${RAMDISK_USED_GB}GB used / ${RAMDISK_AVAIL_GB}GB available" echo "$ICON_LINK Symlink: $TRANSCODE_LINK → $CURRENT_TARGET" echo "$ICON_LINK Flips: $FLIP_COUNT this hour (warn at $TRANSCODE_FLIP_WARN)" echo "$ICON_EMBY Streams: ${TOTAL_SESSIONS} total | Live TV: ${LIVE_TV} | Transcoding: ${TRANSCODING} | Direct: ${DIRECT}" echo "$ICON_TIME Duration: $(format_duration $((END - START)))" echo "$ICON_DONE Status: $ICON_SUCCESS DONE" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"