From 21d0dcc7a29b31cb77ec466bec6d91fcee61af76 Mon Sep 17 00:00:00 2001 From: Jack Dunham Date: Tue, 7 Apr 2026 12:45:57 -0700 Subject: [PATCH 1/2] Add DASH content steering (ETSI TS 103 998) BaseURL with serviceLocation, MPD ContentSteering element, and ContentSteeringOptions (SteeringURI, DefaultServiceLocation, SteeringBaseURLs, optional boolean attrs). ReadFromStringWithOptions enables parsing; BaseURL slices use BaseURLValue throughout. Made-with: Cursor --- mpd/content_steering.go | 46 +++++++++++++++ mpd/content_steering_test.go | 109 +++++++++++++++++++++++++++++++++++ mpd/mpd.go | 15 ++--- mpd/mpd_read_write.go | 8 +++ mpd/mpd_read_write_test.go | 2 +- mpd/mpd_test.go | 10 ++-- mpd/options.go | 29 ++++++++++ mpd/segment_list_test.go | 4 +- mpd/segment_timeline_test.go | 4 +- 9 files changed, 210 insertions(+), 17 deletions(-) create mode 100644 mpd/content_steering.go create mode 100644 mpd/content_steering_test.go create mode 100644 mpd/options.go diff --git a/mpd/content_steering.go b/mpd/content_steering.go new file mode 100644 index 0000000..f4aafab --- /dev/null +++ b/mpd/content_steering.go @@ -0,0 +1,46 @@ +package mpd + +// ServiceLocation is the value of BaseURL@serviceLocation and +// ContentSteering@defaultServiceLocation (ETSI TS 103 998). +type ServiceLocation string + +// SteeredBaseURL is one attributed BaseURL for content steering: a URI and its +// serviceLocation label. +type SteeredBaseURL struct { + Location ServiceLocation + Value string +} + +// BaseURLValue is one DASH BaseURL element. ServiceLocation is used for +// DASH content steering (ETSI TS 103 998 / DASH-IF); omit for plain BaseURLs. +type BaseURLValue struct { + ServiceLocation string `xml:"serviceLocation,attr,omitempty"` + Value string `xml:",chardata"` +} + +// ContentSteering is the MPD-level ContentSteering element (steering server URI +// as character data; defaultServiceLocation names the preferred CDN at startup). +type ContentSteering struct { + DefaultServiceLocation *string `xml:"defaultServiceLocation,attr,omitempty"` + QueryBeforeStart *bool `xml:"queryBeforeStart,attr,omitempty"` + ClientRequirement *bool `xml:"clientRequirement,attr,omitempty"` + URI string `xml:",chardata"` +} + +// StringsToBaseURLs converts plain URL strings to BaseURLValue entries. +func StringsToBaseURLs(ss []string) []BaseURLValue { + out := make([]BaseURLValue, len(ss)) + for i, s := range ss { + out[i] = BaseURLValue{Value: s} + } + return out +} + +// BaseURLsToStrings returns each BaseURL element's text (ignores serviceLocation). +func BaseURLsToStrings(bs []BaseURLValue) []string { + out := make([]string, len(bs)) + for i, b := range bs { + out[i] = b.Value + } + return out +} diff --git a/mpd/content_steering_test.go b/mpd/content_steering_test.go new file mode 100644 index 0000000..4db1cf1 --- /dev/null +++ b/mpd/content_steering_test.go @@ -0,0 +1,109 @@ +package mpd + +import ( + "strings" + "testing" + + . "github.com/cbsinteractive/go-dash/v3/helpers/ptrs" + "github.com/cbsinteractive/go-dash/v3/helpers/require" +) + +func TestReadFromStringWithOptionsNilMatchesReadFromString(t *testing.T) { + xml := ` + + + https://origin.example/ + +` + m1, err1 := ReadFromString(xml) + require.NoError(t, err1) + m2, err2 := ReadFromStringWithOptions(xml, nil) + require.NoError(t, err2) + require.EqualStringSlice(t, BaseURLsToStrings(m1.Periods[0].BaseURL), BaseURLsToStrings(m2.Periods[0].BaseURL)) +} + +func TestReadFromStringWithOptionsNonNilOptsParsesContentSteering(t *testing.T) { + xml := ` + + https://fastly.example/out/v1/x/ + https://akamai.example/out/v1/x/ + https://steer.example/api/v1/steer + + + + seg/ + + + +` + opts := &Options{ContentSteering: &ContentSteeringOptions{}} + m, err := ReadFromStringWithOptions(xml, opts) + require.NoError(t, err) + if m.ContentSteering == nil { + t.Fatal("expected ContentSteering") + } + if strings.TrimSpace(m.ContentSteering.URI) != "https://steer.example/api/v1/steer" { + t.Fatalf("steering URI %q", m.ContentSteering.URI) + } + if m.ContentSteering.DefaultServiceLocation == nil || *m.ContentSteering.DefaultServiceLocation != "fastly" { + t.Fatalf("defaultServiceLocation %+v", m.ContentSteering.DefaultServiceLocation) + } + if len(m.BaseURL) != 2 { + t.Fatalf("base URLs: %d", len(m.BaseURL)) + } + if m.BaseURL[0].ServiceLocation != "fastly" || m.BaseURL[0].Value != "https://fastly.example/out/v1/x/" { + t.Fatalf("first BaseURL %+v", m.BaseURL[0]) + } + if m.BaseURL[1].ServiceLocation != "akamai" { + t.Fatalf("second BaseURL %+v", m.BaseURL[1]) + } +} + +func TestContentSteeringRoundTripWriteRead(t *testing.T) { + m := NewMPD(DASH_PROFILE_ONDEMAND, "PT6M16S", "PT1.97S") + m.BaseURL = []BaseURLValue{ + {ServiceLocation: "fastly", Value: "https://f.example/path/"}, + {ServiceLocation: "akamai", Value: "https://a.example/path/"}, + } + m.ContentSteering = &ContentSteering{ + DefaultServiceLocation: Strptr("fastly"), + URI: "https://steer.example/steer", + } + out, err := m.WriteToString() + require.NoError(t, err) + m2, err := ReadFromStringWithOptions(out, &Options{ContentSteering: &ContentSteeringOptions{}}) + require.NoError(t, err) + if m2.ContentSteering == nil || strings.TrimSpace(m2.ContentSteering.URI) != m.ContentSteering.URI { + t.Fatalf("ContentSteering after round trip: %+v", m2.ContentSteering) + } + if len(m2.BaseURL) != 2 { + t.Fatal(len(m2.BaseURL)) + } +} + +func TestContentSteeringOptionalAttributesRoundTrip(t *testing.T) { + m := NewMPD(DASH_PROFILE_ONDEMAND, "PT1S", "PT1S") + m.ContentSteering = &ContentSteering{ + DefaultServiceLocation: Strptr("c"), + QueryBeforeStart: Boolptr(true), + ClientRequirement: Boolptr(false), + URI: "https://steer/x", + } + out, err := m.WriteToString() + require.NoError(t, err) + m2, err := ReadFromString(out) + require.NoError(t, err) + require.NotNil(t, m2.ContentSteering) + if m2.ContentSteering.QueryBeforeStart == nil || !*m2.ContentSteering.QueryBeforeStart { + t.Fatal("queryBeforeStart") + } + if m2.ContentSteering.ClientRequirement == nil || *m2.ContentSteering.ClientRequirement { + t.Fatal("clientRequirement") + } +} + +func TestStringsToBaseURLsAndBack(t *testing.T) { + ss := []string{"a", "b"} + b := StringsToBaseURLs(ss) + require.EqualStringSlice(t, ss, BaseURLsToStrings(b)) +} diff --git a/mpd/mpd.go b/mpd/mpd.go index 2efb925..2c7fc8c 100644 --- a/mpd/mpd.go +++ b/mpd/mpd.go @@ -88,8 +88,9 @@ type MPD struct { PublishTime *string `xml:"publishTime,attr"` TimeShiftBufferDepth *string `xml:"timeShiftBufferDepth,attr"` SuggestedPresentationDelay *Duration `xml:"suggestedPresentationDelay,attr,omitempty"` - BaseURL []string `xml:"BaseURL,omitempty"` - Location string `xml:"Location,omitempty"` + BaseURL []BaseURLValue `xml:"BaseURL,omitempty"` + ContentSteering *ContentSteering `xml:"ContentSteering,omitempty"` + Location string `xml:"Location,omitempty"` period *Period Periods []*Period `xml:"Period,omitempty"` UTCTiming *DescriptorType `xml:"UTCTiming,omitempty"` @@ -153,7 +154,7 @@ type Period struct { ID string `xml:"id,attr,omitempty"` Duration Duration `xml:"duration,attr,omitempty"` Start *Duration `xml:"start,attr,omitempty"` - BaseURL []string `xml:"BaseURL,omitempty"` + BaseURL []BaseURLValue `xml:"BaseURL,omitempty"` SegmentBase *SegmentBase `xml:"SegmentBase,omitempty"` SegmentList *SegmentList `xml:"SegmentList,omitempty"` SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate,omitempty"` @@ -256,7 +257,7 @@ type AdaptationSet struct { Labels []string `xml:"Label,omitempty"` Representations []*Representation `xml:"Representation,omitempty"` AccessibilityElems []*Accessibility `xml:"Accessibility,omitempty"` - BaseURL []string `xml:"BaseURL,omitempty"` + BaseURL []BaseURLValue `xml:"BaseURL,omitempty"` } func (as *AdaptationSet) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -470,7 +471,7 @@ type Representation struct { Height *int64 `xml:"height,attr"` // Video ID *string `xml:"id,attr"` // Audio + Video Width *int64 `xml:"width,attr"` // Video - BaseURL []string `xml:"BaseURL,omitempty"` // On-Demand Profile + BaseURL []BaseURLValue `xml:"BaseURL,omitempty"` // On-Demand Profile SegmentBase *SegmentBase `xml:"SegmentBase,omitempty"` // On-Demand Profile SegmentList *SegmentList `xml:"SegmentList,omitempty"` SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate,omitempty"` @@ -1265,7 +1266,7 @@ func (r *Representation) SetNewBaseURL(baseURL string) error { return ErrBaseURLEmpty } // overwrite for backwards compatability - r.BaseURL = []string{baseURL} + r.BaseURL = []BaseURLValue{{Value: baseURL}} return nil } @@ -1275,7 +1276,7 @@ func (r *Representation) AddNewBaseURL(baseURL string) error { if baseURL == "" { return ErrBaseURLEmpty } - r.BaseURL = append(r.BaseURL, baseURL) + r.BaseURL = append(r.BaseURL, BaseURLValue{Value: baseURL}) return nil } diff --git a/mpd/mpd_read_write.go b/mpd/mpd_read_write.go index dd9e41e..c0a4df4 100644 --- a/mpd/mpd_read_write.go +++ b/mpd/mpd_read_write.go @@ -23,6 +23,14 @@ func ReadFromFile(path string) (*MPD, error) { // Reads a string into a MPD object. // xmlStr - MPD manifest data as a string. func ReadFromString(xmlStr string) (*MPD, error) { + return ReadFromStringWithOptions(xmlStr, nil) +} + +// ReadFromStringWithOptions parses xmlStr into an MPD. If opts is nil, behavior +// matches ReadFromString. Non-nil opts reserves content-steering-related options +// for future use; unmarshaling is identical today. +func ReadFromStringWithOptions(xmlStr string, opts *Options) (*MPD, error) { + _ = opts // reserved for content-steering / extended parse behavior b := bytes.NewBufferString(xmlStr) return Read(b) } diff --git a/mpd/mpd_read_write_test.go b/mpd/mpd_read_write_test.go index 92d3a17..a936a2c 100644 --- a/mpd/mpd_read_write_test.go +++ b/mpd/mpd_read_write_test.go @@ -303,7 +303,7 @@ func TestFullLiveProfileMultiBaseURLWriteToString(t *testing.T) { m := LiveProfile() require.NotNil(t, m) - m.BaseURL = []string{"./", "../a/", "../b/"} + m.BaseURL = StringsToBaseURLs([]string{"./", "../a/", "../b/"}) xmlStr, err := m.WriteToString() require.NoError(t, err) diff --git a/mpd/mpd_test.go b/mpd/mpd_test.go index 9343cfa..bcc97ce 100644 --- a/mpd/mpd_test.go +++ b/mpd/mpd_test.go @@ -156,7 +156,7 @@ func TestWidevineContentProtection_ImplementsInterface(t *testing.T) { func TestNewMPDLiveWithBaseURLInMPD(t *testing.T) { m := NewMPD(DASH_PROFILE_LIVE, VALID_MEDIA_PRESENTATION_DURATION, VALID_MIN_BUFFER_TIME) - m.BaseURL = []string{VALID_BASE_URL_VIDEO} + m.BaseURL = StringsToBaseURLs([]string{VALID_BASE_URL_VIDEO}) require.NotNil(t, m) expectedMPD := &MPD{ XMLNs: Strptr("urn:mpeg:dash:schema:mpd:2011"), @@ -166,7 +166,7 @@ func TestNewMPDLiveWithBaseURLInMPD(t *testing.T) { MinBufferTime: Strptr(VALID_MIN_BUFFER_TIME), period: &Period{}, Periods: []*Period{{}}, - BaseURL: []string{VALID_BASE_URL_VIDEO}, + BaseURL: StringsToBaseURLs([]string{VALID_BASE_URL_VIDEO}), } expectedString, err := expectedMPD.WriteToString() @@ -179,10 +179,10 @@ func TestNewMPDLiveWithBaseURLInMPD(t *testing.T) { func TestNewMPDLiveWithBaseURLInPeriod(t *testing.T) { m := NewMPD(DASH_PROFILE_LIVE, VALID_MEDIA_PRESENTATION_DURATION, VALID_MIN_BUFFER_TIME) - m.period.BaseURL = []string{VALID_BASE_URL_VIDEO} + m.period.BaseURL = StringsToBaseURLs([]string{VALID_BASE_URL_VIDEO}) require.NotNil(t, m) period := &Period{ - BaseURL: []string{VALID_BASE_URL_VIDEO}, + BaseURL: StringsToBaseURLs([]string{VALID_BASE_URL_VIDEO}), } expectedMPD := &MPD{ XMLNs: Strptr("urn:mpeg:dash:schema:mpd:2011"), @@ -451,7 +451,7 @@ func TestAddNewBaseURLVideo(t *testing.T) { err = r.AddNewBaseURL("../b/") require.NoError(t, err) - require.EqualStringSlice(t, []string{"./", "../a/", "../b/"}, r.BaseURL) + require.EqualStringSlice(t, []string{"./", "../a/", "../b/"}, BaseURLsToStrings(r.BaseURL)) } func TestSetNewBaseURLSubtitle(t *testing.T) { diff --git a/mpd/options.go b/mpd/options.go new file mode 100644 index 0000000..76c5b97 --- /dev/null +++ b/mpd/options.go @@ -0,0 +1,29 @@ +package mpd + +// Options configures MPD read/write behavior for optional extensions. +type Options struct { + // ContentSteering, when non-nil, enables parsing of ETSI TS 103 998 content + // steering (ContentSteering element and BaseURL@serviceLocation). + ContentSteering *ContentSteeringOptions +} + +// ContentSteeringOptions holds header- or policy-derived values used when rendering +// attributed BaseURL and ContentSteering elements (ETSI TS 103 998). +type ContentSteeringOptions struct { + // SteeringURI is the steering server or manifest URI (character data of the + // ContentSteering element). + SteeringURI string + + // DefaultServiceLocation is ContentSteering@defaultServiceLocation: the preferred + // service location until steering resolves. + DefaultServiceLocation ServiceLocation + + // SteeringBaseURLs lists BaseURL elements with serviceLocation (one CDN root per location). + SteeringBaseURLs []SteeredBaseURL + + // QueryBeforeStart is ContentSteering@queryBeforeStart when non-nil. + QueryBeforeStart *bool + + // ClientRequirement is ContentSteering@clientRequirement when non-nil. + ClientRequirement *bool +} diff --git a/mpd/segment_list_test.go b/mpd/segment_list_test.go index 49f364d..7b3a49b 100644 --- a/mpd/segment_list_test.go +++ b/mpd/segment_list_test.go @@ -24,7 +24,7 @@ func TestSegmentListDeserialization(t *testing.T) { expected := getSegmentListMPD() require.EqualString(t, m.Comment, "Generated with https://github.com/shaka-project/shaka-packager version 288eddc863-release") - require.EqualStringSlice(t, expected.Periods[0].BaseURL, m.Periods[0].BaseURL) + require.EqualStringSlice(t, BaseURLsToStrings(expected.Periods[0].BaseURL), BaseURLsToStrings(m.Periods[0].BaseURL)) expectedAudioSegList := expected.Periods[0].AdaptationSets[0].Representations[0].SegmentList audioSegList := m.Periods[0].AdaptationSets[0].Representations[0].SegmentList @@ -60,7 +60,7 @@ func TestSegmentListDeserialization(t *testing.T) { func getSegmentListMPD() *MPD { m := NewMPD(DASH_PROFILE_LIVE, "PT30.016S", "PT2.000S") - m.period.BaseURL = []string{"http://localhost:8002/dash/"} + m.period.BaseURL = StringsToBaseURLs([]string{"http://localhost:8002/dash/"}) m.Comment = "Generated with https://github.com/shaka-project/shaka-packager version 288eddc863-release" aas, _ := m.AddNewAdaptationSetAudioWithID("1", "audio/mp4", true, 1, "English") diff --git a/mpd/segment_timeline_test.go b/mpd/segment_timeline_test.go index 29997e8..e5d5b66 100644 --- a/mpd/segment_timeline_test.go +++ b/mpd/segment_timeline_test.go @@ -32,7 +32,7 @@ func TestSegmentTimelineDeserialization(t *testing.T) { m, err := ReadFromString(xml) require.NoError(t, err) expected := getSegmentTimelineMPD() - require.EqualStringSlice(t, expected.Periods[0].BaseURL, m.Periods[0].BaseURL) + require.EqualStringSlice(t, BaseURLsToStrings(expected.Periods[0].BaseURL), BaseURLsToStrings(m.Periods[0].BaseURL)) expectedAudioSegTimeline := expected.Periods[0].AdaptationSets[0].Representations[0].SegmentTemplate.SegmentTimeline audioSegTimeline := m.Periods[0].AdaptationSets[0].Representations[0].SegmentTemplate.SegmentTimeline @@ -102,7 +102,7 @@ func getMultiPeriodSegmentTimelineMPD() *MPD { func getSegmentTimelineMPD() *MPD { m := NewMPD(DASH_PROFILE_LIVE, "PT65.063S", "PT2.000S") - m.period.BaseURL = []string{"http://localhost:8002/public/"} + m.period.BaseURL = StringsToBaseURLs([]string{"http://localhost:8002/public/"}) aas, _ := m.AddNewAdaptationSetAudioWithID("1", "audio/mp4", true, 1, "English") ra, _ := aas.AddNewRepresentationAudio(48000, 255000, "mp4a.40.2", "audio_1") From b4d71295bc14627fbe52b38761fa3b83709f7cb7 Mon Sep 17 00:00:00 2001 From: Jack Dunham Date: Tue, 7 Apr 2026 16:43:35 -0700 Subject: [PATCH 2/2] actually use ContentSteeringOptions, during manifest rendering --- mpd/content_steering.go | 32 ++++++++++++++++++++++++++++++++ mpd/content_steering_test.go | 26 ++++++++++++++++++++++++++ mpd/mpd_read_write.go | 14 ++++++++++---- mpd/options.go | 4 ++-- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/mpd/content_steering.go b/mpd/content_steering.go index f4aafab..01b092d 100644 --- a/mpd/content_steering.go +++ b/mpd/content_steering.go @@ -44,3 +44,35 @@ func BaseURLsToStrings(bs []BaseURLValue) []string { } return out } + +// ApplyContentSteeringOptions merges o into m so Write/Encode output includes policy-driven +// steering (ETSI TS 103 998): the ContentSteering element and optional BaseURL@serviceLocation +// rows. Non-empty SteeringURI replaces m.ContentSteering; SteeringBaseURLs are appended +// to m.BaseURL (skipping entries with an empty Value). +// +// ReadFromStringWithOptions calls this automatically after a successful decode when +// opts.ContentSteering is non-nil. +func ApplyContentSteeringOptions(m *MPD, o *ContentSteeringOptions) { + if m == nil || o == nil { + return + } + if o.SteeringURI != "" { + cs := &ContentSteering{URI: o.SteeringURI} + if o.DefaultServiceLocation != "" { + s := string(o.DefaultServiceLocation) + cs.DefaultServiceLocation = &s + } + cs.QueryBeforeStart = o.QueryBeforeStart + cs.ClientRequirement = o.ClientRequirement + m.ContentSteering = cs + } + for _, sb := range o.SteeringBaseURLs { + if sb.Value == "" { + continue + } + m.BaseURL = append(m.BaseURL, BaseURLValue{ + ServiceLocation: string(sb.Location), + Value: sb.Value, + }) + } +} diff --git a/mpd/content_steering_test.go b/mpd/content_steering_test.go index 4db1cf1..891a739 100644 --- a/mpd/content_steering_test.go +++ b/mpd/content_steering_test.go @@ -107,3 +107,29 @@ func TestStringsToBaseURLsAndBack(t *testing.T) { b := StringsToBaseURLs(ss) require.EqualStringSlice(t, ss, BaseURLsToStrings(b)) } + +func TestReadFromStringWithOptionsAppliesSteeringOptionsForWrite(t *testing.T) { + xml := ` + + + v/ + +` + m, err := ReadFromStringWithOptions(xml, &Options{ContentSteering: &ContentSteeringOptions{ + SteeringURI: "https://steer.example/steer", + DefaultServiceLocation: "cdn-a", + SteeringBaseURLs: []SteeredBaseURL{ + {Location: "cdn-a", Value: "https://cdn-a.example/out/"}, + {Location: "cdn-b", Value: "https://cdn-b.example/out/"}, + }, + }}) + require.NoError(t, err) + out, err := m.WriteToString() + require.NoError(t, err) + if !strings.Contains(out, "