chore(release): 1.1.0
- playout: run-scoped R2 manifest URIs (candidate a rewrite via rewriteManifestToRun) - tests: playlist_rewrite_test.go (rewrite + publish integration + slate guard) - docs: STREAMING, cloudflare-workers-setup, architecture (incl. Level-2 diagram) - release housekeeping: TODO->DONE, CHANGELOG 1.1.0, version bump
This commit is contained in:
1 parent
8c5e70959b
commit
d8cf853842
13 files changed
+526
-164
No files matched your search
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
> Completed tasks from [TODO](./TODO.md). Product, feature, and release history lives in [CHANGELOG](./CHANGELOG.md); this file keeps only engineering and ops notes not captured there.
|
> Completed tasks from [TODO](./TODO.md). Product, feature, and release history lives in [CHANGELOG](./CHANGELOG.md); this file keeps only engineering and ops notes not captured there.
|
||||||
|
|
||||||
|
## Manifest run-scoped rewrite — candidate (a) (2026-09-03)
|
||||||
|
|
||||||
|
- Playout now publishes the live R2 manifest with run-scoped media URIs. `rewriteManifestToRun` in `backend/playout/src/playlist.go` prefixes each bare relative URI (`seg-*.m4s` / `init.mp4`, plus `#EXT-X-MAP:URI`) with `r-{runID}/` before `publishManifest`'s atomic `.tmp` → `CopyObject` commit; skipped in slate mode. Added a stubbable `copyObject` hook for tests (consistent with `putObject`/`listObjects`/`deleteObjects`).
|
||||||
|
- Tests: `playlist_rewrite_test.go` — `TestRewriteManifestToRun`, `TestRewriteManifestToRun_AlreadyPrefixedUnchanged`, `TestRewriteManifestToRun_HandlesTSAndComments`, `TestRewriteManifestToRun_EmptyRunID`, `TestIsMediaURI`, `TestPublishManifest_RewritesToRunScopedURIs`, `TestPublishManifest_SlateNotRewritten`.
|
||||||
|
- Docs updated: `STREAMING.md`, `operations/cloudflare-workers-setup.md`, `architecture/README.md` (incl. Level-2 diagram showing the R2-bound `linearvoid-media` Worker and run-scoped layout).
|
||||||
|
- **Still pending (in TODO): live Validation** — deploy the updated playout to the test lab and confirm manifest/segment URIs resolve via `media.linearvoid.tv`.
|
||||||
|
|
||||||
## Test infrastructure & refactors (2026-08-07)
|
## Test infrastructure & refactors (2026-08-07)
|
||||||
|
|
||||||
- Chat service `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics); `db.RedisClient` + `db.PubSub` interfaces added for mocking.
|
- Chat service `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics); `db.RedisClient` + `db.PubSub` interfaces added for mocking.
|
||||||
@@ -37,3 +44,35 @@
|
|||||||
- Test in Firefox: live playback, rebuffer/recovery, chat sync; record quirks in `docs/STREAMING.md`
|
- Test in Firefox: live playback, rebuffer/recovery, chat sync; record quirks in `docs/STREAMING.md`
|
||||||
- Rebuild + run playout; confirm `playlist.m3u8` lists ~12 segments at 2s each
|
- Rebuild + run playout; confirm `playlist.m3u8` lists ~12 segments at 2s each
|
||||||
- Manual: verify viewer latency and cache-hit behavior after the change
|
- Manual: verify viewer latency and cache-hit behavior after the change
|
||||||
|
|
||||||
|
## Chat sidebar (2026-08-17)
|
||||||
|
|
||||||
|
- [x] remove search bar from chat sidebar (not needed for single-room)
|
||||||
|
- [x] remove "user" toggle button from chat sidebar (hide for now, add back when user interface is stable)
|
||||||
|
- [x] make login/registration form a modal and only toggle it when user is not logged in and clicks "Login" or "Register"
|
||||||
|
- [x] when user is logged in, do not show login/registration form
|
||||||
|
- [x] make sure each message is only rendered once in the chat log (no duplicates on reconnect or initial load)
|
||||||
|
|
||||||
|
### Plan
|
||||||
|
|
||||||
|
Files: `frontend/index.html`, `frontend/src/main.ts`, `frontend/src/style.css`.
|
||||||
|
|
||||||
|
1. **Remove search bar** (single-room)
|
||||||
|
- `index.html`: delete the `<div id="dm-search-row">` block.
|
||||||
|
- `main.ts`: remove `searchDM()` and the `dm-search-btn` listener; drop the `#dm-search-row` show/hide logic in `switchRoom()` (the `searchRow.hidden = !isDM` lines). Keep `currentDMTarget` only if the "New DM" flow stays.
|
||||||
|
|
||||||
|
2. **Remove "Users" toggle button**
|
||||||
|
- `index.html`: delete the `<button id="users-toggle-btn">` button.
|
||||||
|
- `main.ts`: remove `toggleUserList()` and the `users-toggle-btn` listener; drop the now-unused `usersVisible` variable.
|
||||||
|
|
||||||
|
3. **Login/registration form as a modal**
|
||||||
|
- `index.html`: wrap the `#auth-panel` form in a modal structure (`.auth-modal` + backdrop) with a close button.
|
||||||
|
- `style.css`: add `.auth-modal` / `.auth-modal-backdrop` styles.
|
||||||
|
- `main.ts`: add `openAuthModal()` / `closeAuthModal()`; change the `auth-toggle-btn` handler to open the modal (only when not authenticated) instead of toggling `#auth-panel` inline; wire a close/backdrop-click to `closeAuthModal()`.
|
||||||
|
|
||||||
|
4. **Hide form when logged in**
|
||||||
|
- `main.ts` `updateAuthUI()`: when `authenticated` → hide the "Sign in" button and call `closeAuthModal()`; show the user label + "Sign out" button. When not authenticated → show "Sign in" and hide "Sign out".
|
||||||
|
|
||||||
|
5. **No duplicate messages**
|
||||||
|
- `main.ts`: add `clearMessages()` (empties `#messages`) and call it at the top of `connectWebSocket()` so each (re)connect renders history fresh instead of appending.
|
||||||
|
- Fix the initial double-connect: make the initial `connectWebSocket("main")` wait for `refreshUserSession()` (or connect once with the resolved username) so history isn't pushed twice on load.
|
||||||
@@ -1,57 +1,39 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
||||||
|
|
||||||
## Chat sidebar
|
## Playout / R2 manifest–segment layout mismatch
|
||||||
|
|
||||||
- [x] remove search bar from chat sidebar (not needed for single-room)
|
**Chosen approach: candidate (a)** — rewrite the live manifest so its media URIs point into the current run-scoped folder before the atomic R2 publish. Go change in `backend/playout/src/playlist.go`. The `linearvoid-media` Worker stays a faithful path→R2 mapper (no serve-time rewrite).
|
||||||
- [x] remove "user" toggle button from chat sidebar (hide for now, add back when user interface is stable)
|
|
||||||
- [x] make login/registration form a modal and only toggle it when user is not logged in and clicks "Login" or "Register"
|
### Remaining — Validation
|
||||||
- [x] when user is logged in, do not show login/registration form
|
|
||||||
- [x] make sure each message is only rendered once in the chat log (no duplicates on reconnect or initial load)
|
- [ ] Run playout (local or test lab), then:
|
||||||
|
- `curl https://media.linearvoid.tv/hls/playlist.m3u8` → 200, short TTL.
|
||||||
### Plan
|
- Every URI in the manifest resolves `200` with `Cache-Control: public, max-age=31536000, immutable` (via the `linearvoid-media` Worker).
|
||||||
|
- Confirm manifest refresh + `CF-Cache-Status` progression and no 404 on segment fetch across a run loop.
|
||||||
Files: `frontend/index.html`, `frontend/src/main.ts`, `frontend/src/style.css`.
|
- [ ] Verify slate fallback still serves correctly after the rewrite change.
|
||||||
|
|
||||||
1. **Remove search bar** (single-room)
|
> Implementation (candidate a), Go unit tests, and documentation are complete — see [DONE.md](./DONE.md).
|
||||||
- `index.html`: delete the `<div id="dm-search-row">` block.
|
|
||||||
- `main.ts`: remove `searchDM()` and the `dm-search-btn` listener; drop the `#dm-search-row` show/hide logic in `switchRoom()` (the `searchRow.hidden = !isDM` lines). Keep `currentDMTarget` only if the "New DM" flow stays.
|
## Frontend Tests
|
||||||
|
|
||||||
2. **Remove "Users" toggle button**
|
### User Frontend
|
||||||
- `index.html`: delete the `<button id="users-toggle-btn">` button.
|
|
||||||
- `main.ts`: remove `toggleUserList()` and the `users-toggle-btn` listener; drop the now-unused `usersVisible` variable.
|
- [ ] Test coverage for all critical frontend components and interactions
|
||||||
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
3. **Login/registration form as a modal**
|
- [ ] Document test cases and expected outcomes
|
||||||
- `index.html`: wrap the `#auth-panel` form in a modal structure (`.auth-modal` + backdrop) with a close button.
|
|
||||||
- `style.css`: add `.auth-modal` / `.auth-modal-backdrop` styles.
|
### Admin Frontend
|
||||||
- `main.ts`: add `openAuthModal()` / `closeAuthModal()`; change the `auth-toggle-btn` handler to open the modal (only when not authenticated) instead of toggling `#auth-panel` inline; wire a close/backdrop-click to `closeAuthModal()`.
|
|
||||||
|
- [ ] Test coverage for all critical admin UI components and interactions
|
||||||
4. **Hide form when logged in**
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
- `main.ts` `updateAuthUI()`: when `authenticated` → hide the "Sign in" button and call `closeAuthModal()`; show the user label + "Sign out" button. When not authenticated → show "Sign in" and hide "Sign out".
|
- [ ] Document test cases and expected outcomes
|
||||||
|
|
||||||
5. **No duplicate messages**
|
## Backend Integration Tests
|
||||||
- `main.ts`: add `clearMessages()` (empties `#messages`) and call it at the top of `connectWebSocket()` so each (re)connect renders history fresh instead of appending.
|
|
||||||
- Fix the initial double-connect: make the initial `connectWebSocket("main")` wait for `refreshUserSession()` (or connect once with the resolved username) so history isn't pushed twice on load.
|
### Cloudflare R2
|
||||||
|
|
||||||
## Frontend Tests
|
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||||
|
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||||
### User Frontend
|
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
|
||||||
|
|
||||||
- [ ] Test coverage for all critical frontend components and interactions
|
|
||||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
|
||||||
- [ ] Document test cases and expected outcomes
|
|
||||||
|
|
||||||
### Admin Frontend
|
|
||||||
|
|
||||||
- [ ] Test coverage for all critical admin UI components and interactions
|
|
||||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
|
||||||
- [ ] Document test cases and expected outcomes
|
|
||||||
|
|
||||||
## Backend Integration Tests
|
|
||||||
|
|
||||||
### Cloudflare R2
|
|
||||||
|
|
||||||
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
|
||||||
- [ ] Validate cache hit/miss behavior and performance metrics
|
|
||||||
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
|
|
||||||
+17
-25
@@ -2,43 +2,35 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.1.0] - 2026-09-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Cloudflare media edge worker (`linearvoid-media`)**: New self-contained project under `infrastructure/cloudflare/` that owns `media.linearvoid.tv` and serves HLS objects from the `linearvoid-media` R2 bucket via an `r2_buckets` binding. Enforces immutable `Cache-Control` for segments (`max-age=31536000, immutable`) and a short TTL for live `.m3u8`, with HLS CORS, OPTIONS preflight, Range support, and a `/health` probe. Replaces the ad-hoc `worker.js`. Includes `wrangler.toml` (default preview + `production` env attaching the media custom domain), Vitest unit tests, Miniflare/workerd R2 integration tests, and automated production deploy via `.gitea/workflows/workers-media-deploy.yml`.
|
- **Cloudflare media edge worker (`linearvoid-media`)**: new self-contained project under `infrastructure/cloudflare/` — an R2-bound Worker owning `media.linearvoid.tv` that enforces immutable segment caching (`public, max-age=31536000, immutable`) and short-TTL live `.m3u8`, with HLS CORS/OPTIONS, byte-Range, and a `/health` probe. Includes Vitest unit tests + Miniflare/workerd R2 integration tests and production deploy via `.gitea/workflows/workers-media-deploy.yml`. Replaces the ad-hoc `worker.js`.
|
||||||
- **Bash equivalents of Windows scripts**: Added `scripts/cache-hit-ratio.sh`, `scripts/check-env.sh`, `scripts/check-tokens.sh`, and `scripts/loadtest/run-loadtests.sh`, `scripts/loadtest/summarize-artillery.sh`, `scripts/loadtest/summarize-k6.sh` — Linux/macOS ports of the existing PowerShell scripts (the `.ps1` files are retained).
|
- **Bash script ports**: Linux/macOS equivalents of the Windows ops/load-test scripts (`cache-hit-ratio`, `check-env`, `check-tokens`, loadtest run/summarize); `.ps1` files retained.
|
||||||
- **Transient presence flash**: Join/leave announcements now appear as a 3-second flash at the top of the chat panel (`#chat-flash`) with a fade-out, instead of permanent chat rows. Presence indicators still update from the same events.
|
- **Transient presence flash**: join/leave events show as a 3-second `#chat-flash` instead of permanent chat rows.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Cloudflare worker docs updated**: README, SETUP, architecture, and Cloudflare ops guides now describe the `linearvoid-media` edge worker (R2-bound on `media.linearvoid.tv`) and the `linearvoid-frontend` SPA worker, replacing stale `worker.js` references and the R2 custom-domain wording for the media host.
|
- **Cloudflare Workers hosting**: frontend moved from Cloudflare Pages to the `linearvoid-frontend` static-assets Worker; docs now describe `linearvoid-frontend` (SPA) and `linearvoid-media` (edge HLS), correcting stale `worker.js` and R2 custom-domain wording.
|
||||||
- **Coolify host-port de-collision**: Published playout on host port `8081` (`${PLAYOUT_PORT:-8081}:8081`) and moved the nginx host port from `8080`/`80` to a free `8082` (`${NGINX_PORT:-8082}:80`) in both `docker-compose.yml` and `docker-compose.prod.yml`, so no service binds ports reserved by the Coolify environment (Traefik proxy `80`/`443`, dashboard/Portainer `8000`, and the taken `8080`). `NGINX_PORT` default in `.env.example` updated from `80` to `8082`.
|
- **Docs de-Windows'd & condensed**: bash-first guides for `SETUP`, `DEPLOYMENT`, `API`, `MONITORING`, `STREAMING`, `COOLIFY`, incident runbook, scaling playbook, architecture README, and Prometheus metrics inventory.
|
||||||
|
- **Compose port de-collision**: playout on host port `8081`, nginx moved to `8082` (`NGINX_PORT` default updated) to avoid Coolify-reserved ports.
|
||||||
- **Docs condensed and de-Windows'd**: Removed Windows/PowerShell-specific instructions from the docs and condensed the longest documents into tighter, bash-first guides — `SETUP.md`, `DEPLOYMENT.md`, `API.md`, `MONITORING.md`, `STREAMING.md`, `COOLIFY.md`, `incident-runbook.md`, `operations/scaling-performance-playbook.md`, `architecture/README.md`, and the Prometheus metrics inventory. `scripts/loadtest/README.md` now documents the bash load-test helpers.
|
- **Playout**: R2 retention now by run count + byte cap (`R2_KEEP_RUNS` default 2, `R2_MAX_RETENTION_BYTES` default 10 GiB, new gauges) replacing the 24h time-based cleanup; default `HLS_SEGMENT_DURATION` 2s / `HLS_WINDOW_SIZE` 12; deduplicated, concurrent startup validation.
|
||||||
- **System messages no longer persisted**: The chat backend no longer stores `system`-type messages; chat history and the admin chat-log table only contain real chat messages (moderation actions remain in the audit log).
|
- **Chat/UX**: 5s reconnect grace window (no join/leave spam); `system` messages no longer persisted; smaller `.message-text`; sidebar cleanup (removed DM search + Users toggle, login/register now a modal).
|
||||||
- **Reconnect grace period**: Disconnects schedule a delayed "left" announcement (5s); a reconnect within 5s cancels it and skips "joined", eliminating join/leave spam on brief reconnects.
|
- **Auth**: viewer session cookie uses `SameSite=None; Secure` over HTTPS (cross-site SPA) and `Lax` over plain HTTP.
|
||||||
- **HLS segment duration 2s**: Default `HLS_SEGMENT_DURATION` changed from 6 to 2 and `HLS_WINDOW_SIZE` from 24 to 12 (2s × 12 = 24s live window), reducing end-to-end latency; the 2s GOP was already in place.
|
|
||||||
- **Chat font sizing**: `.message-text` set to `0.95rem` (slightly smaller than the default, larger than the `0.83rem` system/flash text).
|
|
||||||
- **R2 retention by run count + size**: Playout now keeps the newest `R2_KEEP_RUNS` run folders (default 2) and enforces an `R2_MAX_RETENTION_BYTES` cap (default 10 GiB), replacing the old 24h time-based `R2_SEGMENT_RETENTION_HOURS` cleanup. Cleanup runs after each new run plus an hourly backstop, and never deletes the live manifest. Adds `linearvoid_playout_r2_runs_retained` and `linearvoid_playout_r2_bytes_retained` gauges.
|
|
||||||
- **Cloudflare Workers frontend hosting**: Replaced Cloudflare Pages with a Cloudflare Worker (`linearvoid-frontend`) that serves the static SPA via Workers Static Assets. Added `frontend/wrangler.toml` and renamed the deploy workflow to `.gitea/workflows/workers-deploy.yml` (builds the SPA and runs `npx wrangler deploy`). Architecture, setup/deployment, and ops documentation updated from Pages to Workers.
|
|
||||||
- **Viewer session cookie cross-site**: The `linearvoid_user_session` cookie now uses `SameSite=None; Secure` over HTTPS (so it is sent cross-site from the Workers-served SPA) and `SameSite=Lax` over plain HTTP (local dev). The admin session cookie remains `SameSite=Strict`.
|
|
||||||
- **Parallel playout startup validation**: `ValidatePlaylist` now deduplicates repeated paths and probes unique files concurrently through a bounded worker pool (`PLAYOUT_VALIDATE_WORKERS`, default `runtime.NumCPU()`); `ValidateVideo` runs its stream/format and keyframe `ffprobe` probes concurrently; and `main()` performs the strict validation once (removing the redundant `-validate` pre-step in `start-playout.sh`). This sharply cuts startup time on large or duplicated playlists.
|
|
||||||
- **Chat sidebar cleanup**: Removed the DM search bar and the "Users" toggle button, and converted the login/register form into a modal that opens from "Sign in" only when signed out and is hidden once logged in.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Login/register cross-origin API URLs**: `submitAuth` in `frontend/src/main.ts` now prefixes `/api/auth/login` and `/api/auth/register` with `VITE_PUBLIC_APP_URL` (falling back to same-origin), matching the other API calls so the Workers-served SPA reaches the app origin instead of a same-origin path.
|
- **Run-scoped R2 manifest URIs (playout)**: `publishManifest` now rewrites bare relative media URIs to the current run folder (`r-{runID}/…`) via `rewriteManifestToRun` before the atomic `.tmp` → `CopyObject` publish, so the live `hls/playlist.m3u8` references resolve against the run-scoped segment keys on `media.linearvoid.tv`. Skipped in slate mode. (Live validation pending — see `TODO.md`.)
|
||||||
- **Duplicate chat history on reconnect/initial load**: `connectWebSocket` now clears `#messages` before each (re)connect, and the initial connect waits for session resolution, so room history renders once instead of being appended repeatedly.
|
- **Cross-origin auth**: login/register API calls now prefix `VITE_PUBLIC_APP_URL` so the Workers-served SPA reaches the app origin.
|
||||||
|
- **Duplicate chat history**: `connectWebSocket` clears messages on each (re)connect and the initial connect waits for session resolution.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- WebSocket: `TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`.
|
- WebSocket (`TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`), handlers (`TestHandleAdminChatLog_ExcludesSystem`), frontend presence flash.
|
||||||
- Handlers: `TestHandleAdminChatLog_ExcludesSystem`.
|
- Playout: `TestCleanupOldRuns(_SizeCap)`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`, `TestValidateWorkersConfig`, `TestValidatePlaylist_Dedup`.
|
||||||
- Frontend: `presence flash` test.
|
- Playout (run-scoped manifest rewrite): `TestRewriteManifestToRun`, `TestRewriteManifestToRun_AlreadyPrefixedUnchanged`, `TestRewriteManifestToRun_HandlesTSAndComments`, `TestRewriteManifestToRun_EmptyRunID`, `TestIsMediaURI`, `TestPublishManifest_RewritesToRunScopedURIs`, `TestPublishManifest_SlateNotRewritten`.
|
||||||
- Playout: `TestCleanupOldRuns`, `TestCleanupOldRuns_SizeCap`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`.
|
- Cloudflare media worker: Vitest unit (`cacheControlForPath`, `contentTypeForPath`, `parseByteRange`) + Miniflare R2 integration (immutable segment, short-TTL manifest, Range `206`, `404`, OPTIONS, method guard).
|
||||||
- Playout: `TestValidateWorkersConfig`, `TestValidatePlaylist_Dedup`.
|
|
||||||
- Cloudflare media worker: Vitest unit tests (`cacheControlForPath`, `contentTypeForPath`, `parseByteRange`) and Miniflare R2 integration tests (immutable segment, short-TTL manifest, Range `206`, `404`, OPTIONS CORS, method guard).
|
|
||||||
|
|
||||||
## [1.0.13] - 2026-08-13
|
## [1.0.13] - 2026-08-13
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ var (
|
|||||||
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
return client.DeleteObjects(context.TODO(), input)
|
return client.DeleteObjects(context.TODO(), input)
|
||||||
}
|
}
|
||||||
|
copyObject = func(client *s3.Client, input *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
return client.CopyObject(context.TODO(), input)
|
||||||
|
}
|
||||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||||
playoutMode = "starting" // "live", "slate", "degraded"
|
playoutMode = "starting" // "live", "slate", "degraded"
|
||||||
restartCount = 0
|
restartCount = 0
|
||||||
@@ -185,6 +188,8 @@ func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
|
// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
|
||||||
|
// Bare media URIs in the manifest are rewritten to the run-scoped R2 layout
|
||||||
|
// (r-{runID}/…) so that every referenced segment/init resolves on the media host.
|
||||||
func publishManifest(localManifestPath string) error {
|
func publishManifest(localManifestPath string) error {
|
||||||
if r2Client == nil {
|
if r2Client == nil {
|
||||||
if err := initR2Client(); err != nil {
|
if err := initR2Client(); err != nil {
|
||||||
@@ -192,13 +197,33 @@ func publishManifest(localManifestPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(localManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read manifest failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite bare relative media URIs to the current run folder so the manifest
|
||||||
|
// matches the run-scoped segment keys uploaded by watchAndPublishSegments /
|
||||||
|
// syncLatestToR2 (candidate (a)). Slate mode is skipped: its manifest references
|
||||||
|
// slate assets that are not published under the live run prefix.
|
||||||
|
if playoutMode != "slate" {
|
||||||
|
data = rewriteManifestToRun(runID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage the (rewritten) manifest locally so the existing SyncToR2 path is reused.
|
||||||
|
staging := localManifestPath + ".r2staging"
|
||||||
|
if err := os.WriteFile(staging, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write staging manifest failed: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(staging)
|
||||||
|
|
||||||
// 1. Upload to temp key
|
// 1. Upload to temp key
|
||||||
if err := SyncToR2(localManifestPath, "hls/playlist.m3u8.tmp"); err != nil {
|
if err := SyncToR2(staging, "hls/playlist.m3u8.tmp"); err != nil {
|
||||||
return fmt.Errorf("temp manifest upload failed: %w", err)
|
return fmt.Errorf("temp manifest upload failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Atomic copy to final key
|
// 2. Atomic copy to final key
|
||||||
_, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
_, err = copyObject(r2Client, &s3.CopyObjectInput{
|
||||||
Bucket: aws.String(bucket),
|
Bucket: aws.String(bucket),
|
||||||
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
|
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
|
||||||
Key: aws.String("hls/playlist.m3u8"),
|
Key: aws.String("hls/playlist.m3u8"),
|
||||||
@@ -210,6 +235,55 @@ func publishManifest(localManifestPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isMediaURI reports whether s names an HLS media file (a segment or init file).
|
||||||
|
func isMediaURI(s string) bool {
|
||||||
|
name := s
|
||||||
|
if i := strings.IndexByte(name, '?'); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
switch strings.ToLower(filepath.Ext(name)) {
|
||||||
|
case ".m4s", ".ts", ".mp4", ".m4a":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteManifestToRun prefixes bare relative media URIs in an HLS manifest with the
|
||||||
|
// run folder, e.g. "seg-000001.m4s" -> "r-20260903T…/seg-000001.m4s" and
|
||||||
|
// #EXT-X-MAP:URI="init.mp4" -> URI="r-20260903T…/init.mp4". This keeps the published
|
||||||
|
// manifest consistent with the run-scoped R2 layout (STREAMING.md contract). Directives,
|
||||||
|
// comments, blank lines, and URIs that already contain a path are left unchanged.
|
||||||
|
// Returns data unchanged when runID is empty.
|
||||||
|
func rewriteManifestToRun(runID string, data []byte) []byte {
|
||||||
|
if runID == "" {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
prefix := runID + "/"
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(trimmed, "#EXT-X-MAP:URI="):
|
||||||
|
if open := strings.Index(trimmed, `URI="`); open >= 0 {
|
||||||
|
rest := trimmed[open+len(`URI="`):]
|
||||||
|
if closeIdx := strings.IndexByte(rest, '"'); closeIdx > 0 {
|
||||||
|
uri := rest[:closeIdx]
|
||||||
|
if uri != "" && !strings.Contains(uri, "/") {
|
||||||
|
line = strings.Replace(line, `URI="`+uri+`"`, `URI="`+prefix+uri+`"`, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
case trimmed != "" && !strings.HasPrefix(trimmed, "#") && !strings.Contains(trimmed, "/") && isMediaURI(trimmed):
|
||||||
|
out = append(out, prefix+trimmed)
|
||||||
|
default:
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(out, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
|
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
|
||||||
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||||
file, err := os.Open(playlistPath)
|
file, err := os.Open(playlistPath)
|
||||||
@@ -1218,4 +1292,4 @@ func loadSharedHLSConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize)
|
fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleManifest = `#EXTM3U
|
||||||
|
#EXT-X-VERSION:7
|
||||||
|
#EXT-X-TARGETDURATION:2
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXT-X-INDEPENDENT-SEGMENTS
|
||||||
|
#EXT-X-MAP:URI="init.mp4"
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
seg-000000.m4s
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
seg-000001.m4s
|
||||||
|
#EXT-X-ENDLIST
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun(t *testing.T) {
|
||||||
|
runID := "r-20260903T120000Z"
|
||||||
|
out := string(rewriteManifestToRun(runID, []byte(sampleManifest)))
|
||||||
|
|
||||||
|
wants := []string{
|
||||||
|
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||||
|
"r-20260903T120000Z/seg-000000.m4s",
|
||||||
|
"r-20260903T120000Z/seg-000001.m4s",
|
||||||
|
"#EXTM3U",
|
||||||
|
"#EXT-X-VERSION:7",
|
||||||
|
"#EXT-X-TARGETDURATION:2",
|
||||||
|
"#EXT-X-MEDIA-SEQUENCE:0",
|
||||||
|
"#EXT-X-INDEPENDENT-SEGMENTS",
|
||||||
|
"#EXT-X-ENDLIST",
|
||||||
|
"#EXTINF:2.000000,",
|
||||||
|
}
|
||||||
|
for _, want := range wants {
|
||||||
|
if !contains(out, want) {
|
||||||
|
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media URIs must be prefixed (no bare segments left).
|
||||||
|
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n", `URI="init.mp4"`} {
|
||||||
|
if contains(out, bare) {
|
||||||
|
t.Errorf("output still contains bare %q\noutput:\n%s", bare, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_AlreadyPrefixedUnchanged(t *testing.T) {
|
||||||
|
manifest := "#EXTM3U\n#EXT-X-MAP:URI=\"r-old/init.mp4\"\nseg-000000.m4s\nr-old/seg-000001.m4s\n#EXT-X-ENDLIST\n"
|
||||||
|
out := string(rewriteManifestToRun("r-new", []byte(manifest)))
|
||||||
|
|
||||||
|
if !contains(out, `#EXT-X-MAP:URI="r-old/init.mp4"`) {
|
||||||
|
t.Errorf("EXT-X-MAP already-prefixed URI was rewritten: %s", out)
|
||||||
|
}
|
||||||
|
// A URI line that already contains a path is left alone.
|
||||||
|
if !contains(out, "r-old/seg-000001.m4s") {
|
||||||
|
t.Errorf("already-prefixed segment URI was changed:\n%s", out)
|
||||||
|
}
|
||||||
|
// Bare URI still gets the run prefix.
|
||||||
|
if !contains(out, "r-new/seg-000000.m4s") {
|
||||||
|
t.Errorf("bare segment not prefixed:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_HandlesTSAndComments(t *testing.T) {
|
||||||
|
manifest := "#EXTM3U\n# a comment\nvideo-1.ts\ninit.mp4\n#EXT-X-ENDLIST\n"
|
||||||
|
out := string(rewriteManifestToRun("r-x", []byte(manifest)))
|
||||||
|
|
||||||
|
for _, want := range []string{"r-x/video-1.ts", "r-x/init.mp4", "# a comment", "#EXTM3U", "#EXT-X-ENDLIST"} {
|
||||||
|
if !contains(out, want) {
|
||||||
|
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_EmptyRunID(t *testing.T) {
|
||||||
|
out := rewriteManifestToRun("", []byte(sampleManifest))
|
||||||
|
if string(out) != sampleManifest {
|
||||||
|
t.Errorf("empty runID should return input unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsMediaURI(t *testing.T) {
|
||||||
|
for _, ok := range []string{"seg-000001.m4s", "init.mp4", "a.ts", "x.m4a"} {
|
||||||
|
if !isMediaURI(ok) {
|
||||||
|
t.Errorf("isMediaURI(%q) = false, want true", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notOK := range []string{"playlist.m3u8", "readme.txt", "seg-000001.m4s?x=1"} {
|
||||||
|
// Note: query strings are stripped; '?x=1' on m4s should still be media.
|
||||||
|
if notOK == "seg-000001.m4s?x=1" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isMediaURI(notOK) {
|
||||||
|
t.Errorf("isMediaURI(%q) = true, want false", notOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isMediaURI("seg-000001.m4s?token=abc") {
|
||||||
|
t.Errorf("isMediaURI with query should strip query and be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishManifest_RewritesToRunScopedURIs(t *testing.T) {
|
||||||
|
prevClient, prevBucket := r2Client, bucket
|
||||||
|
prevRunID, prevMode := runID, playoutMode
|
||||||
|
prevPut, prevCopy := putObject, copyObject
|
||||||
|
defer func() {
|
||||||
|
r2Client, bucket = prevClient, prevBucket
|
||||||
|
runID, playoutMode = prevRunID, prevMode
|
||||||
|
putObject, copyObject = prevPut, prevCopy
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
runID = "r-20260903T120000Z"
|
||||||
|
playoutMode = "live"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
var gotKey, gotCopySource, gotCopyKey string
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotKey = aws.ToString(in.Key)
|
||||||
|
b, err := io.ReadAll(in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gotBody = b
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
copyObject = func(_ *s3.Client, in *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
gotCopySource = aws.ToString(in.CopySource)
|
||||||
|
gotCopyKey = aws.ToString(in.Key)
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publishManifest(mp); err != nil {
|
||||||
|
t.Fatalf("publishManifest error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotKey != "hls/playlist.m3u8.tmp" {
|
||||||
|
t.Errorf("temp upload key = %q, want hls/playlist.m3u8.tmp", gotKey)
|
||||||
|
}
|
||||||
|
if gotCopySource != "linearvoid-media/hls/playlist.m3u8.tmp" {
|
||||||
|
t.Errorf("CopySource = %q, want linearvoid-media/hls/playlist.m3u8.tmp", gotCopySource)
|
||||||
|
}
|
||||||
|
if gotCopyKey != "hls/playlist.m3u8" {
|
||||||
|
t.Errorf("final key = %q, want hls/playlist.m3u8", gotCopyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := string(gotBody)
|
||||||
|
for _, want := range []string{
|
||||||
|
"r-20260903T120000Z/seg-000000.m4s",
|
||||||
|
"r-20260903T120000Z/seg-000001.m4s",
|
||||||
|
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||||
|
} {
|
||||||
|
if !contains(body, want) {
|
||||||
|
t.Errorf("published manifest missing %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n"} {
|
||||||
|
if contains(body, bare) {
|
||||||
|
t.Errorf("published manifest still contains bare %q:\n%s", bare, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishManifest_SlateNotRewritten(t *testing.T) {
|
||||||
|
prevClient, prevBucket := r2Client, bucket
|
||||||
|
prevRunID, prevMode := runID, playoutMode
|
||||||
|
prevPut, prevCopy := putObject, copyObject
|
||||||
|
defer func() {
|
||||||
|
r2Client, bucket = prevClient, prevBucket
|
||||||
|
runID, playoutMode = prevRunID, prevMode
|
||||||
|
putObject, copyObject = prevPut, prevCopy
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
runID = "r-20260903T120000Z"
|
||||||
|
playoutMode = "slate"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
b, _ := io.ReadAll(in.Body)
|
||||||
|
gotBody = b
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
copyObject = func(_ *s3.Client, _ *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publishManifest(mp); err != nil {
|
||||||
|
t.Fatalf("publishManifest error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(gotBody) != sampleManifest {
|
||||||
|
t.Errorf("slate manifest should be published unmodified; got:\n%s", string(gotBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+59
-32
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
## Environments
|
## Environments
|
||||||
|
|
||||||
| Environment | Host | Docker Target | Purpose |
|
| Environment | Host | Docker Target | Purpose |
|
||||||
|---|---|---|---|
|
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
|
||||||
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
|
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
|
||||||
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv |
|
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv |
|
||||||
|
|
||||||
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. `cd.yml` and `docker-compose.prod.yml` target the test lab; switching to production only requires changing `DOCKER_HOST`.
|
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. `cd.yml` and `docker-compose.prod.yml` target the test lab; switching to production only requires changing `DOCKER_HOST`.
|
||||||
|
|
||||||
@@ -14,12 +14,14 @@
|
|||||||
**Status: Not provisioned.** Plan documented and pipeline ready; only the VPS needs creation.
|
**Status: Not provisioned.** Plan documented and pipeline ready; only the VPS needs creation.
|
||||||
|
|
||||||
### Planned Infrastructure
|
### Planned Infrastructure
|
||||||
|
|
||||||
- Provider: Hetzner Cloud, Region: Ashburn, VA (us-east)
|
- Provider: Hetzner Cloud, Region: Ashburn, VA (us-east)
|
||||||
- Instance: CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent
|
- Instance: CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent
|
||||||
- OS: Ubuntu 24.04 LTS or Debian 12
|
- OS: Ubuntu 24.04 LTS or Debian 12
|
||||||
- Bandwidth: 20 TB included traffic/month; Estimated cost: ~€7–10/month base
|
- Bandwidth: 20 TB included traffic/month; Estimated cost: ~€7–10/month base
|
||||||
|
|
||||||
### Cutover Plan
|
### Cutover Plan
|
||||||
|
|
||||||
1. Provision Hetzner VPS
|
1. Provision Hetzner VPS
|
||||||
2. Add production secrets to Gitea (`CI_PROD_*` prefix)
|
2. Add production secrets to Gitea (`CI_PROD_*` prefix)
|
||||||
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
|
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
7. Decommission test lab as public target
|
7. Decommission test lab as public target
|
||||||
|
|
||||||
### Production Preflight (minimum readiness gate)
|
### Production Preflight (minimum readiness gate)
|
||||||
|
|
||||||
1. Verify Hetzner host reachable over SSH and Docker remote API TLS works for the Gitea runner
|
1. Verify Hetzner host reachable over SSH and Docker remote API TLS works for the Gitea runner
|
||||||
2. Confirm all production secrets exist under `CI_PROD_*` prefix
|
2. Confirm all production secrets exist under `CI_PROD_*` prefix
|
||||||
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, `media.linearvoid.tv` DNS targets match intended phase
|
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, `media.linearvoid.tv` DNS targets match intended phase
|
||||||
@@ -37,22 +40,24 @@
|
|||||||
6. Verify rollback possible before final DNS switch
|
6. Verify rollback possible before final DNS switch
|
||||||
|
|
||||||
### Firewall / Security Group
|
### Firewall / Security Group
|
||||||
| Port | Source | Purpose |
|
|
||||||
|---|---|---|
|
| Port | Source | Purpose |
|
||||||
| 22 | Admin IPs | SSH management |
|
| ---- | --------------------- | ------------------------------ |
|
||||||
| 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) |
|
| 22 | Admin IPs | SSH management |
|
||||||
| 443 | 0.0.0.0/0 | HTTPS (nginx) |
|
| 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) |
|
||||||
| 2375 | Gitea runner IP only | Docker remote API (TLS) |
|
| 443 | 0.0.0.0/0 | HTTPS (nginx) |
|
||||||
|
| 2375 | Gitea runner IP only | Docker remote API (TLS) |
|
||||||
| 9090 | Admin IPs or internal | Prometheus (or tunnel via SSH) |
|
| 9090 | Admin IPs or internal | Prometheus (or tunnel via SSH) |
|
||||||
| 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) |
|
| 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) |
|
||||||
|
|
||||||
### Pipeline Readiness
|
### Pipeline Readiness
|
||||||
| Workflow | Trigger | Purpose |
|
|
||||||
|---|---|---|
|
| Workflow | Trigger | Purpose |
|
||||||
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push images (`:latest`, `:main`) |
|
| -------------------- | ----------------- | --------------------------------------------------------------------------------- |
|
||||||
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
|
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push images (`:latest`, `:main`) |
|
||||||
| `release.yml` | Tag push `*.*.*` | Build & push versioned images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
|
||||||
| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers |
|
| `release.yml` | Tag push `*.*.*` | Build & push versioned images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
||||||
|
| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers |
|
||||||
|
|
||||||
CI and CD run simultaneously on every push to `main`. Deploy pulls whatever `:latest` images are available at that moment. `cd.yml` uses `DOCKER_HOST` to target the host:
|
CI and CD run simultaneously on every push to `main`. Deploy pulls whatever `:latest` images are available at that moment. `cd.yml` uses `DOCKER_HOST` to target the host:
|
||||||
|
|
||||||
@@ -67,6 +72,7 @@ DOCKER_HOST=tcp://<hetzner-public-ip>:2375
|
|||||||
No pipeline code changes needed to switch targets.
|
No pipeline code changes needed to switch targets.
|
||||||
|
|
||||||
## Target Topology
|
## Target Topology
|
||||||
|
|
||||||
- Host: Linux server with Docker Engine
|
- Host: Linux server with Docker Engine
|
||||||
- Reverse proxy: nginx container (linearvoid-nginx)
|
- Reverse proxy: nginx container (linearvoid-nginx)
|
||||||
- Core services: playout, chat, redis, postgres
|
- Core services: playout, chat, redis, postgres
|
||||||
@@ -74,6 +80,7 @@ No pipeline code changes needed to switch targets.
|
|||||||
- CDN/edge: Cloudflare
|
- CDN/edge: Cloudflare
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Docker Engine + Docker Compose plugin
|
- Docker Engine + Docker Compose plugin
|
||||||
- Network access to git.allucanget.biz
|
- Network access to git.allucanget.biz
|
||||||
- Valid `.env` file with production values
|
- Valid `.env` file with production values
|
||||||
@@ -82,6 +89,7 @@ No pipeline code changes needed to switch targets.
|
|||||||
## Environment File
|
## Environment File
|
||||||
|
|
||||||
`.env` requires at minimum:
|
`.env` requires at minimum:
|
||||||
|
|
||||||
- `DATABASE_URL` — PostgreSQL connection string
|
- `DATABASE_URL` — PostgreSQL connection string
|
||||||
- `REDIS_URL` — Redis connection string
|
- `REDIS_URL` — Redis connection string
|
||||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — admin dashboard credentials
|
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — admin dashboard credentials
|
||||||
@@ -89,18 +97,21 @@ No pipeline code changes needed to switch targets.
|
|||||||
- `VITE_PUBLIC_APP_URL` / `VITE_PUBLIC_WS_URL` / `VITE_PUBLIC_HLS_URL` — frontend URLs
|
- `VITE_PUBLIC_APP_URL` / `VITE_PUBLIC_WS_URL` / `VITE_PUBLIC_HLS_URL` — frontend URLs
|
||||||
|
|
||||||
**Cloudflare R2 (required for HLS segment sync to edge):**
|
**Cloudflare R2 (required for HLS segment sync to edge):**
|
||||||
|
|
||||||
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY`
|
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY`
|
||||||
- `R2_BUCKET_NAME` (e.g. `linearvoid-media`)
|
- `R2_BUCKET_NAME` (e.g. `linearvoid-media`)
|
||||||
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
||||||
- `R2_REGION` — set to `auto`
|
- `R2_REGION` — set to `auto`
|
||||||
|
|
||||||
**Cloudflare (optional, for Workers):**
|
**Cloudflare (optional, for Workers):**
|
||||||
|
|
||||||
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
||||||
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
||||||
|
|
||||||
See `.env.example` and `SETUP.md` for full variable reference.
|
See `.env.example` and `SETUP.md` for full variable reference.
|
||||||
|
|
||||||
### Gitea Secrets
|
### Gitea Secrets
|
||||||
|
|
||||||
`cd.yml` injects sensitive values from Gitea secrets into `.env` at deploy time. Configure in the Gitea repository:
|
`cd.yml` injects sensitive values from Gitea secrets into `.env` at deploy time. Configure in the Gitea repository:
|
||||||
|
|
||||||
`DATABASE_URL`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN_ACCESS_KEY_ID` (optional), `R2_ADMIN_SECRET_ACCESS_KEY` (optional), `R2_ADMIN_ENDPOINT` (optional), `CLOUDFLARE_API_TOKEN` (optional), `CLOUDFLARE_ACCOUNT_ID` (optional), `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`
|
`DATABASE_URL`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN_ACCESS_KEY_ID` (optional), `R2_ADMIN_SECRET_ACCESS_KEY` (optional), `R2_ADMIN_ENDPOINT` (optional), `CLOUDFLARE_API_TOKEN` (optional), `CLOUDFLARE_ACCOUNT_ID` (optional), `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`
|
||||||
@@ -116,6 +127,7 @@ docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
|||||||
```
|
```
|
||||||
|
|
||||||
## First-Time or Recovery Provisioning
|
## First-Time or Recovery Provisioning
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/chat data/playlist data/media data/hls
|
mkdir -p data/chat data/playlist data/media data/hls
|
||||||
docker volume ls
|
docker volume ls
|
||||||
@@ -124,10 +136,13 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Health Verification
|
## Health Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml ps
|
docker compose -f docker-compose.prod.yml ps
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoint checks:
|
Endpoint checks:
|
||||||
|
|
||||||
- App: `http://{host}:8080/`
|
- App: `http://{host}:8080/`
|
||||||
- Nginx health: `http://{host}:8080/health`
|
- Nginx health: `http://{host}:8080/health`
|
||||||
- Chat health: `http://{host}:3000/health`
|
- Chat health: `http://{host}:3000/health`
|
||||||
@@ -136,30 +151,38 @@ Endpoint checks:
|
|||||||
- Grafana: `http://{host}:3001`
|
- Grafana: `http://{host}:3001`
|
||||||
|
|
||||||
Domain checks:
|
Domain checks:
|
||||||
|
|
||||||
- `https://linearvoid.tv`
|
- `https://linearvoid.tv`
|
||||||
- `https://linearvoid.tv/admin`
|
- `https://linearvoid.tv/admin`
|
||||||
- `https://linearvoid.tv/metrics`
|
- `https://linearvoid.tv/metrics`
|
||||||
|
|
||||||
## Zero/Low-Downtime Update Pattern
|
## Zero/Low-Downtime Update Pattern
|
||||||
|
|
||||||
1. Pull newest images.
|
1. Pull newest images.
|
||||||
2. Recreate changed services only:
|
2. Recreate changed services only:
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml pull
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
docker compose -f docker-compose.prod.yml pull
|
||||||
```
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
3. Re-run health checks.
|
3. Re-run health checks.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
If new images are unhealthy:
|
If new images are unhealthy:
|
||||||
|
|
||||||
1. Pin previous tags in `docker-compose.prod.yml`.
|
1. Pin previous tags in `docker-compose.prod.yml`.
|
||||||
2. Pull pinned tags.
|
2. Pull pinned tags.
|
||||||
3. Restart impacted services:
|
3. Restart impacted services:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operational Tasks
|
## Operational Tasks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View logs
|
# View logs
|
||||||
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
||||||
@@ -175,30 +198,34 @@ docker compose -f docker-compose.prod.yml down -v
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Nginx and Proxy Considerations
|
## Nginx and Proxy Considerations
|
||||||
|
|
||||||
- If an external proxy (e.g. Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx (see `infrastructure/nginx/nginx.conf`).
|
- If an external proxy (e.g. Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx (see `infrastructure/nginx/nginx.conf`).
|
||||||
- For resumable admin uploads, ensure `client_max_body_size` supports chunk requests in both proxy layers.
|
- For resumable admin uploads, ensure `client_max_body_size` supports chunk requests in both proxy layers.
|
||||||
|
|
||||||
## Security Baseline
|
## Security Baseline
|
||||||
|
|
||||||
- Use a strong `ADMIN_PASSWORD` and rotate regularly.
|
- Use a strong `ADMIN_PASSWORD` and rotate regularly.
|
||||||
- Keep Docker host access restricted.
|
- Keep Docker host access restricted.
|
||||||
- Prefer HTTPS/WSS through Cloudflare.
|
- Prefer HTTPS/WSS through Cloudflare.
|
||||||
- Restrict exposed ports if only proxy access is needed.
|
- Restrict exposed ports if only proxy access is needed.
|
||||||
|
|
||||||
## Common Deployment Pitfalls
|
## Common Deployment Pitfalls
|
||||||
|
|
||||||
- Missing frontend build artifacts in local-only compose workflows.
|
- Missing frontend build artifacts in local-only compose workflows.
|
||||||
- Wrong registry credentials or missing login.
|
- Wrong registry credentials or missing login.
|
||||||
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
|
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
| Symptom | Check | Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| Service not starting | `docker logs linearvoid-<svc> --tail 50` | Check `.env` vars, volume mounts, port conflicts |
|
|
||||||
| Playout stall / slate mode | `curl http://<host>:8081/health` — mode=slate | Check playlist file, media paths, disk space |
|
|
||||||
| Chat not connecting | `curl http://<host>:3000/health` | Check Redis: `docker exec linearvoid-redis redis-cli ping` |
|
|
||||||
| Admin 401 loop | Cookie not sent | Verify `X-Forwarded-Proto: https`, nginx auth_request headers |
|
|
||||||
| Upload fails (413) | File too large | Check nginx `client_max_body_size` (should be 64m+) |
|
|
||||||
| HLS not playing | `curl http://<host>/hls/playlist.m3u8` | Check playout health, HLS dir permissions |
|
|
||||||
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
|
|
||||||
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
|
|
||||||
|
|
||||||
Additional notes: proxy-layer body size limits can return 413 on upload; health checks may fail before service warmup completes.
|
| Symptom | Check | Fix |
|
||||||
|
| -------------------------- | --------------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| Service not starting | `docker logs linearvoid-<svc> --tail 50` | Check `.env` vars, volume mounts, port conflicts |
|
||||||
|
| Playout stall / slate mode | `curl http://<host>:8081/health` — mode=slate | Check playlist file, media paths, disk space |
|
||||||
|
| Chat not connecting | `curl http://<host>:3000/health` | Check Redis: `docker exec linearvoid-redis redis-cli ping` |
|
||||||
|
| Admin 401 loop | Cookie not sent | Verify `X-Forwarded-Proto: https`, nginx auth_request headers |
|
||||||
|
| Upload fails (413) | File too large | Check nginx `client_max_body_size` (should be 64m+) |
|
||||||
|
| HLS not playing | `curl http://<host>/hls/playlist.m3u8` | Check playout health, HLS dir permissions |
|
||||||
|
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
|
||||||
|
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
|
||||||
|
|
||||||
|
Additional notes: proxy-layer body size limits can return 413 on upload; health checks may fail before service warmup completes.
|
||||||
+44
-34
@@ -26,14 +26,14 @@ Nginx `/hls/` is a local/test fallback, not the production origin. The SPA, admi
|
|||||||
|
|
||||||
Only validated files may enter the production playlist.
|
Only validated files may enter the production playlist.
|
||||||
|
|
||||||
| Property | Required profile |
|
| Property | Required profile |
|
||||||
| ---------- | ------------------------------------------------------------------------ |
|
| ---------- | ----------------------------------------------------------------------- |
|
||||||
| Container | MP4 |
|
| Container | MP4 |
|
||||||
| Video | H.264/AVC, `yuv420p` |
|
| Video | H.264/AVC, `yuv420p` |
|
||||||
| Resolution | 1280x720 or 1920x1080; avoid resolution changes within a playlist block |
|
| Resolution | 1280x720 or 1920x1080; avoid resolution changes within a playlist block |
|
||||||
| Frame rate | Constant 30 fps |
|
| Frame rate | Constant 30 fps |
|
||||||
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
||||||
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
|
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
|
||||||
|
|
||||||
The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or wrong keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID/object key.
|
The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or wrong keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID/object key.
|
||||||
|
|
||||||
@@ -62,14 +62,14 @@ ffmpeg -i input.mov \
|
|||||||
|
|
||||||
The playout service wraps FFmpeg, tracks the active item/revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
|
The playout service wraps FFmpeg, tracks the active item/revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
| -------------- | ---------------------------------------------------------------------------------- |
|
| ---------------- | ------------------------------------------------------------------------ |
|
||||||
| Protocol | Standard live HLS, not LL-HLS |
|
| Protocol | Standard live HLS, not LL-HLS |
|
||||||
| Segment format | fMP4 (`.m4s`) with init segment; `.ts` may be supported during migration |
|
| Segment format | fMP4 (`.m4s`) with init segment; `.ts` may be supported during migration |
|
||||||
| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
|
| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
|
||||||
| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), ~24 seconds |
|
| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), ~24 seconds |
|
||||||
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
||||||
| Expected latency | 15–35 seconds |
|
| Expected latency | 15–35 seconds |
|
||||||
|
|
||||||
Segment boundaries must align with keyframes; timestamps must stay monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new init segment.
|
Segment boundaries must align with keyframes; timestamps must stay monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new init segment.
|
||||||
|
|
||||||
@@ -96,15 +96,25 @@ Order prevents a manifest referencing an unavailable segment:
|
|||||||
|
|
||||||
Never upload an actively written segment or publish the manifest first. Use a single publisher (or fencing/leader election) so two workers can't update the live manifest concurrently.
|
Never upload an actively written segment or publish the manifest first. Use a single publisher (or fencing/leader election) so two workers can't update the live manifest concurrently.
|
||||||
|
|
||||||
|
**Run-scoped manifest URIs.** Segments and the init file are stored run-scoped under `hls/r-{runID}/…`, so the live `hls/playlist.m3u8` must reference them with that run prefix. Before the final commit step, the publisher rewrites each bare relative media URI (segments plus `#EXT-X-MAP:URI`) to `r-{runID}/<name>`. `publishManifest` in `backend/playout/src/playlist.go` does this via `rewriteManifestToRun`, using the same `runID` under which the segments were uploaded; the rewrite is skipped in slate mode (its manifest is served as-is). The published manifest therefore contains, e.g.:
|
||||||
|
|
||||||
|
```m3u8
|
||||||
|
#EXT-X-MAP:URI="r-20260807T170412Z/init.mp4"
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
r-20260807T170412Z/seg-000001234.m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
The `linearvoid-media` Worker on `media.linearvoid.tv` is a faithful path→R2 mapper and serves these run-scoped paths directly; it does **not** rewrite URIs at serve time.
|
||||||
|
|
||||||
`data/hls/` is regenerable staging. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
|
`data/hls/` is regenerable staging. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
|
||||||
|
|
||||||
## Cache contract
|
## Cache contract
|
||||||
|
|
||||||
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
||||||
| -------------------------- | --------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
|
| -------------------------- | --------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
| `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key |
|
| `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key |
|
||||||
| Live `.m3u8` | Cache eligible; edge TTL 2s | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` |
|
| Live `.m3u8` | Cache eligible; edge TTL 2s | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` |
|
||||||
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
||||||
|
|
||||||
Further rules:
|
Further rules:
|
||||||
|
|
||||||
@@ -159,21 +169,21 @@ A release is streaming-ready only when: one full playlist boundary plays without
|
|||||||
|
|
||||||
## Required configuration
|
## Required configuration
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| ------------------------------------------ | -------------------------------------------------- |
|
| ------------------------------------------ | ------------------------------------- |
|
||||||
| `PLAYLIST_PATH` | Active playlist file |
|
| `PLAYLIST_PATH` | Active playlist file |
|
||||||
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `2` |
|
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `2` |
|
||||||
| `HLS_WINDOW_SIZE` | Manifest window; baseline `12` |
|
| `HLS_WINDOW_SIZE` | Manifest window; baseline `12` |
|
||||||
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
||||||
| `R2_BUCKET_NAME` | Media bucket |
|
| `R2_BUCKET_NAME` | Media bucket |
|
||||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, publisher only |
|
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, publisher only |
|
||||||
| `R2_KEEP_RUNS` | Run folders to retain (default `2`) |
|
| `R2_KEEP_RUNS` | Run folders to retain (default `2`) |
|
||||||
| `R2_MAX_RETENTION_BYTES` | Max bytes across run folders (10 GiB) |
|
| `R2_MAX_RETENTION_BYTES` | Max bytes across run folders (10 GiB) |
|
||||||
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
||||||
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
||||||
|
|
||||||
Production values: `MEDIA_DOMAIN=media.linearvoid.tv`, `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
|
Production values: `MEDIA_DOMAIN=media.linearvoid.tv`, `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
|
||||||
|
|
||||||
## Explicitly deferred
|
## Explicitly deferred
|
||||||
|
|
||||||
ABR ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any requires an ADR and an update to this document.
|
ABR ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any requires an ADR and an update to this document.
|
||||||
@@ -156,11 +156,12 @@ graph TD
|
|||||||
graph TB
|
graph TB
|
||||||
subgraph "Cloudflare Edge"
|
subgraph "Cloudflare Edge"
|
||||||
CF[CDN + WAF<br/>Cache Rules]
|
CF[CDN + WAF<br/>Cache Rules]
|
||||||
R2[R2 Bucket<br/>media + HLS]
|
R2["R2 Bucket<br/>media + HLS<br/>hls/r-{runID}/… run-scoped"]
|
||||||
WORKERS[Workers<br/>SPA hosting]
|
MEDIA[linearvoid-media Worker<br/>R2-bound · media.linearvoid.tv]
|
||||||
|
WORKERS[linearvoid-frontend Worker<br/>SPA hosting]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Docker Host (Hetzner CX22)"
|
subgraph "Docker Host"
|
||||||
NGINX[nginx<br/>:80/:443<br/>proxy + auth]
|
NGINX[nginx<br/>:80/:443<br/>proxy + auth]
|
||||||
CHAT[chat service<br/>:3000<br/>Go + gorilla/websocket]
|
CHAT[chat service<br/>:3000<br/>Go + gorilla/websocket]
|
||||||
PLAYOUT[playout service<br/>:8081<br/>Go + FFmpeg]
|
PLAYOUT[playout service<br/>:8081<br/>Go + FFmpeg]
|
||||||
@@ -173,13 +174,14 @@ graph TB
|
|||||||
ADMIN[Admin Browser<br/>SPA + uploads]
|
ADMIN[Admin Browser<br/>SPA + uploads]
|
||||||
end
|
end
|
||||||
|
|
||||||
VIEWER -->|HTTPS| CF
|
VIEWER -->|HTTPS SPA| CF
|
||||||
VIEWER -->|WSS| NGINX
|
VIEWER -->|WSS Chat| NGINX
|
||||||
|
VIEWER -->|HTTPS /hls| MEDIA
|
||||||
ADMIN -->|HTTPS| CF
|
ADMIN -->|HTTPS| CF
|
||||||
CF --> NGINX
|
CF --> NGINX
|
||||||
|
MEDIA --> R2
|
||||||
NGINX --> CHAT
|
NGINX --> CHAT
|
||||||
NGINX --> PLAYOUT
|
NGINX --> PLAYOUT
|
||||||
NGINX -->|/hls/*| R2
|
|
||||||
CHAT --> REDIS
|
CHAT --> REDIS
|
||||||
CHAT --> DB
|
CHAT --> DB
|
||||||
PLAYOUT --> R2
|
PLAYOUT --> R2
|
||||||
@@ -189,7 +191,8 @@ graph TB
|
|||||||
**Playout Service** (`backend/playout/src/playlist.go`)
|
**Playout Service** (`backend/playout/src/playlist.go`)
|
||||||
|
|
||||||
- Role: continuous HLS encoding loop via FFmpeg concat demuxer.
|
- Role: continuous HLS encoding loop via FFmpeg concat demuxer.
|
||||||
- Input: `data/playlist/playlist.txt` (ordered MP4 paths); Output: HLS segments (`.m4s`, `.ts`) + live `.m3u8` to `data/hls/`, mirrored to R2.
|
- Input: `data/playlist/playlist.txt` (ordered MP4 paths); Output: HLS segments (`.m4s`, `.ts`) + live `.m3u8` to `data/hls/`, mirrored to R2 run-scoped under `hls/r-{runID}/…`.
|
||||||
|
- Run-scoped manifest: before publishing the live `hls/playlist.m3u8`, `publishManifest` rewrites bare media URIs to the current run folder (`rewriteManifestToRun`), so manifest → segment links resolve on `media.linearvoid.tv`. See [`STREAMING.md`](../STREAMING.md).
|
||||||
- Resilience: restart counter (>5/60s → slate); Prometheus on 8081.
|
- Resilience: restart counter (>5/60s → slate); Prometheus on 8081.
|
||||||
- Validation: ffprobe gate (codec/fps/resolution/keyframe); `-validate` mode checks playlist without running.
|
- Validation: ffprobe gate (codec/fps/resolution/keyframe); `-validate` mode checks playlist without running.
|
||||||
- Seamless looping: resets HLS only when playlist fingerprint (SHA-256) changes, crash/slate recovery, or first run; else continues via `-start_number`.
|
- Seamless looping: resets HLS only when playlist fingerprint (SHA-256) changes, crash/slate recovery, or first run; else continues via `-start_number`.
|
||||||
@@ -267,7 +270,7 @@ graph TB
|
|||||||
|
|
||||||
1. Admin adds normalized MP4 to R2 + updates playlist.
|
1. Admin adds normalized MP4 to R2 + updates playlist.
|
||||||
2. Playout worker (FFmpeg concat loop) reads next file, packages HLS segments (2s).
|
2. Playout worker (FFmpeg concat loop) reads next file, packages HLS segments (2s).
|
||||||
3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated.
|
3. Segments uploaded to R2 with unique immutable, run-scoped names (`hls/r-{runID}/…`); the live `.m3u8` is published referencing those run-prefixed URIs (see `STREAMING.md`).
|
||||||
4. Viewer loads SPA → requests manifest → hls.js follows current edge.
|
4. Viewer loads SPA → requests manifest → hls.js follows current edge.
|
||||||
5. Cloudflare serves `.m4s`/`.ts` from nearest edge cache (origin hit once per POP/region).
|
5. Cloudflare serves `.m4s`/`.ts` from nearest edge cache (origin hit once per POP/region).
|
||||||
6. Parallel WebSocket to chat service for synchronized interaction.
|
6. Parallel WebSocket to chat service for synchronized interaction.
|
||||||
@@ -400,7 +403,7 @@ _Cache rules baseline_: `.ts`/`.m4s` Cache Everything, long TTL, immutable; `.m3
|
|||||||
|
|
||||||
_Edge security/controls_: enforce HTTPS/WSS + redirect HTTP; WAF baseline; rate limits on abusive patterns (auth/admin); minimize origin exposure.
|
_Edge security/controls_: enforce HTTPS/WSS + redirect HTTP; WAF baseline; rate limits on abusive patterns (auth/admin); minimize origin exposure.
|
||||||
|
|
||||||
_Worker & header policy_: the `linearvoid-media` Worker ([`infrastructure/cloudflare/`](../../infrastructure/cloudflare/), R2-bound) owns `media.linearvoid.tv` and enforces immutable caching for segments, short TTL for live manifests, plus HLS CORS/Range; the `linearvoid-frontend` Worker ([`frontend/wrangler.toml`](../../frontend/wrangler.toml)) serves the static SPA on `linearvoid.tv`. Automated deploys: `workers-deploy.yml` (SPA) and `workers-media-deploy.yml` (media).
|
_Worker & header policy_: the `linearvoid-media` Worker ([`infrastructure/cloudflare/`](../../infrastructure/cloudflare/), R2-bound) owns `media.linearvoid.tv` and enforces immutable caching for segments, short TTL for live manifests, plus HLS CORS/Range; it is a faithful path→R2 mapper and does **not** rewrite manifest URIs at serve time (playout rewrites them to run-scoped paths at publish — see [`STREAMING.md`](../STREAMING.md)). The `linearvoid-frontend` Worker ([`frontend/wrangler.toml`](../../frontend/wrangler.toml)) serves the static SPA on `linearvoid.tv`. Automated deploys: `workers-deploy.yml` (SPA) and `workers-media-deploy.yml` (media). Ops detail: [`cloudflare-workers-setup.md`](../operations/cloudflare-workers-setup.md).
|
||||||
|
|
||||||
_Operations & validation_: verify cache status progression (MISS→HIT), manifest freshness under active playout; track hit ratio, request/error rates, ToS/cost posture.
|
_Operations & validation_: verify cache status progression (MISS→HIT), manifest freshness under active playout; track hit ratio, request/error rates, ToS/cost posture.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
|||||||
|
|
||||||
The Worker in [`infrastructure/cloudflare/`](../../infrastructure/cloudflare/) runs on the `linearvoid-media` Workers service. It owns the `media.linearvoid.tv` host, serves HLS objects from the R2 bucket (bound as `MEDIA_BUCKET`), and stamps immutable `Cache-Control` on segments plus a short TTL on live manifests (CORS + Range supported). See its [README](../../infrastructure/cloudflare/README.md).
|
The Worker in [`infrastructure/cloudflare/`](../../infrastructure/cloudflare/) runs on the `linearvoid-media` Workers service. It owns the `media.linearvoid.tv` host, serves HLS objects from the R2 bucket (bound as `MEDIA_BUCKET`), and stamps immutable `Cache-Control` on segments plus a short TTL on live manifests (CORS + Range supported). See its [README](../../infrastructure/cloudflare/README.md).
|
||||||
|
|
||||||
|
**Run-scoped layout.** R2 objects are stored run-scoped (`hls/r-{runID}/seg-*.m4s`, `…/init.mp4`), and the live `hls/playlist.m3u8` references them with the run prefix (the playout rewrites bare URIs before publishing — see [`docs/STREAMING.md`](../STREAMING.md)). The Worker is a faithful path→R2 mapper: it serves whatever key the request path resolves to (e.g. `/hls/r-…/seg-*.m4s`), adds the cache headers by file type, and does not rewrite manifest URIs at serve time.
|
||||||
|
|
||||||
Test / deploy:
|
Test / deploy:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hls.js": "^1.6.16"
|
"hls.js": "^1.6.16"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.13",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ bucket_name = "linearvoid-media"
|
|||||||
# npx wrangler deploy
|
# npx wrangler deploy
|
||||||
#
|
#
|
||||||
# Production environment: attach the media host as a custom domain on this worker.
|
# Production environment: attach the media host as a custom domain on this worker.
|
||||||
|
# Note: bindings are NOT inherited by environments, so r2_buckets is redeclared here.
|
||||||
# npx wrangler deploy --env production
|
# npx wrangler deploy --env production
|
||||||
[env.production]
|
[env.production]
|
||||||
routes = [
|
routes = [
|
||||||
{ pattern = "media.linearvoid.tv", custom_domain = true }
|
{ pattern = "media.linearvoid.tv", custom_domain = true }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[env.production.r2_buckets]]
|
||||||
|
binding = "MEDIA_BUCKET"
|
||||||
|
bucket_name = "linearvoid-media"
|
||||||
|
|
||||||
Reference in new issue
Block a user