chore(release): 1.1.0
Release / release (push) Successful in 1m15s
CI / test (push) Successful in 2m53s
CI / docker (push) Successful in 2m0s

- 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:
zwitschi committed 2026-09-03 09:27:38 +02:00
1 parent 8c5e70959b
commit d8cf853842
13 files changed
+526 -164

No files matched your search

+77 -3
View File
@@ -44,6 +44,9 @@ var (
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
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")
playoutMode = "starting" // "live", "slate", "degraded"
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).
// 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 {
if r2Client == 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
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)
}
// 2. Atomic copy to final key
_, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
_, err = copyObject(r2Client, &s3.CopyObjectInput{
Bucket: aws.String(bucket),
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
Key: aws.String("hls/playlist.m3u8"),
@@ -210,6 +235,55 @@ func publishManifest(localManifestPath string) error {
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
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
file, err := os.Open(playlistPath)
@@ -1218,4 +1292,4 @@ func loadSharedHLSConfig() {
}
}
fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize)
}
}