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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user