diff --git a/mpd/content_steering.go b/mpd/content_steering.go
new file mode 100644
index 0000000..01b092d
--- /dev/null
+++ b/mpd/content_steering.go
@@ -0,0 +1,78 @@
+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
+}
+
+// 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
new file mode 100644
index 0000000..891a739
--- /dev/null
+++ b/mpd/content_steering_test.go
@@ -0,0 +1,135 @@
+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))
+}
+
+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, "