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, "