diff --git a/api.go b/api.go
index 11b28bc..ef1ffd8 100644
--- a/api.go
+++ b/api.go
@@ -479,11 +479,17 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
}
name := entry.Name()
- // Only process rec_*.mp4 files (not snap_*.jpg).
+ // Only process completed rec_*.mp4 files (not snap_*.jpg).
if !strings.HasPrefix(name, "rec_") || !strings.HasSuffix(strings.ToLower(name), ".mp4") {
continue
}
+ // Skip in-progress segments. FFmpeg only writes the MP4 index (moov)
+ // when it finalizes the file, so a .part.mp4 cannot be played yet.
+ if strings.HasSuffix(strings.ToLower(name), ".part.mp4") {
+ continue
+ }
+
info, statErr := entry.Info()
if statErr != nil {
continue
diff --git a/public/index.html b/public/index.html
index 73ed87d..8ce18b9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -51,7 +51,7 @@
-
+
diff --git a/recorder.go b/recorder.go
index 8a6b4e4..2da89de 100644
--- a/recorder.go
+++ b/recorder.go
@@ -146,6 +146,7 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
"-an", // drop audio (pcm_mulaw not supported in MP4)
"-c:v", "copy", // stream-copy video: zero transcoding, near-zero CPU
"-t", "300", // stop after 300 seconds (5 minutes) of input
+ "-movflags", "+faststart", // put the MP4 index first — Safari/iOS needs this
"-y", // overwrite output without asking
partFile,
)
diff --git a/scripts/remux-faststart.sh b/scripts/remux-faststart.sh
new file mode 100755
index 0000000..3cc9f31
--- /dev/null
+++ b/scripts/remux-faststart.sh
@@ -0,0 +1,133 @@
+#!/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