fix: skip in-progress .part segments and enable MP4 faststart

- /api/recordings no longer lists rec_*.part.mp4 files: FFmpeg has not
  written the moov atom yet, so they can never play. The in-progress
  segment was always the newest entry at the top of the list.
- Record new segments with -movflags +faststart so the MP4 index is at
  the front. iOS/Safari otherwise tail-seeks the moov and stalls.
- Add playsinline to the playback video for inline iOS playback.
- Add scripts/remux-faststart.sh to convert the existing archive
  (dry-run by default, atomic, preserves mtime for retention).
This commit is contained in:
Claus Lohmar 2026-10-20 08:10:09 +01:00
parent f794c57198
commit 6346d5fbe1
4 changed files with 142 additions and 2 deletions

8
api.go
View file

@ -479,11 +479,17 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
} }
name := entry.Name() 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") { if !strings.HasPrefix(name, "rec_") || !strings.HasSuffix(strings.ToLower(name), ".mp4") {
continue 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() info, statErr := entry.Info()
if statErr != nil { if statErr != nil {
continue continue

View file

@ -51,7 +51,7 @@
</div> </div>
<div id="pb-clips" class="clip-grid"></div> <div id="pb-clips" class="clip-grid"></div>
<div id="pb-player" class="clip-player hidden"> <div id="pb-player" class="clip-player hidden">
<video id="pb-video" controls></video> <video id="pb-video" controls playsinline></video>
<div style="display:flex;gap:8px;margin-top:12px"> <div style="display:flex;gap:8px;margin-top:12px">
<button id="pb-download" class="btn-primary">⬇ Download</button> <button id="pb-download" class="btn-primary">⬇ Download</button>
<button id="pb-close">✕ Close</button> <button id="pb-close">✕ Close</button>

View file

@ -146,6 +146,7 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
"-an", // drop audio (pcm_mulaw not supported in MP4) "-an", // drop audio (pcm_mulaw not supported in MP4)
"-c:v", "copy", // stream-copy video: zero transcoding, near-zero CPU "-c:v", "copy", // stream-copy video: zero transcoding, near-zero CPU
"-t", "300", // stop after 300 seconds (5 minutes) of input "-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 "-y", // overwrite output without asking
partFile, partFile,
) )

133
scripts/remux-faststart.sh Executable file
View file

@ -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