#!/usr/bin/env bash # NextNVR — remux-faststart.sh # # Moves the MP4 index atom (moov) to the front of existing recordings so they # start immediately in browsers, especially Safari/iOS. New recordings already # use -movflags +faststart; this script fixes the archive recorded before that # change. # # Usage: # scripts/remux-faststart.sh [--apply] [recordings-path] # # Defaults to a dry run against /mnt/recordings. Writes are atomic (temp file # in the same directory + rename) and the original mtime is preserved so the # retention cleaner still sees the true recording age. # # Requirements: ffmpeg. python3 is optional — when present, files that already # have moov before mdat are skipped. set -euo pipefail APPLY=0 ROOT="/mnt/recordings" usage() { cat <<'EOF' Usage: remux-faststart.sh [--apply] [recordings-path] --apply Actually rewrite files (default: dry run, report only) -h,--help Show this help Rewrites completed rec_*.mp4 files so their MP4 index (moov) is at the front. In-progress *.part.mp4 files are never touched. EOF } for arg in "$@"; do case "$arg" in --apply) APPLY=1 ;; -h|--help) usage; exit 0 ;; --*) echo "error: unknown option: $arg" >&2; usage >&2; exit 2 ;; *) ROOT="$arg" ;; esac done command -v ffmpeg >/dev/null 2>&1 || { echo "error: ffmpeg not found" >&2; exit 1; } [ -d "$ROOT" ] || { echo "error: not a directory: $ROOT" >&2; exit 1; } # Returns 0 when moov already precedes mdat (faststart), 1 when it does not, # 2 when the file cannot be parsed. moov_is_first() { python3 - "$1" <<'PY' import os import struct import sys path = sys.argv[1] size = os.path.getsize(path) off = 0 order = [] try: with open(path, "rb") as f: while off < size: f.seek(off) hdr = f.read(8) if len(hdr) < 8: break atom_size = struct.unpack(">I", hdr[:4])[0] atom_type = hdr[4:8].decode("latin1") if atom_size == 1: ext = f.read(8) if len(ext) < 8: break atom_size = struct.unpack(">Q", ext)[0] order.append(atom_type) if atom_size == 0: break off += atom_size except Exception: sys.exit(2) if "moov" not in order or "mdat" not in order: sys.exit(2) sys.exit(0 if order.index("moov") < order.index("mdat") else 1) PY } HAVE_PYTHON=0 command -v python3 >/dev/null 2>&1 && HAVE_PYTHON=1 total=0 converted=0 skipped=0 failed=0 echo "remux-faststart: root=$ROOT apply=$APPLY" while IFS= read -r -d '' file; do total=$((total + 1)) if [ "$HAVE_PYTHON" -eq 1 ] && moov_is_first "$file"; then skipped=$((skipped + 1)) continue fi if [ "$APPLY" -eq 0 ]; then echo "would remux: $file" converted=$((converted + 1)) continue fi tmp="${file%.mp4}.faststart.tmp.mp4" if ffmpeg -nostdin -v error -y -i "$file" -c copy -movflags +faststart "$tmp" \ && touch -r "$file" "$tmp" \ && mv -f "$tmp" "$file"; then converted=$((converted + 1)) echo "remuxed: $file" else failed=$((failed + 1)) rm -f "$tmp" echo "FAILED: $file" >&2 fi if [ $((converted % 100)) -eq 0 ] && [ "$converted" -gt 0 ]; then echo "--- progress: $converted processed ---" fi done < <(find "$ROOT" -type f -name 'rec_*.mp4' ! -name '*.part.mp4' -print0) echo echo "remux-faststart: scanned=$total remuxed=$converted skipped=$skipped failed=$failed" if [ "$APPLY" -eq 0 ] && [ "$converted" -gt 0 ]; then echo "dry run — re-run with --apply to rewrite the files above" fi