Files
Media-Server-Setup/bin/vgstuck.sh
Brandyn / Techy ca01516d42 Stuck-recording triage, a diag landing page, and the fixes behind Tuesday's symptoms
Triage
- bin/vgstuck.sh classifies every unpublished recording (parked by a failure log, published
  collision, waiting, still writing), with ffmpeg's own reason for parked ones. retry clears the
  blocker; drop moves to trash/<today>/ with the same 3-day undo as a dumped night; drop --all is
  refused. A leftover .mp4.part blocks nothing (ffmpeg overwrites with -y) and is only cleaned up.
- The same tool backs a page at diag.HOST/stuck. dumpd serves /stuck/api, lets anyone past the gate
  read, and allows retry/bin only for the admin groups; the path validation is the single thing
  between a request body and a file move, so it accepts only an existing .mp4 under .live/ inside
  the streams tree.
- The sweep logs event=parked once per run so Kibana can name stuck files rather than only count
  them.

Landing
- diag.HOST opens on a "Vanguard ops" dashboard via Kibana's defaultRoute: a markdown panel of links
  to every host and the triage page, plus Discover, Dashboards, Stack Rules and Index Management.
  Kibana stays at the root; moving it under /kibana would have meant the prefix in both Beats'
  setup.kibana, two scripts and the deadman, with Metricbeat crash-looping on whichever was missed.
- /api/kibana/settings is an internal API behind server.restrictInternalApis, which Kibana 9 turns
  on by default, so the script sends x-elastic-internal-origin. kibana-setup.py takes a step name and
  verifies each step by reading it back.

Installers
- bin/install.list is the one list of what lands in /usr/local/bin, read by install.sh,
  configure.py and deploy.sh. deploy.sh's own copy had drifted to five of ten files. check.sh fails
  on drift. deploy.sh also recreates a Beat or Kibana when its bind-mounted config changes, which
  nothing did before.
- check.sh distinguishes absent from broken: the server checkout is partial, and a yaml file or node
  binary that is not there is a skip, not a failure.

Fixes
- Go Live pixelation: x-google-min-bitrate=6000 forbade Chrome from encoding below 6 Mbps, so on a
  weak uplink the excess was dropped and MediaMTX logged invalid FU-A packets. Loss was on the
  publish sessions only. Floor is 1200 and scaleResolutionDownBy is unpinned.
- HLS manifestParsingError: handle_path strips /hls, so MediaMTX's cookieCheck redirect came back
  root-relative and landed on the SPA catch-all. header_down rewrites Location.
- Sweep audio: the band-removal branch overwrote $reason and lost the probe's audio=opus marker, so a
  Go Live recording with the band shipped Opus inside an MP4. probe_reason is kept separately.
- Deadman false alarm: alerts.state was only written when something changed, so quiet runs let it go
  stale. Every run stamps last_run now, and the deadman also checks is-failed on the unit.
- Both timer scripts load /etc/vanguard/alerts.env themselves, so a run by hand matches the timer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 21:25:43 -06:00

144 lines
7.0 KiB
Bash
Executable File

#!/bin/bash
# Triage recordings that never published: see why each one is stuck, then retry it or bin it.
#
# vgstuck.sh list everything stuck, with the reason (read-only, the default)
# vgstuck.sh retry <match> clear what is blocking it so the sweep tries again
# vgstuck.sh retry --all ...for everything that is parked
# vgstuck.sh drop <match> move the recording to trash: 3-day undo, same as "dump tonight"
# vgstuck.sh sweep kick the sweep once, without waiting on it
# vgstuck.sh json the same survey as JSON (what dumpd serves to the web page)
#
# <match> is any substring of the path (a streamer name, a title, a timestamp). It must match at least one
# recording, and `drop` refuses --all: binning every stuck recording in one keystroke is never a thing
# somebody meant to do.
#
# WHY A RECORDING STAYS IN .live/
# parked a <Title>__<ts>.normalize-failed.log sits next to the intended output. The sweep skips the
# file entirely until that log is deleted, so it will never retry on its own.
# published an output <Title>__<ts>.mp4 already exists, so the sweep leaves the source alone.
# waiting nothing is blocking it; it is simply newer than MINS_IDLE or the sweep has not run yet.
# partial a leftover <output>.mp4.part. This does NOT block anything (ffmpeg overwrites it with -y);
# it only wastes disk, and retry removes it.
#
# Nothing here deletes media. `drop` MOVES to /srv/media/trash/<today>/, which trash-purge clears after
# three days, so a wrong call is recoverable for the same window as a dumped night.
set -uo pipefail
ROOT="${MEDIA_ROOT:-/srv/media}"
TRASH="$ROOT/trash"
MINS_IDLE="${MINS_IDLE:-3}"
vglog() { local lvl="$1"; shift; printf 'ts=%s level=%s component=vgstuck %s\n' "$(date -Is)" "$lvl" "$*"; }
# Everything about one .live source, as: src|out|state|age_s|bytes|dur_s|reason
survey() {
local src titledir user title stamp base out state reason dur
while IFS= read -r -d '' src; do
titledir="$(dirname "$(dirname "$src")")"
user="$(basename "$(dirname "$titledir")")"
title="$(basename "$titledir")"
stamp="$(basename "${src%.*}")"
base="$(dirname "$titledir")/${title}__${stamp}"
out="$base.mp4"
reason=""
if [[ -e "$base.normalize-failed.log" ]]; then
state=parked
reason="$(head -1 "$base.normalize-failed.log" 2>/dev/null)"
local why; why=$(grep -A2 '^--- ffmpeg stderr ---$' "$base.normalize-failed.log" 2>/dev/null | sed -n '2p')
[[ -n ${why:-} ]] && reason="$reason :: $why"
elif [[ -e "$out" ]]; then
state=published
reason="output already exists: $(basename "$out")"
elif [[ -n $(find "$src" -mmin "-$MINS_IDLE" -print -quit 2>/dev/null) ]]; then
state=writing
reason="touched in the last ${MINS_IDLE}m — still recording"
else
state=waiting
reason="nothing blocking it; the sweep has not got to it"
fi
[[ -e "$out.part" ]] && reason="$reason [leftover .part]"
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$src" 2>/dev/null | cut -d. -f1)
printf '%s|%s|%s|%s|%s|%s|%s\n' "$src" "$out" "$state" \
"$(( $(date +%s) - $(stat -c %Y "$src") ))" "$(stat -c %s "$src")" "${dur:-0}" "$reason"
done < <(find "$ROOT/streams" -path '*/.live/*' -name '*.mp4' -print0 2>/dev/null)
}
show() {
local any=0 src out state age bytes dur reason
printf '\n %-9s %-9s %9s %8s %s\n' state waiting length size recording
while IFS='|' read -r src out state age bytes dur reason; do
any=1
printf ' %-9s %-9s %9s %7.0fM %s\n' "$state" \
"$(( age / 3600 ))h$(printf '%02d' $(( (age % 3600) / 60 )))m" \
"$(printf '%d:%02d:%02d' $(( dur / 3600 )) $(( (dur % 3600) / 60 )) $(( dur % 60 )))" \
"$(awk -v b="$bytes" 'BEGIN{print b/1000000}')" "${src#"$ROOT"/streams/}"
printf ' %s\n' "$reason"
done < <(survey | sort -t'|' -k3,3 -k4,4nr)
(( any )) || printf ' nothing is stuck.\n'
printf '\n retry one: vgstuck.sh retry <substring> bin one: vgstuck.sh drop <substring>\n\n'
}
# rows matching the pattern, or everything when --all
selected() {
local pattern="$1"
if [[ $pattern == --all ]]; then survey; else survey | grep -F -- "$pattern" || true; fi
}
do_retry() {
local pattern="$1" n=0 src out state age bytes dur reason
while IFS='|' read -r src out state age bytes dur reason; do
[[ $state == writing ]] && { vglog info "event=skip reason=still_recording file=\"$src\""; continue; }
rm -f "$out.part" # leftover partial output: harmless, but no reason to keep
rm -f "${out%.mp4}.normalize-failed.log" # THIS is what makes the sweep skip the file forever
n=$((n + 1))
vglog info "event=retry file=\"$src\" was=$state"
done < <(selected "$pattern")
(( n )) || { vglog warn "event=nothing_matched pattern=\"$pattern\""; return 1; }
vglog info "event=retry_done count=$n msg=\"run vgstuck.sh sweep, or wait for the timer\""
}
do_drop() {
local pattern="$1" night n=0 src out state age bytes dur reason rel dst
[[ $pattern == --all ]] && { vglog error 'event=refused msg="drop --all is not allowed; name them"' >&2; return 2; }
night="$(date +%F)"
while IFS='|' read -r src out state age bytes dur reason; do
[[ $state == writing ]] && { vglog info "event=skip reason=still_recording file=\"$src\""; continue; }
for victim in "$src" "${src%.*}.json" "${out%.mp4}.normalize-failed.log" "$out.part"; do
[[ -e $victim ]] || continue
rel="${victim#"$ROOT"/}"
dst="$TRASH/$night/$rel"
mkdir -p "$(dirname "$dst")"
mv -f "$victim" "$dst" && vglog info "event=trashed file=\"$rel\" undo_days=3"
done
rmdir "$(dirname "$src")" "$(dirname "$(dirname "$src")")" 2>/dev/null || true
n=$((n + 1))
done < <(selected "$pattern")
(( n )) || { vglog warn "event=nothing_matched pattern=\"$pattern\""; return 1; }
vglog info "event=drop_done count=$n trash=\"$TRASH/$night\""
}
# Same survey, as JSON. bin/dumpd.py serves this to the web page, so the classification lives in exactly
# one place and the browser and the CLI can never disagree about why something is stuck.
emit_json() {
survey | python3 -c '
import json, sys
rows = []
for line in sys.stdin:
parts = line.rstrip("\n").split("|", 6)
if len(parts) != 7:
continue
src, out, state, age, size, dur, reason = parts
rows.append({"src": src, "out": out, "state": state, "age_s": int(age or 0),
"bytes": int(size or 0), "duration_s": int(dur or 0), "reason": reason})
print(json.dumps(rows))'
}
case "${1:-list}" in
list|"") show ;;
json) emit_json ;;
retry) [[ -n ${2:-} ]] || { echo "usage: vgstuck.sh retry <substring>|--all" >&2; exit 1; }; do_retry "$2" ;;
drop) [[ -n ${2:-} ]] || { echo "usage: vgstuck.sh drop <substring>" >&2; exit 1; }; do_drop "$2" ;;
sweep) systemctl start --no-block normalize-sweep.service && vglog info "event=sweep_started" ;;
*) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac