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