diff --git a/content_steering.go b/content_steering.go new file mode 100644 index 0000000..314415e --- /dev/null +++ b/content_steering.go @@ -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() +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..85192e1 --- /dev/null +++ b/options.go @@ -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 +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..7329463 --- /dev/null +++ b/options_test.go @@ -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) + } +} diff --git a/reader.go b/reader.go index ca10b6e..c735fb3 100644 --- a/reader.go +++ b/reader.go @@ -74,7 +74,7 @@ func (p *MasterPlaylist) decode(buf *bytes.Buffer, strict bool) error { } else if err != nil { break } - err = decodeLineOfMasterPlaylist(p, state, line, strict) + err = decodeLineOfMasterPlaylist(p, state, line, strict, nil) if strict && err != nil { return err } @@ -195,7 +195,7 @@ func (p *MediaPlaylist) decode(buf *bytes.Buffer, strict bool) error { // Decode detects type of playlist and decodes it. It accepts bytes // buffer as input. func Decode(data bytes.Buffer, strict bool) (Playlist, ListType, error) { - return decode(&data, strict, nil) + return decode(&data, strict, nil, nil) } // DecodeFrom detects type of playlist and decodes it. It accepts data @@ -206,7 +206,21 @@ func DecodeFrom(reader io.Reader, strict bool) (Playlist, ListType, error) { if err != nil { return nil, 0, err } - return decode(buf, strict, nil) + return decode(buf, strict, nil, nil) +} + +// DecodeFromWithOptions is like DecodeFrom but accepts optional decode settings. +// If opts is nil, behavior matches DecodeFrom exactly. +func DecodeFromWithOptions(reader io.Reader, strict bool, opts *Options) (Playlist, ListType, error) { + if opts == nil { + return DecodeFrom(reader, strict) + } + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(reader) + if err != nil { + return nil, 0, err + } + return decode(buf, strict, nil, opts) } // DecodeWith detects the type of playlist and decodes it. It accepts either bytes.Buffer @@ -214,14 +228,14 @@ func DecodeFrom(reader io.Reader, strict bool) (Playlist, ListType, error) { func DecodeWith(input interface{}, strict bool, customDecoders []CustomDecoder) (Playlist, ListType, error) { switch v := input.(type) { case bytes.Buffer: - return decode(&v, strict, customDecoders) + return decode(&v, strict, customDecoders, nil) case io.Reader: buf := new(bytes.Buffer) _, err := buf.ReadFrom(v) if err != nil { return nil, 0, err } - return decode(buf, strict, customDecoders) + return decode(buf, strict, customDecoders, nil) default: return nil, 0, errors.New("input must be bytes.Buffer or io.Reader type") } @@ -229,7 +243,7 @@ func DecodeWith(input interface{}, strict bool, customDecoders []CustomDecoder) // Detect playlist type and decode it. May be used as decoder for both // master and media playlists. -func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder) (Playlist, ListType, error) { +func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder, opts *Options) (Playlist, ListType, error) { var eof bool var line string var master *MasterPlaylist @@ -268,7 +282,7 @@ func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder) (Pla continue } - err = decodeLineOfMasterPlaylist(master, state, line, strict) + err = decodeLineOfMasterPlaylist(master, state, line, strict, opts) if strict && err != nil { return master, state.listType, err } @@ -290,6 +304,9 @@ func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder) (Pla switch state.listType { case MASTER: master.setAlternatives(state) + if opts != nil && opts.ContentSteering != nil { + ApplyContentSteeringOptions(master, opts.ContentSteering) + } return master, MASTER, nil case MEDIA: if media.Closed || media.MediaType == EVENT { @@ -317,7 +334,7 @@ func decodeParamsLine(line string) map[string]string { } // Parse one line of master playlist. -func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line string, strict bool) error { +func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line string, strict bool, opts *Options) error { var err error line = strings.TrimSpace(line) @@ -352,6 +369,19 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st p.Twitch = Twitch(line) case line == "#EXT-X-INDEPENDENT-SEGMENTS": p.SetIndependentSegments(true) + case strings.HasPrefix(line, "#EXT-X-CONTENT-STEERING:"): + if opts != nil && opts.ContentSteering != nil { + state.listType = MASTER + prefix := "#EXT-X-CONTENT-STEERING:" + for k, v := range decodeParamsLine(line[len(prefix):]) { + switch k { + case "SERVER-URI": + p.ContentSteeringServerURI = v + case "PATHWAY-ID": + p.ContentSteeringPathwayID = v + } + } + } case strings.HasPrefix(line, "#EXT-X-MEDIA:"): var alt Alternative alt.Index = state.currentAltIdx @@ -444,6 +474,10 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st state.variant.HDCPLevel = v case "SUPPLEMENTAL-CODECS": state.variant.SupplementalCodecs = v + case "PATHWAY-ID": + if opts != nil && opts.ContentSteering != nil { + state.variant.PathwayID = v + } } } case state.tagStreamInf && !strings.HasPrefix(line, "#"): diff --git a/structure.go b/structure.go index 0cb432e..ae3c7a8 100644 --- a/structure.go +++ b/structure.go @@ -156,12 +156,16 @@ type MasterPlaylist struct { Args string // optional arguments placed after URI (URI?Args) CypherVersion string // non-standard tag for Widevine (see also WV struct) Twitch Twitch // non-standard tag for Twitch - buf bytes.Buffer - ver uint8 - independentSegments bool - Comments []string - Custom map[string]CustomTag - customDecoders []CustomDecoder + // ContentSteeringServerURI and ContentSteeringPathwayID map to EXT-X-CONTENT-STEERING + // SERVER-URI and PATHWAY-ID (default pathway). Populated when decoding with Options.ContentSteering. + ContentSteeringServerURI string + ContentSteeringPathwayID string + buf bytes.Buffer + ver uint8 + independentSegments bool + Comments []string + Custom map[string]CustomTag + customDecoders []CustomDecoder } // Variant structure represents variants for master playlist. @@ -192,6 +196,7 @@ type VariantParams struct { HDCPLevel string SupplementalCodecs string FrameRate float64 // EXT-X-STREAM-INF + PathwayID string // EXT-X-STREAM-INF PATHWAY-ID (content steering) Alternatives []*Alternative // EXT-X-MEDIA } diff --git a/writer.go b/writer.go index f010512..1f2d2a1 100644 --- a/writer.go +++ b/writer.go @@ -78,6 +78,32 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { p.buf.WriteString("#EXTM3U\n") + contentSteeringInUse := p.ContentSteeringServerURI != "" + if !contentSteeringInUse { + for _, pl := range p.Variants { + if pl != nil && pl.PathwayID != "" { + contentSteeringInUse = true + break + } + } + } + if contentSteeringInUse { + version(&p.ver, 9) + } + + // RFC 8216 bis: EXT-X-CONTENT-STEERING immediately after EXTM3U (before VERSION). + if p.ContentSteeringServerURI != "" { + p.buf.WriteString("#EXT-X-CONTENT-STEERING:SERVER-URI=\"") + p.buf.WriteString(strings.ReplaceAll(p.ContentSteeringServerURI, `"`, `\"`)) + p.buf.WriteRune('"') + if p.ContentSteeringPathwayID != "" { + p.buf.WriteString(",PATHWAY-ID=\"") + p.buf.WriteString(strings.ReplaceAll(p.ContentSteeringPathwayID, `"`, `\"`)) + p.buf.WriteRune('"') + } + p.buf.WriteRune('\n') + } + if p.Twitch == "" { p.buf.WriteString("#EXT-X-VERSION:") p.buf.WriteString(strver(p.ver)) @@ -152,6 +178,11 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(",HDCP-LEVEL=") p.buf.WriteString(pl.HDCPLevel) } + if pl.PathwayID != "" { + p.buf.WriteString(",PATHWAY-ID=\"") + p.buf.WriteString(strings.ReplaceAll(pl.PathwayID, `"`, `\"`)) + p.buf.WriteRune('"') + } if pl.URI != "" { p.buf.WriteString(",URI=\"") p.buf.WriteString(pl.URI) @@ -241,6 +272,11 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(",HDCP-LEVEL=") p.buf.WriteString(pl.HDCPLevel) } + if pl.PathwayID != "" { + p.buf.WriteString(",PATHWAY-ID=\"") + p.buf.WriteString(strings.ReplaceAll(pl.PathwayID, `"`, `\"`)) + p.buf.WriteRune('"') + } p.buf.WriteRune('\n') p.buf.WriteString(pl.URI)