#!/bin/bash # Verify every host-side runtime dependency is installed and minimally functional; run at first # setup and after host upgrades. Usage: preflight.sh [--verbose | --strict] (--verbose prints # version strings; --strict treats optional misses as failure). # Exit: 0 all required present and probes OK, 1 required missing/broken, 2 only optional missing. set -uo pipefail verbose=0 strict=0 for arg in "$@"; do case "$arg" in --verbose) verbose=1 ;; --strict) strict=1 ;; *) printf 'preflight: unknown arg %q\n' "$arg" >&2; exit 2 ;; esac done REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" PATH="$REPO_ROOT/.venv/bin:$PATH" # the stack runs from the repo venv fail=0 optfail=0 _probe_cmd() { # _probe_cmd # [-- ]: the probe, if given, must exit 0 (a broken binary must not pass). # Argv-style, not an eval'd string. local counter="$1" miss_tag="$2" miss_sfx="$3" broken_tag="$4" cmd="$5" desc="$6" shift 6 [[ "${1:-}" == "--" ]] && shift if ! command -v "$cmd" >/dev/null 2>&1; then printf '%s%s — %s%s\n' "$miss_tag" "$cmd" "$desc" "$miss_sfx" printf -v "$counter" '%d' $(( ${!counter} + 1 )) return fi if (( $# > 0 )); then local out rc out=$("$@" 2>&1 | head -1) rc=$? if [[ $rc -ne 0 ]]; then printf '%s%s — probe exit %d: %s\n' "$broken_tag" "$cmd" "$rc" "$out" printf -v "$counter" '%d' $(( ${!counter} + 1 )) return fi if [[ $verbose -eq 1 ]]; then printf 'OK: %s — %s\n' "$cmd" "$out" return fi fi printf 'OK: %s\n' "$cmd" } # require_cmd/optional_cmd [-- ]: a miss is FAIL or OPT. require_cmd() { _probe_cmd fail 'MISSING: ' '' 'BROKEN: ' "$@"; } optional_cmd() { _probe_cmd optfail 'OPT: ' ' (not installed)' 'OPT: ' "$@"; } require_cmd ffmpeg "two-pass loudnorm + remux" -- ffmpeg -version require_cmd ffprobe "metadata probe used everywhere" -- ffprobe -version require_cmd mkvmerge "v2 pipeline final mux (matroska cues)" -- mkvmerge --version require_cmd mkvpropedit "global-tag stamping (NORMALIZED_AUDIO, CONSOLIDATED_SUBS)" -- mkvpropedit --version require_cmd mkvextract "tag XML extraction in media_stack/tags.py" -- mkvextract --version require_cmd python3 "all host-side scripts" -- python3 --version require_cmd docker "container runtime" -- docker --version # inotifywait --help exits 1, so probe via dpkg-query (argv-style: no pipes). require_cmd inotifywait "consolidate-watch.service file event source" \ -- dpkg-query -W -f='${Version}' inotify-tools require_cmd curl "ad-hoc API probes in shell scripts" -- curl --version require_cmd flock "cron-driver mutual exclusion" # Python deps live in ONE uv-managed venv (/.venv, pinned by uv.lock); PATH above puts it first. # uv sync --check exits 1 when the venv is missing, stale or has extra packages. require_cmd uv "manages the Python venv (install: https://docs.astral.sh/uv/)" -- uv --version if command -v uv >/dev/null 2>&1; then if [[ ! -x "$REPO_ROOT/.venv/bin/python" ]]; then printf 'MISSING: .venv/bin/python (run: uv sync)\n' fail=$((fail + 1)) elif sync_out=$(uv sync --check --locked --offline --project "$REPO_ROOT" 2>&1); then [[ $verbose -eq 1 ]] && printf 'OK py: .venv matches uv.lock\n' else printf 'BROKEN: .venv does not match uv.lock (run: uv sync): %s\n' "$(tail -n 1 <<<"$sync_out")" fail=$((fail + 1)) fi fi require_cmd subliminal "sub fallback chain in media_stack/subtitles.py" -- subliminal --version require_cmd ffsubsync "consolidate-subs.py timing sync" -- ffsubsync --version optional_cmd nvidia-smi "NVIDIA driver + GPU presence (used by Jellyfin HW transcode)" \ -- nvidia-smi --query-gpu=name --format=csv,noheader if command -v docker >/dev/null 2>&1; then if docker compose -f "$REPO_ROOT/docker-compose.yml" config >/dev/null 2>&1; then printf 'OK: docker-compose.yml parses\n' else printf 'MISSING: docker-compose.yml fails to parse — fix before bringing the stack up\n' fail=$((fail + 1)) fi fi for f in consolidate-subs.py normalize-audio.py; do if [[ ! -r "$REPO_ROOT/$f" ]]; then printf 'MISSING repo: %s (expected in %s)\n' "$f" "$REPO_ROOT" fail=$((fail + 1)) fi done printf '\n' if [[ $fail -gt 0 ]]; then printf 'PREFLIGHT FAILED: %d required dependency miss(es). %d optional miss(es).\n' "$fail" "$optfail" exit 1 fi if [[ $optfail -gt 0 ]]; then printf 'PREFLIGHT OK with %d optional miss(es).\n' "$optfail" [[ $strict -eq 1 ]] && exit 1 exit 2 fi printf 'PREFLIGHT OK: every required + optional dependency present.\n' exit 0