feat: add automatic media normalization, R2 retention cleanup, and seamless playout loops

This commit is contained in:
zwitschi committed 2026-08-13 11:59:46 +02:00
1 parent e15d89edfe
commit 06ab5b4655
16 files changed
+1103 -408

No files matched your search

+169 -80
View File
@@ -4,74 +4,74 @@ Completed tasks from `.github/instructions/TODO.md`. Historical record for CHANG
## Session 2026-08-07 — Unit Tests
- [x] Chat service refactor: `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics)
- [x] `db.RedisClient` + `db.PubSub` interfaces defined for mocking
- [x] Config tests: 10 env-var tests, table-driven, 100% coverage
- [x] Auth tests: token generation, session CRUD, cookie security, middleware, 97.8% coverage
- [x] DB tests: SQLite in-memory CRUD for all 4 models, 14.3% coverage
- [x] Handlers tests: 35 tests covering login/logout/session/overview/playlist/moderation/analytics/system/config/users/schedule/media/chat-log, 47.2% coverage
- [x] WebSocket tests: 15 tests covering connect/send/dm/moderation/banned/muted/history/presence/pubsub, 79.6% coverage
- [x] Media tests: formatDuration, extensions, mime, upload IDs, playlist parsing, 36.8% coverage
- [x] Ratelimit tests: message/login/WS rate limits with mock Redis, 90.3% coverage
- [x] Metrics tests: Prometheus output validation, all 9 metrics present, 100% coverage
- [x] Playout tests: 12 tests (LoadPlaylist, keyframeInterval, HealthHandler, slate escalation, reset/activate/deactivate)
- [x] Frontend test framework: vitest + jsdom installed
- [x] User frontend smoke tests: 6 tests (chat toggle, sendMessage, switchRoom, DM toggle)
- [x] Admin frontend smoke tests: 5 tests (DOM structure, upload progress, fingerprint, constants, import stability)
- [x] CI pipeline updated: `go test -race -cover` with 50% threshold
- [x] SQLite driver switched to pure-Go `github.com/glebarez/sqlite` (CGO-free)
- [x] Total: 78 tests across Go + frontend, all passing
- Chat service refactor: `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics)
- `db.RedisClient` + `db.PubSub` interfaces defined for mocking
- Config tests: 10 env-var tests, table-driven, 100% coverage
- Auth tests: token generation, session CRUD, cookie security, middleware, 97.8% coverage
- DB tests: SQLite in-memory CRUD for all 4 models, 14.3% coverage
- Handlers tests: 35 tests covering login/logout/session/overview/playlist/moderation/analytics/system/config/users/schedule/media/chat-log, 47.2% coverage
- WebSocket tests: 15 tests covering connect/send/dm/moderation/banned/muted/history/presence/pubsub, 79.6% coverage
- Media tests: formatDuration, extensions, mime, upload IDs, playlist parsing, 36.8% coverage
- Ratelimit tests: message/login/WS rate limits with mock Redis, 90.3% coverage
- Metrics tests: Prometheus output validation, all 9 metrics present, 100% coverage
- Playout tests: 12 tests (LoadPlaylist, keyframeInterval, HealthHandler, slate escalation, reset/activate/deactivate)
- Frontend test framework: vitest + jsdom installed
- User frontend smoke tests: 6 tests (chat toggle, sendMessage, switchRoom, DM toggle)
- Admin frontend smoke tests: 5 tests (DOM structure, upload progress, fingerprint, constants, import stability)
- CI pipeline updated: `go test -race -cover` with 50% threshold
- SQLite driver switched to pure-Go `github.com/glebarez/sqlite` (CGO-free)
- Total: 78 tests across Go + frontend, all passing
## Session 2026-08-07 — Runtime Validation
- [x] Check `monitoring/prometheus/prometheus.yml` for completeness
- [x] Make sure all 7 scrape targets are included (chat, playout, nginx, redis, postgres, node, prometheus)
- [x] Check `monitoring/prometheus/alerts.yml` for completeness
- [x] Make sure configuration for prometheus is mounted into container via named volume
- [x] Use folder `monitoring/grafana/` to create ini file for Grafana, set `GF_PATHS_CONFIG` environment variable to point to a different location than `/etc/grafana/grafana.ini` if needed
- [x] Set default password from `GRAFANA_PASSWORD` env var, disable user signups
- [x] Configure Prometheus data source in Grafana ini file
- [x] Include dashboards from `monitoring/grafana/dashboards/` directory to be imported into Grafana on startup
- [x] **CD trigger parallel**: Change `cd.yml` from `workflow_run: [CI]` to `push: branches: [main]` so deploy runs in parallel with CI, not after
- [x] **CD failure fix**: Remove `environment: production` from `cd.yml` deploy job (no Gitea environment configured, causes silent job abort after Buildx)
- [x] **Remove redundant CI release job**: Delete `release` job from `ci.yml` — fully handled by `release.yml`
- [x] **Tag format**: Use `*.*.*` tags (e.g. `1.0.9`) — `release.yml` pattern is `*.*.*`, not `v*.*.*`. Fix existing `v1.0.x` tags to `1.0.x`
- [x] **Update DEPLOYMENT.md**: Document new parallel pipeline flow (push → CI + CD run simultaneously; tag push → Release builds versioned images)
- [x] Add sections to `SETUP.md` under `## Cloudflare` for detailed instructions:
- [x] DNS: `linearvoid.tv` apex + `www` CNAME → Cloudflare Pages
- [x] DNS: `media.linearvoid.tv` for split-domain HLS delivery
- [x] R2 bucket `linearvoid-media` with `enam` location hint
- [x] Cache Rules: `.ts/.m4s` immutable long TTL (31536000), `.m3u8` short TTL (2s, revalidate)
- [x] Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist`
- [x] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
- [x] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
- [x] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
- [x] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2
- [x] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
- [x] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
- [x] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
- [x] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
- [x] Check all known URLs for metrics and their availability:
- [x] Prometheus: `http://<host>:9090` (targets, graph, alerts)
- [x] Grafana: `http://<host>:3001` (default: admin/admin, change via `GRAFANA_PASSWORD`)
- [x] Chat metrics: `http://chat:3000/metrics` from Prometheus container
- [x] Playout metrics: `http://playout:8081/metrics` from Prometheus container
- [x] Nginx metrics: `http://nginx-exporter:9113/metrics` from Prometheus container
- [x] Redis metrics: `http://redis-exporter:9121/metrics` from Prometheus container
- [x] Postgres metrics: `http://postgres-exporter:9187/metrics` from Prometheus container
- [x] Node metrics: `http://node-exporter:9100/metrics` from Prometheus container
- [x] Make sure Prometheus config is mounted into container via named volume `prometheus-config` (not bind mount)
- [x] Make sure Prometheus alerting rules are configured and working (8 rules: ChatDown, PlayoutDown, RedisDown, etc.)
- [x] Verify that all Prometheus targets are up and reporting metrics
- [x] Verify that Grafana dashboards are imported and displaying metrics correctly
- [x] Video normalization: test with MOV, MKV, variable framerate, different audio layouts
- Check `monitoring/prometheus/prometheus.yml` for completeness
- Make sure all 7 scrape targets are included (chat, playout, nginx, redis, postgres, node, prometheus)
- Check `monitoring/prometheus/alerts.yml` for completeness
- Make sure configuration for prometheus is mounted into container via named volume
- Use folder `monitoring/grafana/` to create ini file for Grafana, set `GF_PATHS_CONFIG` environment variable to point to a different location than `/etc/grafana/grafana.ini` if needed
- Set default password from `GRAFANA_PASSWORD` env var, disable user signups
- Configure Prometheus data source in Grafana ini file
- Include dashboards from `monitoring/grafana/dashboards/` directory to be imported into Grafana on startup
- **CD trigger parallel**: Change `cd.yml` from `workflow_run: [CI]` to `push: branches: [main]` so deploy runs in parallel with CI, not after
- **CD failure fix**: Remove `environment: production` from `cd.yml` deploy job (no Gitea environment configured, causes silent job abort after Buildx)
- **Remove redundant CI release job**: Delete `release` job from `ci.yml` — fully handled by `release.yml`
- **Tag format**: Use `*.*.*` tags (e.g. `1.0.9`) — `release.yml` pattern is `*.*.*`, not `v*.*.*`. Fix existing `v1.0.x` tags to `1.0.x`
- **Update DEPLOYMENT.md**: Document new parallel pipeline flow (push → CI + CD run simultaneously; tag push → Release builds versioned images)
- Add sections to `SETUP.md` under `## Cloudflare` for detailed instructions:
- DNS: `linearvoid.tv` apex + `www` CNAME → Cloudflare Pages
- DNS: `media.linearvoid.tv` for split-domain HLS delivery
- R2 bucket `linearvoid-media` with `enam` location hint
- Cache Rules: `.ts/.m4s` immutable long TTL (31536000), `.m3u8` short TTL (2s, revalidate)
- Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist`
- Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
- WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
- Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
- HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2
- HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
- UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
- UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
- Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
- Check all known URLs for metrics and their availability:
- Prometheus: `http://<host>:9090` (targets, graph, alerts)
- Grafana: `http://<host>:3001` (default: admin/admin, change via `GRAFANA_PASSWORD`)
- Chat metrics: `http://chat:3000/metrics` from Prometheus container
- Playout metrics: `http://playout:8081/metrics` from Prometheus container
- Nginx metrics: `http://nginx-exporter:9113/metrics` from Prometheus container
- Redis metrics: `http://redis-exporter:9121/metrics` from Prometheus container
- Postgres metrics: `http://postgres-exporter:9187/metrics` from Prometheus container
- Node metrics: `http://node-exporter:9100/metrics` from Prometheus container
- Make sure Prometheus config is mounted into container via named volume `prometheus-config` (not bind mount)
- Make sure Prometheus alerting rules are configured and working (8 rules: ChatDown, PlayoutDown, RedisDown, etc.)
- Verify that all Prometheus targets are up and reporting metrics
- Verify that Grafana dashboards are imported and displaying metrics correctly
- Video normalization: test with MOV, MKV, variable framerate, different audio layouts
- Evidence: all normalized outputs probed as `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2ch`.
- [x] Video normalization: verify output plays back-to-back in continuous concat
- Video normalization: verify output plays back-to-back in continuous concat
- Evidence: concat remux reported `CONCAT_OK`; output duration `25.344667s`.
- [x] Viewer SPA: test responsive layout on iOS Safari + Android Chrome
- Viewer SPA: test responsive layout on iOS Safari + Android Chrome
- Evidence: viewport emulation (`390x844`, `412x915`) showed video/chat/toggle present; fixed 8px horizontal overflow in `frontend/src/style.css` (`100vw` -> `100%`).
- Limitation: no physical-device Safari/Chrome test in this run.
- [x] Redis: test restart, verify sessions + presence data survive
- Redis: test restart, verify sessions + presence data survive
- Evidence: admin session HTTP `200` before/after Redis restart; seeded presence key visible before/after restart; Redis health returned to `healthy`.
### Runtime Tests (all 4)
@@ -84,15 +84,15 @@ Completed tasks from `.github/instructions/TODO.md`. Historical record for CHANG
### Documentation Polish
- [x] API.md: add error codes table (full status code matrix) + rate limit headers
- [x] DEPLOYMENT.md: prereqs checklist, first-deploy walkthrough, restart/view-logs/rollback, troubleshooting
- [x] MONITORING.md: architecture diagram, Grafana URL + default creds, dashboard tour, alert descriptions, scrape targets
- [x] SECURITY.md: threat model diagram, TLS/HSTS config, auth flow, network isolation, secrets management
- [x] Level 2 building blocks: detail Playout/Storage/Frontend/Chat/Proxy with Mermaid + code locations + persistence
- [x] Level 3 building blocks: module breakdowns (parser, manager, uploader, handler, registry, router, etc.)
- [x] Recovery runbook: document quarterly disaster recovery drill
- API.md: add error codes table (full status code matrix) + rate limit headers
- DEPLOYMENT.md: prereqs checklist, first-deploy walkthrough, restart/view-logs/rollback, troubleshooting
- MONITORING.md: architecture diagram, Grafana URL + default creds, dashboard tour, alert descriptions, scrape targets
- SECURITY.md: threat model diagram, TLS/HSTS config, auth flow, network isolation, secrets management
- Level 2 building blocks: detail Playout/Storage/Frontend/Chat/Proxy with Mermaid + code locations + persistence
- Level 3 building blocks: module breakdowns (parser, manager, uploader, handler, registry, router, etc.)
- Recovery runbook: document quarterly disaster recovery drill
- Evidence: expanded `docs/incident-runbook.md` with prerequisites, simulation steps, validation checks, and pass/fail criteria.
- [x] Recovery runbook: rehearse quarterly disaster recovery drill
- Recovery runbook: rehearse quarterly disaster recovery drill
- Manual step: execute full drill on target environment and capture RTO/RPO evidence.
## Session 2026-08-06 — Documentation Adjustments, Playout, Normalization, Chat, Viewer SPA, Admin, Docker, CI/CD
@@ -975,12 +975,12 @@ Notes:
Completed 6 of 7 streaming pipeline gaps from STREAMING.md contract audit:
- [x] **Continuous R2 sync (HIGH)** — Added `watchAndPublishSegments()` using `fsnotify` to upload segments in real-time (~7s delay), replacing batch-on-exit pattern.
- [x] **Globally unique segment naming (HIGH)** — Added `initRunID()` generating `r-ISOTIME` IDs and `seg-{seq}.m4s` monotonic naming. Replaced `strftime` collision-prone pattern.
- [x] **Atomic manifest publication (MEDIUM)** — Added `publishManifest()` uploading to `playlist.m3u8.tmp` then `CopyObject` to final key.
- [x] **Manifest Cache-Control contract (LOW)** — Updated to `public, max-age=1, s-maxage=2, must-revalidate`.
- [x] **Player error recovery (MEDIUM)** — Added `hls.on(ERROR)` handler with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay with spinner.
- [x] **Health endpoint detail (LOW)** — Enriched `/health` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`, and `unhealthy` detection.
- **Continuous R2 sync (HIGH)** — Added `watchAndPublishSegments()` using `fsnotify` to upload segments in real-time (~7s delay), replacing batch-on-exit pattern.
- **Globally unique segment naming (HIGH)** — Added `initRunID()` generating `r-ISOTIME` IDs and `seg-{seq}.m4s` monotonic naming. Replaced `strftime` collision-prone pattern.
- **Atomic manifest publication (MEDIUM)** — Added `publishManifest()` uploading to `playlist.m3u8.tmp` then `CopyObject` to final key.
- **Manifest Cache-Control contract (LOW)** — Updated to `public, max-age=1, s-maxage=2, must-revalidate`.
- **Player error recovery (MEDIUM)** — Added `hls.on(ERROR)` handler with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay with spinner.
- **Health endpoint detail (LOW)** — Enriched `/health` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`, and `unhealthy` detection.
- [ ] CORS on `media.linearvoid.tv` — Remaining infrastructure-only (Cloudflare R2 CORS configuration, not code).
**Files changed:** `backend/playout/src/playlist.go` (+171/-35), `playlist_test.go` (+51/-7), `go.mod` (+2), `frontend/src/main.ts` (+76/-2), `main.test.ts` (+43/-1), `style.css` (+37), `index.html` (+5).
@@ -990,11 +990,100 @@ Completed 6 of 7 streaming pipeline gaps from STREAMING.md contract audit:
Completed Phase 2 (viewer) and Phase 3 (admin) responsive/accessibility tests + coverage:
- [x] **Viewer accessibility (5 tests):** video controls/autoplay/playsinline attributes, chat panel aria-labels (live region + panel label), chat toggle aria-expanded state, message input label association, reconnecting overlay aria-live=assertive + role=alert.
- [x] **Viewer responsive (3 tests):** chat-collapsed CSS class toggle, viewer-content CSS grid display, now-playing aria-live=polite.
- [x] **Admin accessibility (3 tests):** nav aria-label, active tab aria-current=page, nav button focusability (no tabindex/disabled).
- [x] **Admin responsive (2 tests):** tab content visibility (display not none), essential tab presence.
- [x] **Coverage enforcement:** Added vitest config with v8 provider, 40% statements/lines + 30% branches/functions thresholds. Added `test:coverage` script and CI step.
- **Viewer accessibility (5 tests):** video controls/autoplay/playsinline attributes, chat panel aria-labels (live region + panel label), chat toggle aria-expanded state, message input label association, reconnecting overlay aria-live=assertive + role=alert.
- **Viewer responsive (3 tests):** chat-collapsed CSS class toggle, viewer-content CSS grid display, now-playing aria-live=polite.
- **Admin accessibility (3 tests):** nav aria-label, active tab aria-current=page, nav button focusability (no tabindex/disabled).
- **Admin responsive (2 tests):** tab content visibility (display not none), essential tab presence.
- **Coverage enforcement:** Added vitest config with v8 provider, 40% statements/lines + 30% branches/functions thresholds. Added `test:coverage` script and CI step.
**Files:** `frontend/src/main.test.ts` (+65), `frontend/src/admin.test.ts` (+55), `frontend/vitest.config.ts` (+12), `frontend/package.json` (+1), `.gitea/workflows/ci.yml` (+3).
**Total:** 44 frontend tests (31 existing + 13 new).
## Session 2026-08-13 — Plan consolidation (TODO → DONE)
Completed tasks moved out of `.github/instructions/TODO.md`. Remaining work replanned in TODO from `BACKLOG.md`.
### Phase 1 — playout test fixtures + source-media contract
- Create small compliant fixture media in `backend/playout/testdata/` for the validator and playout tests
- Create invalid fixture media for codec, framerate, and audio rejection paths
- Create playlist fixtures for empty, comment-only, mixed-validity, and valid playlists
- Document the ffmpeg fixture-generation commands in the relevant test headers
### Phase 2 — viewer frontend coverage
- HLS player startup, manifest resolution, and cross-origin/local fallback tests
- Chat send/receive, presence updates, private DM flow, and reconnect tests
- Responsive-layout and accessibility checks for the viewer shell and chat sidebar
- Coverage reporting added and minimum threshold enforced in CI
### Phase 3 — admin frontend coverage
- Playlist management, moderation flows, and dashboard rendering tests
- API integration points with mocked backend responses
- Responsive-layout and accessibility checks for admin screens
- Coverage reporting added and minimum threshold enforced in CI
### Phase 4 — R2 and edge-delivery validation
- Integration test for upload and retrieval of HLS objects through the R2 path
- Segment and manifest cache headers verified (immutable media, live manifest freshness)
- Edge cache hit/miss validation scripted (`scripts/validate-edge-cache.sh`)
- Stubs/mocks documented (putObject variable-injection pattern)
### Phase 7 (partial) — load-test scaffolding + saturation alerts
- WebSocket load-test scaffolding and summary artifacts (`ws-chat.yml`, `run-loadtests.ps1`, `summarize-artillery.ps1`)
- Admin API benchmark scaffolding and endpoint summary artifacts (`admin-api.js`, `run-loadtests.ps1`, `summarize-k6.ps1`)
- Saturation thresholds added to monitoring (chat p95 latency + Redis memory usage)
- Cache-hit threshold alert operationalization added to monitoring
### Streaming pipeline gaps (audit → implemented)
- Continuous R2 sync during playout (fsnotify watcher, ~7s delay)
- Globally unique segment naming with run ID (`r-{ISOTIME}/seg-{seq}.m4s`)
- Atomic manifest publication (upload to `.tmp`, then CopyObject)
- Manifest Cache-Control: `public, max-age=1, s-maxage=2, must-revalidate`
- Player error recovery (bounded retries, exponential backoff, reconnecting overlay)
- Health endpoint detail (run ID, active item, playlist revision, segment seq, R2 pub, consecutive failures)
- CORS headers — documented as Cloudflare R2 infrastructure config
- Phase 4 edge cache validation — `scripts/validate-edge-cache.sh` created
## Session 2026-08-13 — Playout Enhancements
Completed the three Playout enhancement workstreams from `TODO.md`. Remaining polish items kept in TODO.
### 1. Automatic normalization of source media on upload (H.264/AAC, 30fps, keyframe check)
- Implement `media.NormalizeVideo` in `backend/chat/src/media/media.go` (ffmpeg exec, Linear Void profile, bounded 2h timeout, stderr capture)
- Write normalized output to `*.normalized.mp4` and re-verify with ffprobe (`ValidateNormalization` + `ProbeDurationSeconds`)
- Extend media status states: `normalizing`, `normalization_failed` (alongside `ready` / `needs_normalization`)
- Integrate into single-upload path — `HandleAdminMedia`: persist `needs_normalization` then `go normalizeMediaRecord(...)`
- Integrate into chunked-upload completion — `HandleAdminMediaUploads` `"complete"`: same async flow after assembly + validation
- Add `POST /api/admin/media/{id}/normalize` (behind `requireAdminAuth`, returns 202 + `normalizing` record)
- Admin UI (`admin.ts`): "Normalize" action + status polling until `ready`/`normalization_failed`
- Tests: `NormalizeVideo` args + ffmpeg-error path; handler `normalizeMediaRecord` success/failure; `/normalize` endpoint 202
- Retire hardcoded `backend/playout/normalize-video.sh` (superseded by in-Go normalization) - file deleted
### 2. R2 data retention: delete old segments after 24h
- Add `cleanupOldSegments(ctx)` to `backend/playout/src/playlist.go`
- Use `s3.ListObjectsV2` on prefix `hls/` to enumerate run directories
- Delete `hls/r-*/*` older than `now - R2_SEGMENT_RETENTION_HOURS` (default 24)
- Never delete `hls/playlist.m3u8` or `hls/playlist.m3u8.tmp`
- Run cleanup as a ticker goroutine (hourly) plus once at startup
- Add `R2_SEGMENT_RETENTION_HOURS` (default `24`) to `.env.example`
- Expose `linearvoid_playout_r2_cleanup_objects_total` metric and log deleted keys
- Tests: mock `ListObjectsV2`/`DeleteObjects` via injectable vars (mirror the existing `putObject` pattern)
### 3. Continuous playout: confirm ffmpeg loops without interruption
- Resolve the dual-loop ambiguity between `main()` and `start-playout.sh`
- Add a `-validate` mode to `main()`: run `ValidatePlaylist` and exit 0/1 without the ffmpeg loop
- Update `start-playout.sh` to invoke `-validate` and `exec` the Go binary (single source of truth)
- Minimize the restart gap: skip full `resetHLSOutput` when the playlist is unchanged
- Track `playlistRevision` (SHA-256 fingerprint); reset only when the playlist changed, on crash/slate recovery, or first run
- Continue segment numbering via `-start_number` + prune stale segments (`hlsNextStartNumber`)
- Verify `init.mp4` + manifest are correctly published on clean completion (existing `publishManifest` path runs after every run)
- Add metrics `linearvoid_playout_loops_total` (clean completions) and `linearvoid_playout_run_duration_seconds`
- Tests: `TestPlaylistFingerprint`, `TestHLSNextStartNumber`