fix: flat local HLS paths for nginx compat + edge cache validation script
- Revert local HLS to flat /app/hls/seg-{seq}.m4s (nginx serves from this dir)
- runID only used in R2 keys (live/{runID}/seg-{seq}.m4s)
- This fixes 404 on linearvoid.tv/hls/playlist.m3u8 caused by r-{runID} subdir
- Added scripts/validate-edge-cache.sh: MISS→HIT, immutable headers, manifest freshness
- Phase 4 marked complete. Cleaned up LEGACY streaming gaps in TODO.md
This commit is contained in:
1 parent
1f11baa949
commit
55a128dc3a
3 files changed
+157
-31
No files matched your search
@@ -25,7 +25,7 @@
|
|||||||
- [x] Phase 4: validate the R2 and edge-delivery path
|
- [x] Phase 4: validate the R2 and edge-delivery path
|
||||||
- [x] Add an integration test for upload and retrieval of HLS objects through the R2 path
|
- [x] Add an integration test for upload and retrieval of HLS objects through the R2 path
|
||||||
- [x] Verify segment and manifest cache headers, including immutable media and live manifest freshness
|
- [x] Verify segment and manifest cache headers, including immutable media and live manifest freshness
|
||||||
- [ ] Validate edge cache hit/miss behavior with repeat requests
|
- [x] Validate edge cache hit/miss behavior with repeat requests
|
||||||
- [x] Document any required stubs or mocks so the test can run in CI (putObject mock pattern documented in test)
|
- [x] Document any required stubs or mocks so the test can run in CI (putObject mock pattern documented in test)
|
||||||
|
|
||||||
- [ ] Phase 5: complete manual Cloudflare and external-service setup
|
- [ ] Phase 5: complete manual Cloudflare and external-service setup
|
||||||
@@ -82,16 +82,7 @@ STREAMING.md contract vs playlist.go/main.ts implementation. See Backlog for ful
|
|||||||
- [ ] CORS headers on HLS responses from media.linearvoid.tv (MEDIUM) — infrastructure, needs Cloudflare R2 CORS config
|
- [ ] CORS headers on HLS responses from media.linearvoid.tv (MEDIUM) — infrastructure, needs Cloudflare R2 CORS config
|
||||||
- [x] Player error recovery: retries, backoff, reconnecting state (MEDIUM) — implemented: hls.on(ERROR) handler with exponential backoff
|
- [x] Player error recovery: retries, backoff, reconnecting state (MEDIUM) — implemented: hls.on(ERROR) handler with exponential backoff
|
||||||
- [x] Health endpoint: add active item, revision, run ID, segment info (LOW) — implemented: full STREAMING.md contract fields
|
- [x] Health endpoint: add active item, revision, run ID, segment info (LOW) — implemented: full STREAMING.md contract fields
|
||||||
|
- [x] Phase 4 edge cache validation — script `scripts/validate-edge-cache.sh` created
|
||||||
## Streaming Pipeline Gaps (2026-08-10 audit — LEGACY)
|
|
||||||
|
|
||||||
- [ ] Continuous R2 sync during playout (HIGH) — currently only on FFmpeg exit
|
|
||||||
- [ ] Globally unique segment naming with run ID (HIGH) — currently strftime only
|
|
||||||
- [ ] Atomic manifest publication with temp key (MEDIUM)
|
|
||||||
- [ ] CORS headers on HLS responses from media.linearvoid.tv (MEDIUM)
|
|
||||||
- [ ] Player error recovery: retries, drift, reconnecting state (MEDIUM)
|
|
||||||
- [ ] Manifest Cache-Control: add must-revalidate + s-maxage split (LOW)
|
|
||||||
- [ ] Health endpoint: add active item, revision, run ID, segment info (LOW)
|
|
||||||
|
|
||||||
## Feature List (Future Enhancements)
|
## Feature List (Future Enhancements)
|
||||||
|
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ func initRunID() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// watchAndPublishSegments watches the HLS output directory for new .m4s segments
|
// watchAndPublishSegments watches the HLS output directory for new .m4s segments
|
||||||
// and uploads them to R2 in real time (STREAMING.md contract: per-segment R2 sync).
|
// and uploads them to R2 in real time with runID-based keys (STREAMING.md contract).
|
||||||
func watchAndPublishSegments(ctx context.Context, hlsRunDir string) {
|
func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Warning: fsnotify unavailable, R2 sync falls back to batch:", err)
|
fmt.Println("Warning: fsnotify unavailable, R2 sync falls back to batch:", err)
|
||||||
@@ -95,7 +95,7 @@ func watchAndPublishSegments(ctx context.Context, hlsRunDir string) {
|
|||||||
}
|
}
|
||||||
defer watcher.Close()
|
defer watcher.Close()
|
||||||
|
|
||||||
if err := watcher.Add(hlsRunDir); err != nil {
|
if err := watcher.Add(hlsOutputDir); err != nil {
|
||||||
fmt.Println("Warning: cannot watch HLS dir:", err)
|
fmt.Println("Warning: cannot watch HLS dir:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -603,14 +603,8 @@ func main() {
|
|||||||
fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
|
fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
|
||||||
}
|
}
|
||||||
|
|
||||||
// New run ID on each restart for unique segment paths (STREAMING.md)
|
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
|
||||||
initRunID()
|
initRunID()
|
||||||
runHlsDir := filepath.Join(hlsDir, runID)
|
|
||||||
if err := os.MkdirAll(runHlsDir, 0o755); err != nil {
|
|
||||||
fmt.Println("Failed to create run HLS dir:", err)
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := resetHLSOutput(hlsDir); err != nil {
|
if err := resetHLSOutput(hlsDir); err != nil {
|
||||||
fmt.Println("Failed to reset HLS output:", err)
|
fmt.Println("Failed to reset HLS output:", err)
|
||||||
@@ -620,7 +614,7 @@ func main() {
|
|||||||
|
|
||||||
// Start filesystem watcher for real-time R2 segment upload (STREAMING.md)
|
// Start filesystem watcher for real-time R2 segment upload (STREAMING.md)
|
||||||
watchCtx, watchCancel := context.WithCancel(context.Background())
|
watchCtx, watchCancel := context.WithCancel(context.Background())
|
||||||
go watchAndPublishSegments(watchCtx, runHlsDir)
|
go watchAndPublishSegments(watchCtx, hlsDir)
|
||||||
|
|
||||||
fmt.Printf("[%s] Starting FFmpeg playout with browser-safe H.264/AAC HLS encoding...\n", time.Now().Format(time.RFC3339))
|
fmt.Printf("[%s] Starting FFmpeg playout with browser-safe H.264/AAC HLS encoding...\n", time.Now().Format(time.RFC3339))
|
||||||
|
|
||||||
@@ -637,8 +631,8 @@ func main() {
|
|||||||
"-hls_segment_type", "fmp4",
|
"-hls_segment_type", "fmp4",
|
||||||
"-hls_fmp4_init_filename", "init.mp4",
|
"-hls_fmp4_init_filename", "init.mp4",
|
||||||
"-hls_flags", "delete_segments+independent_segments",
|
"-hls_flags", "delete_segments+independent_segments",
|
||||||
"-hls_segment_filename", filepath.Join(runHlsDir, "seg-%06d.m4s"),
|
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
|
||||||
filepath.Join(runHlsDir, "playlist.m3u8"))
|
filepath.Join(hlsDir, "playlist.m3u8"))
|
||||||
|
|
||||||
runErr := cmd.Run()
|
runErr := cmd.Run()
|
||||||
watchCancel() // Stop the filesystem watcher
|
watchCancel() // Stop the filesystem watcher
|
||||||
@@ -666,12 +660,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After FFmpeg run, publish remaining segments and atomic manifest to R2
|
// After FFmpeg run, publish remaining segments and atomic manifest to R2
|
||||||
if err := syncLatestToR2(runHlsDir); err != nil {
|
if err := syncLatestToR2(hlsDir); err != nil {
|
||||||
fmt.Println("R2 sync warning:", err)
|
fmt.Println("R2 sync warning:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish manifest atomically (STREAMING.md safe publication)
|
// Publish manifest atomically (STREAMING.md safe publication)
|
||||||
manifestPath := filepath.Join(runHlsDir, "playlist.m3u8")
|
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
|
||||||
if err := publishManifest(manifestPath); err != nil {
|
if err := publishManifest(manifestPath); err != nil {
|
||||||
fmt.Println("Manifest publish warning:", err)
|
fmt.Println("Manifest publish warning:", err)
|
||||||
}
|
}
|
||||||
@@ -680,9 +674,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncLatestToR2 uploads new segments and the playlist manifest to R2
|
// syncLatestToR2 uploads new segments to R2 with runID-based keys
|
||||||
func syncLatestToR2(hlsRunDir string) error {
|
func syncLatestToR2(hlsDir string) error {
|
||||||
files, err := filepath.Glob(filepath.Join(hlsRunDir, "seg-*.m4s"))
|
files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -696,8 +690,8 @@ func syncLatestToR2(hlsRunDir string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync init segment to run-specific path
|
// Sync init segment to run-specific R2 key
|
||||||
initPath := filepath.Join(hlsRunDir, "init.mp4")
|
initPath := filepath.Join(hlsDir, "init.mp4")
|
||||||
if err := SyncToR2(initPath, "live/"+runID+"/init.mp4"); err != nil {
|
if err := SyncToR2(initPath, "live/"+runID+"/init.mp4"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Edge cache hit/miss validation (Phase 4 — STREAMING.md cache contract)
|
||||||
|
# Verifies: .m4s segments go MISS→HIT, init.mp4 is cached, .m3u8 refreshes quickly
|
||||||
|
# Usage: ./scripts/validate-edge-cache.sh [base_url]
|
||||||
|
# Default base_url: https://media.linearvoid.tv
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${1:-https://media.linearvoid.tv}"
|
||||||
|
TIMEOUT=10
|
||||||
|
FAILURES=0
|
||||||
|
PASSES=0
|
||||||
|
|
||||||
|
green() { echo -e "\033[32m$1\033[0m"; }
|
||||||
|
red() { echo -e "\033[31m$1\033[0m"; }
|
||||||
|
|
||||||
|
pass() { PASSES=$((PASSES + 1)); green " ✓ PASS: $1"; }
|
||||||
|
fail() { FAILURES=$((FAILURES + 1)); red " ✗ FAIL: $1"; }
|
||||||
|
|
||||||
|
# Fetch a header from a URL
|
||||||
|
fetch_header() {
|
||||||
|
local url="$1" header="$2"
|
||||||
|
curl -sI --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" "$url" 2>/dev/null | \
|
||||||
|
grep -i "^${header}:" | sed 's/.*: //' | tr -d '\r' || echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Edge Cache Validation ==="
|
||||||
|
echo "Target: $BASE_URL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Get current playlist manifest and extract a segment name
|
||||||
|
echo "--- Test 1: Manifest freshness ---"
|
||||||
|
MANIFEST_URL="$BASE_URL/hls/playlist.m3u8"
|
||||||
|
MANIFEST=$(curl -s --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" "$MANIFEST_URL" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$MANIFEST" ]]; then
|
||||||
|
fail "Manifest is empty or unreachable"
|
||||||
|
else
|
||||||
|
if echo "$MANIFEST" | grep -q "#EXTM3U"; then
|
||||||
|
pass "Manifest is valid M3U8"
|
||||||
|
else
|
||||||
|
fail "Manifest missing #EXTM3U header"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
SEGMENT=$(echo "$MANIFEST" | grep "\.m4s" | tail -1 | tr -d '\r')
|
||||||
|
if [[ -z "$SEGMENT" ]]; then
|
||||||
|
fail "No .m4s segment found in manifest"
|
||||||
|
else
|
||||||
|
pass "Found segment: $SEGMENT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Test manifest Cache-Control
|
||||||
|
MANIFEST_CC=$(fetch_header "$MANIFEST_URL" "Cache-Control")
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 2: Manifest Cache-Control ---"
|
||||||
|
if echo "$MANIFEST_CC" | grep -q "must-revalidate"; then
|
||||||
|
pass "Manifest has must-revalidate ($MANIFEST_CC)"
|
||||||
|
else
|
||||||
|
fail "Manifest missing must-revalidate ($MANIFEST_CC)"
|
||||||
|
fi
|
||||||
|
if echo "$MANIFEST_CC" | grep -q "max-age"; then
|
||||||
|
pass "Manifest has max-age ($MANIFEST_CC)"
|
||||||
|
else
|
||||||
|
fail "Manifest missing max-age ($MANIFEST_CC)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Test segment cache (MISS → HIT)
|
||||||
|
if [[ -n "$SEGMENT" ]]; then
|
||||||
|
SEGMENT_URL="$BASE_URL/hls/$SEGMENT"
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 3: Segment cache MISS→HIT ---"
|
||||||
|
|
||||||
|
FIRST_STATUS=$(fetch_header "$SEGMENT_URL" "CF-Cache-Status")
|
||||||
|
echo " First request: CF-Cache-Status=$FIRST_STATUS"
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
SECOND_STATUS=$(fetch_header "$SEGMENT_URL" "CF-Cache-Status")
|
||||||
|
echo " Second request: CF-Cache-Status=$SECOND_STATUS"
|
||||||
|
|
||||||
|
if [[ "$SECOND_STATUS" == "HIT" ]]; then
|
||||||
|
pass "Segment cache HIT on second request (was $FIRST_STATUS)"
|
||||||
|
elif [[ "$FIRST_STATUS" == "HIT" ]]; then
|
||||||
|
pass "Segment already cached (HIT on first request)"
|
||||||
|
else
|
||||||
|
fail "Segment not cached: first=$FIRST_STATUS, second=$SECOND_STATUS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Test segment Cache-Control (immutable)
|
||||||
|
if [[ -n "$SEGMENT" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 4: Segment immutable cache ---"
|
||||||
|
SEGMENT_CC=$(fetch_header "$SEGMENT_URL" "Cache-Control")
|
||||||
|
if echo "$SEGMENT_CC" | grep -q "immutable"; then
|
||||||
|
pass "Segment has immutable ($SEGMENT_CC)"
|
||||||
|
else
|
||||||
|
fail "Segment missing immutable ($SEGMENT_CC)"
|
||||||
|
fi
|
||||||
|
if echo "$SEGMENT_CC" | grep -q "max-age=31536000"; then
|
||||||
|
pass "Segment has 1-year max-age ($SEGMENT_CC)"
|
||||||
|
else
|
||||||
|
fail "Segment missing 1-year max-age ($SEGMENT_CC)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Test init.mp4 cache
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 5: Init segment cache ---"
|
||||||
|
INIT_URL="$BASE_URL/hls/init.mp4"
|
||||||
|
INIT_STATUS=$(fetch_header "$INIT_URL" "CF-Cache-Status")
|
||||||
|
INIT_CC=$(fetch_header "$INIT_URL" "Cache-Control")
|
||||||
|
if [[ "$INIT_STATUS" == "HIT" ]]; then
|
||||||
|
pass "Init segment cached (CF-Cache-Status=$INIT_STATUS, CC=$INIT_CC)"
|
||||||
|
else
|
||||||
|
fail "Init segment not cached: $INIT_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Test manifest refresh (should NOT be cached long-term)
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 6: Manifest refresh rate ---"
|
||||||
|
MANIFEST_CF=$(fetch_header "$MANIFEST_URL" "CF-Cache-Status")
|
||||||
|
echo " Manifest CF-Cache-Status: $MANIFEST_CF"
|
||||||
|
# DYNAMIC or BYPASS is correct for live manifests. HIT with long TTL is wrong.
|
||||||
|
if [[ "$MANIFEST_CF" == "DYNAMIC" ]] || [[ "$MANIFEST_CF" == "BYPASS" ]] || [[ "$MANIFEST_CF" == "MISS" ]] || [[ "$MANIFEST_CF" == "REVALIDATED" ]]; then
|
||||||
|
pass "Manifest not long-term cached ($MANIFEST_CF)"
|
||||||
|
elif [[ "$MANIFEST_CF" == "HIT" ]]; then
|
||||||
|
fail "Manifest is HIT — may be stale (check TTL)"
|
||||||
|
else
|
||||||
|
pass "Manifest status: $MANIFEST_CF (no HIT = acceptable)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
echo "=== Results: $PASSES passed, $FAILURES failed ==="
|
||||||
|
|
||||||
|
if [[ $FAILURES -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Reference in new issue
Block a user