From 11e0ff6548094cdf2389b91adb7dfa3e86ad7289 Mon Sep 17 00:00:00 2001 From: Jack Dunham Date: Tue, 7 Apr 2026 12:45:54 -0700 Subject: [PATCH 1/4] Add HLS content steering options and manifest support Introduce Options and spec-aligned ContentSteeringOptions (SERVER-URI, DefaultPathwayID, Pathways) per RFC 8216 bis. DecodeFromWithOptions parses EXT-X-CONTENT-STEERING and PATHWAY-ID; writer emits when enabled. Made-with: Cursor --- options.go | 38 ++++++++++++++ options_test.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++ reader.go | 47 +++++++++++++++--- structure.go | 17 ++++--- writer.go | 29 +++++++++++ 5 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 options.go create mode 100644 options_test.go diff --git a/options.go b/options.go new file mode 100644 index 0000000..88285b1 --- /dev/null +++ b/options.go @@ -0,0 +1,38 @@ +package m3u8 + +// Options configures decode/encode behavior for playlists that use optional extensions. +type Options struct { + // ContentSteering, when non-nil, enables parsing and writing of RFC 8216 bis + // content steering tags and attributes (§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..874b5e1 --- /dev/null +++ b/options_test.go @@ -0,0 +1,128 @@ +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) + } +} diff --git a/reader.go b/reader.go index ca10b6e..72d335f 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 } @@ -317,7 +331,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 +366,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 +471,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..1086e2b 100644 --- a/writer.go +++ b/writer.go @@ -78,10 +78,34 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { p.buf.WriteString("#EXTM3U\n") + contentSteeringInUse := p.ContentSteeringServerURI != "" + if !contentSteeringInUse { + for _, pl := range p.Variants { + if pl.PathwayID != "" { + contentSteeringInUse = true + break + } + } + } + if contentSteeringInUse { + version(&p.ver, 9) + } + if p.Twitch == "" { p.buf.WriteString("#EXT-X-VERSION:") p.buf.WriteString(strver(p.ver)) p.buf.WriteRune('\n') + 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') + } } for _, c := range p.Comments { @@ -241,6 +265,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) From 1267240612b92c794cb3bb4b0b8dd034d27c1b1e Mon Sep 17 00:00:00 2001 From: Jack Dunham Date: Tue, 7 Apr 2026 16:44:31 -0700 Subject: [PATCH 2/4] Actually use ContentSteeringOptions, during rendering --- content_steering.go | 90 +++++++++++++++++++++++++++++++++++++++++++++ options.go | 5 ++- options_test.go | 43 ++++++++++++++++++++++ reader.go | 3 ++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 content_steering.go 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 index 88285b1..85192e1 100644 --- a/options.go +++ b/options.go @@ -2,8 +2,9 @@ package m3u8 // Options configures decode/encode behavior for playlists that use optional extensions. type Options struct { - // ContentSteering, when non-nil, enables parsing and writing of RFC 8216 bis - // content steering tags and attributes (§4.4.6.6, §4.4.6.2). + // 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 } diff --git a/options_test.go b/options_test.go index 874b5e1..7329463 100644 --- a/options_test.go +++ b/options_test.go @@ -126,3 +126,46 @@ https://example.com/low.m3u8 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 72d335f..c735fb3 100644 --- a/reader.go +++ b/reader.go @@ -304,6 +304,9 @@ func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder, opts 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 { From 58f3b50acc8205c2ca61729d42f2dfb6f2bf4a5b Mon Sep 17 00:00:00 2001 From: Jack Dunham Date: Wed, 8 Apr 2026 09:32:26 -0700 Subject: [PATCH 3/4] Write #EXT-X-CONTENT-STEERING immediately after EXTM3U (before VERSION) --- writer.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/writer.go b/writer.go index 1086e2b..dd31372 100644 --- a/writer.go +++ b/writer.go @@ -81,7 +81,7 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { contentSteeringInUse := p.ContentSteeringServerURI != "" if !contentSteeringInUse { for _, pl := range p.Variants { - if pl.PathwayID != "" { + if pl != nil && pl.PathwayID != "" { contentSteeringInUse = true break } @@ -91,21 +91,23 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { 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)) p.buf.WriteRune('\n') - 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') - } } for _, c := range p.Comments { From 222dc76f158c6db2b4b144994f9b65b2cd908bbe Mon Sep 17 00:00:00 2001 From: Jack Dunham Date: Thu, 16 Apr 2026 11:22:42 -0700 Subject: [PATCH 4/4] ensure PATHWAY-ID is set for IFRAME manifest links --- writer.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/writer.go b/writer.go index dd31372..1f2d2a1 100644 --- a/writer.go +++ b/writer.go @@ -178,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)