Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions content_steering.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package m3u8

import (
"net/url"
"strings"
)

// ApplyContentSteeringOptions merges o into p so Encode outputs policy-driven steering
// (RFC 8216 bis §4.4.6.6, §4.4.6.2): EXT-X-CONTENT-STEERING and variant PATHWAY-ID / URIs.
//
// Non-empty ServerURI and DefaultPathwayID override p's tag fields. When Pathways is
// non-empty, each existing variant is expanded to len(Pathways) copies (one per pathway);
// each copy gets Pathway-ID from the pathway ID and, if BaseURL is set, a URI derived
// via joinSteeringVariantURIBase.
//
// DecodeFromWithOptions calls this after a successful master decode when opts.ContentSteering is non-nil.
func ApplyContentSteeringOptions(p *MasterPlaylist, o *ContentSteeringOptions) {
if p == nil || o == nil {
return
}
if o.ServerURI != "" {
p.ContentSteeringServerURI = string(o.ServerURI)
}
if o.DefaultPathwayID != "" {
p.ContentSteeringPathwayID = string(o.DefaultPathwayID)
}
if len(o.Pathways) == 0 {
return
}
orig := append([]*Variant(nil), p.Variants...)
p.Variants = nil
for _, v := range orig {
if v == nil {
continue
}
for _, pw := range o.Pathways {
if pw.ID == "" && pw.BaseURL == "" {
continue
}
nv := *v
if pw.ID != "" {
nv.PathwayID = string(pw.ID)
}
if pw.BaseURL != "" {
nv.URI = joinSteeringVariantURIBase(pw.BaseURL, v.URI)
}
p.Variants = append(p.Variants, &nv)
}
}
}

// joinSteeringVariantURIBase builds a variant URI for a pathway: for an absolute variant URI,
// scheme/host (and optional userinfo) come from base; path and query come from the variant.
// If base includes a path prefix, it is prepended before the variant path. Relative variant URIs
// are resolved against base.
func joinSteeringVariantURIBase(base, variantURI string) string {
base = strings.TrimSpace(base)
if base == "" {
return variantURI
}
b, err := url.Parse(base)
if err != nil || b.Host == "" {
return variantURI
}
u, err := url.Parse(variantURI)
if err != nil {
return variantURI
}
if !u.IsAbs() {
b2, err := url.Parse(strings.TrimSuffix(base, "/") + "/")
if err != nil {
return variantURI
}
return b2.ResolveReference(u).String()
}
out := *u
out.Scheme = b.Scheme
out.Host = b.Host
if b.User != nil {
out.User = b.User
}
if pfx := strings.TrimRight(b.Path, "/"); pfx != "" {
if strings.HasPrefix(out.Path, "/") {
out.Path = pfx + out.Path
} else {
out.Path = pfx + "/" + out.Path
}
}
return out.String()
}
39 changes: 39 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package m3u8

// Options configures decode/encode behavior for playlists that use optional extensions.
type Options struct {
// ContentSteering, when non-nil, enables parsing of steering tags/attributes and merges
// ContentSteeringOptions after master decode (see ApplyContentSteeringOptions) so Encode
// reflects policy (RFC 8216 bis §4.4.6.6, §4.4.6.2).
ContentSteering *ContentSteeringOptions
}

// PathwayID identifies an HLS content steering pathway (RFC 8216 bis §7.2).
type PathwayID string

// HLSSteeringPathway pairs a pathway identifier with the absolute CDN base URI
// used when emitting duplicate EXT-X-STREAM-INF lines (one set per pathway).
type HLSSteeringPathway struct {
ID PathwayID

// BaseURL is the absolute base for variant and media playlist URIs on this pathway.
BaseURL string
}

// ContentSteeringOptions holds header- or policy-derived values used when rendering
// or rewriting HLS multivariant playlists per RFC 8216 bis §4.4.6.6 (EXT-X-CONTENT-STEERING)
// and §4.4.6.2 (PATHWAY-ID on EXT-X-STREAM-INF).
type ContentSteeringOptions struct {
// ServerURI is SERVER-URI: quoted URI to the steering manifest (§4.4.6.6). Required
// when emitting #EXT-X-CONTENT-STEERING.
ServerURI string

// DefaultPathwayID is PATHWAY-ID on #EXT-X-CONTENT-STEERING: pathway applied until the
// first steering manifest is obtained (§4.4.6.6). Optional; must match a PATHWAY-ID
// on at least one variant when set.
DefaultPathwayID PathwayID

// Pathways lists every pathway for PATHWAY-ID on duplicated variant lines and for
// base-URL selection when rewriting URIs per pathway.
Pathways []HLSSteeringPathway
}
171 changes: 171 additions & 0 deletions options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package m3u8

import (
"bytes"
"strings"
"testing"
)

func TestDecodeFromWithOptionsNilMatchesDecodeFrom(t *testing.T) {
const sample = `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1280000,PATHWAY-ID="fastly"
https://example.com/low.m3u8
`
r := strings.NewReader(sample)
p1, lt1, err1 := DecodeFrom(r, false)
if err1 != nil {
t.Fatal(err1)
}
r2 := strings.NewReader(sample)
p2, lt2, err2 := DecodeFromWithOptions(r2, false, nil)
if err2 != nil {
t.Fatal(err2)
}
if lt1 != lt2 {
t.Fatalf("list type %v vs %v", lt1, lt2)
}
m1 := p1.(*MasterPlaylist)
m2 := p2.(*MasterPlaylist)
if len(m1.Variants) != len(m2.Variants) {
t.Fatal("variant count mismatch")
}
if m1.Variants[0].PathwayID != m2.Variants[0].PathwayID {
t.Fatal("pathway mismatch")
}
}

func TestDecodeFromWithOptionsContentSteeringParsesTagAndPathway(t *testing.T) {
const sample = `#EXTM3U
#EXT-X-VERSION:9
#EXT-X-CONTENT-STEERING:SERVER-URI="https://steer.example/api",PATHWAY-ID="fastly"
#EXT-X-STREAM-INF:BANDWIDTH=1280000,PATHWAY-ID="fastly"
https://cdn.example/low.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2560000,PATHWAY-ID="akamai"
https://cdn2.example/mid.m3u8
`
opts := &Options{ContentSteering: &ContentSteeringOptions{}}
pl, lt, err := DecodeFromWithOptions(strings.NewReader(sample), true, opts)
if err != nil {
t.Fatal(err)
}
if lt != MASTER {
t.Fatalf("expected MASTER, got %v", lt)
}
m := pl.(*MasterPlaylist)
if m.ContentSteeringServerURI != "https://steer.example/api" {
t.Fatalf("SERVER-URI: got %q", m.ContentSteeringServerURI)
}
if m.ContentSteeringPathwayID != "fastly" {
t.Fatalf("default PATHWAY-ID: got %q", m.ContentSteeringPathwayID)
}
if len(m.Variants) != 2 {
t.Fatalf("variants: %d", len(m.Variants))
}
if m.Variants[0].PathwayID != "fastly" || m.Variants[1].PathwayID != "akamai" {
t.Fatalf("variant pathways: %+v, %+v", m.Variants[0].PathwayID, m.Variants[1].PathwayID)
}
}

func TestDecodeFromWithOptionsWithoutContentSteeringIgnoresPathwayID(t *testing.T) {
const sample = `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1280000,PATHWAY-ID="fastly"
https://example.com/low.m3u8
`
pl, _, err := DecodeFromWithOptions(strings.NewReader(sample), false, &Options{})
if err != nil {
t.Fatal(err)
}
m := pl.(*MasterPlaylist)
if m.Variants[0].PathwayID != "" {
t.Fatalf("expected empty PathwayID without ContentSteering sub-option, got %q", m.Variants[0].PathwayID)
}
}

func TestContentSteeringEncodeRoundTrip(t *testing.T) {
m := NewMasterPlaylist()
m.ContentSteeringServerURI = "https://steer.example/v1/steer"
m.ContentSteeringPathwayID = "fastly"
m.Append("https://a.example/out/v1/x/manifest.m3u8", nil, VariantParams{
ProgramId: 1, Bandwidth: 1000000, Codecs: "avc1.64001E,mp4a.40.2", PathwayID: "fastly",
})
m.Append("https://b.example/out/v1/x/manifest.m3u8", nil, VariantParams{
ProgramId: 1, Bandwidth: 1000000, Codecs: "avc1.64001E,mp4a.40.2", PathwayID: "akamai",
})

encoded := m.Encode().String()
opts := &Options{ContentSteering: &ContentSteeringOptions{}}
pl, _, err := DecodeFromWithOptions(bytes.NewReader([]byte(encoded)), true, opts)
if err != nil {
t.Fatalf("decode: %v\n%s", err, encoded)
}
m2 := pl.(*MasterPlaylist)
if m2.ContentSteeringServerURI != m.ContentSteeringServerURI {
t.Fatalf("server URI %q vs %q", m2.ContentSteeringServerURI, m.ContentSteeringServerURI)
}
if m2.ContentSteeringPathwayID != m.ContentSteeringPathwayID {
t.Fatalf("pathway %q vs %q", m2.ContentSteeringPathwayID, m.ContentSteeringPathwayID)
}
if len(m2.Variants) != 2 {
t.Fatal(len(m2.Variants))
}
if m2.Variants[0].PathwayID != "fastly" || m2.Variants[1].PathwayID != "akamai" {
t.Fatalf("pathways %+v %+v", m2.Variants[0].PathwayID, m2.Variants[1].PathwayID)
}
}

func TestDecodeWithStillWorks(t *testing.T) {
const sample = `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1280000
https://example.com/low.m3u8
`
_, _, err := DecodeWith(strings.NewReader(sample), false, nil)
if err != nil {
t.Fatal(err)
}
}

func TestDecodeFromWithOptionsAppliesSteeringOptionsForEncode(t *testing.T) {
const sample = `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="avc1.64001E,mp4a.40.2"
https://origin.example.com/asset/low.m3u8
`
opts := &Options{ContentSteering: &ContentSteeringOptions{
ServerURI: "https://steer.example/steer",
DefaultPathwayID: "cdn-a",
Pathways: []HLSSteeringPathway{
{ID: "cdn-a", BaseURL: "https://cdn-a.example.com"},
{ID: "cdn-b", BaseURL: "https://cdn-b.example.com"},
},
}}
pl, lt, err := DecodeFromWithOptions(strings.NewReader(sample), true, opts)
if err != nil {
t.Fatal(err)
}
if lt != MASTER {
t.Fatalf("list type %v", lt)
}
m := pl.(*MasterPlaylist)
if m.ContentSteeringServerURI != "https://steer.example/steer" {
t.Fatalf("SERVER-URI %q", m.ContentSteeringServerURI)
}
if m.ContentSteeringPathwayID != "cdn-a" {
t.Fatalf("default pathway %q", m.ContentSteeringPathwayID)
}
if len(m.Variants) != 2 {
t.Fatalf("want 2 pathway variants, got %d", len(m.Variants))
}
enc := m.Encode().String()
if !strings.Contains(enc, "#EXT-X-CONTENT-STEERING:") || !strings.Contains(enc, "https://steer.example/steer") {
t.Fatalf("missing EXT-X-CONTENT-STEERING in:\n%s", enc)
}
if !strings.Contains(enc, "PATHWAY-ID=\"cdn-a\"") || !strings.Contains(enc, "PATHWAY-ID=\"cdn-b\"") {
t.Fatalf("missing pathway IDs in:\n%s", enc)
}
if !strings.Contains(enc, "https://cdn-a.example.com") || !strings.Contains(enc, "https://cdn-b.example.com") {
t.Fatalf("missing CDN hosts in:\n%s", enc)
}
}
Loading