Initial commit: Null DRM Official
Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
This commit is contained in:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
50
apps/pkg/mpd/kid.go
Normal file
50
apps/pkg/mpd/kid.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package mpd
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var reDefaultKID = regexp.MustCompile(`(?i)default_KID="([^"]+)"`)
|
||||
|
||||
// KIDToUUID normalizes any hex-ish KID spelling to dashed UUID form.
|
||||
func KIDToUUID(kid string) (string, error) {
|
||||
var b strings.Builder
|
||||
for _, r := range kid {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
h := strings.ToLower(b.String())
|
||||
if len(h) != 32 {
|
||||
return "", fmt.Errorf("invalid KID %q", kid)
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%s-%s-%s", h[0:8], h[8:12], h[12:16], h[16:20], h[20:32]), nil
|
||||
}
|
||||
|
||||
// KIDHex strips dashes and lowercases a KID (the form used as a map key).
|
||||
func KIDHex(kid string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(kid, "-", ""))
|
||||
}
|
||||
|
||||
// ExtractDefaultKID pulls cenc:default_KID out of an MPD, as dashed UUID.
|
||||
func ExtractDefaultKID(xmlText string) string {
|
||||
m := reDefaultKID.FindStringSubmatch(xmlText)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
u, err := KIDToUUID(m[1])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// XMLEscape escapes text for inclusion in an XML element body.
|
||||
func XMLEscape(s string) string {
|
||||
var b strings.Builder
|
||||
_ = xml.EscapeText(&b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
15
apps/pkg/mpd/mpd.go
Normal file
15
apps/pkg/mpd/mpd.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package mpd
|
||||
|
||||
// Rewriter transforms an upstream live MPD into something N_m3u8DL-RE can
|
||||
// refresh safely. Apps that need DAI/colon stripping or synthetic timelines
|
||||
// implement this; others return Passthrough.
|
||||
type Rewriter interface {
|
||||
Name() string
|
||||
Rewrite(upstreamXML []byte, kidHint string) (out []byte, err error)
|
||||
}
|
||||
|
||||
// Passthrough leaves the MPD unchanged.
|
||||
type Passthrough struct{}
|
||||
|
||||
func (Passthrough) Name() string { return "none" }
|
||||
func (Passthrough) Rewrite(in []byte, _ string) ([]byte, error) { return in, nil }
|
||||
59
apps/pkg/mpd/registry.go
Normal file
59
apps/pkg/mpd/registry.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package mpd
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
regMu sync.RWMutex
|
||||
registry = map[string]func() Rewriter{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("none", func() Rewriter { return Passthrough{} })
|
||||
}
|
||||
|
||||
// Register makes a rewriter resolvable by name, so consumers (streamd) can pick
|
||||
// one from stored config without importing the provider that implements it. A
|
||||
// factory is registered rather than an instance because a rewriter may carry
|
||||
// per-stream fallback state.
|
||||
func Register(name string, newRewriter func() Rewriter) {
|
||||
regMu.Lock()
|
||||
defer regMu.Unlock()
|
||||
registry[strings.ToLower(name)] = newRewriter
|
||||
}
|
||||
|
||||
// Lookup builds a fresh rewriter for a name. An empty name, "none" or an unknown
|
||||
// name yields Passthrough with ok=false, so callers can tell "no rewrite needed"
|
||||
// from "rewrite with X".
|
||||
func Lookup(name string) (Rewriter, bool) {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" || key == "none" {
|
||||
return Passthrough{}, false
|
||||
}
|
||||
regMu.RLock()
|
||||
newRewriter, ok := registry[key]
|
||||
regMu.RUnlock()
|
||||
if !ok {
|
||||
return Passthrough{}, false
|
||||
}
|
||||
rw := newRewriter()
|
||||
if _, isPass := rw.(Passthrough); isPass {
|
||||
return rw, false
|
||||
}
|
||||
return rw, true
|
||||
}
|
||||
|
||||
// Names lists registered rewriter names.
|
||||
func Names() []string {
|
||||
regMu.RLock()
|
||||
defer regMu.RUnlock()
|
||||
out := make([]string, 0, len(registry))
|
||||
for k := range registry {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
104
apps/pkg/mpd/server.go
Normal file
104
apps/pkg/mpd/server.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package mpd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KIDResolver is implemented by rewriters that can recover the live channel from
|
||||
// a KID alone. The local server uses it so a rewrite still works after the
|
||||
// upstream (ad-stitched) manifest session has expired and returns 410.
|
||||
type KIDResolver interface {
|
||||
ResolveKID(kidHex string) (channel, originBase string, ok bool)
|
||||
}
|
||||
|
||||
// Primer is implemented by rewriters that want to be told the fallback channel
|
||||
// and origin discovered on earlier requests.
|
||||
type Primer interface {
|
||||
Prime(channel, originBase string)
|
||||
}
|
||||
|
||||
// LocalServer serves a rewritten live MPD on 127.0.0.1 for a downloader to
|
||||
// refresh. Every GET re-fetches upstream and re-runs the rewriter.
|
||||
type LocalServer struct {
|
||||
URL string
|
||||
Upstream string
|
||||
Key string // KID:KEY
|
||||
Headers map[string]string
|
||||
|
||||
rw Rewriter
|
||||
kidHint string
|
||||
ln net.Listener
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
// StartLocal starts the rewrite proxy. rw must not be nil; pass Passthrough{}
|
||||
// to serve upstream unchanged.
|
||||
func StartLocal(upstream, key string, headers map[string]string, rw Rewriter) (*LocalServer, error) {
|
||||
if rw == nil {
|
||||
rw = Passthrough{}
|
||||
}
|
||||
s := &LocalServer{Upstream: upstream, Key: key, Headers: headers, rw: rw}
|
||||
if key != "" && strings.Contains(key, ":") {
|
||||
s.kidHint = strings.SplitN(key, ":", 2)[0]
|
||||
if r, ok := rw.(KIDResolver); ok {
|
||||
if ch, base, found := r.ResolveKID(KIDHex(s.kidHint)); found {
|
||||
if p, ok := rw.(Primer); ok {
|
||||
p.Prime(ch, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ln = ln
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handle)
|
||||
s.httpServer = &http.Server{Handler: mux}
|
||||
s.URL = fmt.Sprintf("http://%s/manifest.mpd", ln.Addr().String())
|
||||
go func() { _ = s.httpServer.Serve(ln) }()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *LocalServer) Close() {
|
||||
if s.httpServer != nil {
|
||||
_ = s.httpServer.Close()
|
||||
}
|
||||
if s.ln != nil {
|
||||
_ = s.ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LocalServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
body := ""
|
||||
req, err := http.NewRequest(http.MethodGet, s.Upstream, nil)
|
||||
if err == nil {
|
||||
for k, v := range s.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(b) > 0 {
|
||||
body = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Upstream being gone is not fatal: a rewriter with a KID fallback can still
|
||||
// publish the encoder timeline so the downloader keeps pulling segments.
|
||||
payload, err := s.rw.Rewrite([]byte(body), s.kidHint)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/dash+xml")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
142
apps/pkg/mpd/smooth.go
Normal file
142
apps/pkg/mpd/smooth.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package mpd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DefaultMSSTimescale is the Smooth Streaming tick rate (100ns units).
|
||||
const DefaultMSSTimescale = 10_000_000
|
||||
|
||||
var (
|
||||
reSegDur = regexp.MustCompile(`\bd="(\d+)"`)
|
||||
reSegT = regexp.MustCompile(`\bt="(\d+)"`)
|
||||
reSegR = regexp.MustCompile(`\br="(\d+)"`)
|
||||
reSegEntry = regexp.MustCompile(`<c\s+([^/]*)/>`)
|
||||
)
|
||||
|
||||
// ScaleMSS converts a Smooth Streaming tick to another timescale without
|
||||
// overflowing int64 (MSS times are ~1e16; mss*48000 does not fit).
|
||||
func ScaleMSS(mss, fromScale, toScale int64) int64 {
|
||||
if fromScale <= 0 {
|
||||
fromScale = DefaultMSSTimescale
|
||||
}
|
||||
return mss/fromScale*toScale + (mss%fromScale)*toScale/fromScale
|
||||
}
|
||||
|
||||
// Grid is the segment duration + phase of one encoder channel, per media type.
|
||||
type Grid struct {
|
||||
VDur, ADur int64
|
||||
VMod, AMod int64
|
||||
}
|
||||
|
||||
// GridSpec describes how to learn a Grid from an origin's Smooth manifest.
|
||||
type GridSpec struct {
|
||||
// ManifestPath is appended to the origin base (e.g. "Manifest").
|
||||
ManifestPath string
|
||||
// VideoName / AudioName are StreamIndex Name (or Type) attributes.
|
||||
VideoName, AudioName string
|
||||
// Timescales the DASH output uses.
|
||||
VideoTimescale, AudioTimescale int64
|
||||
MSSTimescale int64
|
||||
// FallbackSegmentDuration in MSS ticks when the manifest has no d="".
|
||||
FallbackSegmentDuration int64
|
||||
}
|
||||
|
||||
var (
|
||||
gridMu sync.Mutex
|
||||
gridCache = map[string]Grid{}
|
||||
)
|
||||
|
||||
// LearnGrid fetches the origin Smooth manifest once per channel and derives the
|
||||
// segment duration and phase offset for video and audio.
|
||||
func LearnGrid(channel, originBase string, spec GridSpec) (Grid, error) {
|
||||
gridMu.Lock()
|
||||
if g, ok := gridCache[channel]; ok {
|
||||
gridMu.Unlock()
|
||||
return g, nil
|
||||
}
|
||||
gridMu.Unlock()
|
||||
|
||||
if !strings.HasSuffix(originBase, "/") {
|
||||
originBase += "/"
|
||||
}
|
||||
url := originBase + spec.ManifestPath
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return Grid{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return Grid{}, err
|
||||
}
|
||||
text := string(body)
|
||||
video := ParseMSSStreamTimes(text, spec.VideoName)
|
||||
audio := ParseMSSStreamTimes(text, spec.AudioName)
|
||||
mssDur := spec.FallbackSegmentDuration
|
||||
if m := reSegDur.FindStringSubmatch(text); len(m) == 2 {
|
||||
if v, e := strconv.ParseInt(m[1], 10, 64); e == nil {
|
||||
mssDur = v
|
||||
}
|
||||
}
|
||||
if len(video) == 0 || len(audio) == 0 {
|
||||
return Grid{}, fmt.Errorf("encoder Manifest missing streams at %s", url)
|
||||
}
|
||||
ms := spec.MSSTimescale
|
||||
vDur := ScaleMSS(mssDur, ms, spec.VideoTimescale)
|
||||
aDur := ScaleMSS(mssDur, ms, spec.AudioTimescale)
|
||||
g := Grid{
|
||||
VDur: vDur,
|
||||
ADur: aDur,
|
||||
VMod: ScaleMSS(video[len(video)-1], ms, spec.VideoTimescale) % vDur,
|
||||
AMod: ScaleMSS(audio[len(audio)-1], ms, spec.AudioTimescale) % aDur,
|
||||
}
|
||||
gridMu.Lock()
|
||||
gridCache[channel] = g
|
||||
gridMu.Unlock()
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// ParseMSSStreamTimes expands the <c> timeline of one StreamIndex into
|
||||
// absolute MSS tick times. streamName matches Name="" or Type="".
|
||||
func ParseMSSStreamTimes(manifestXML, streamName string) []int64 {
|
||||
reBlock := regexp.MustCompile(`(?is)<StreamIndex\b[^>]*\bName="` + regexp.QuoteMeta(streamName) + `"[^>]*>(.*?)</StreamIndex>`)
|
||||
m := reBlock.FindStringSubmatch(manifestXML)
|
||||
if m == nil {
|
||||
reBlock = regexp.MustCompile(`(?is)<StreamIndex\b[^>]*\bType="` + regexp.QuoteMeta(streamName) + `"[^>]*>(.*?)</StreamIndex>`)
|
||||
m = reBlock.FindStringSubmatch(manifestXML)
|
||||
}
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
var times []int64
|
||||
tCur := int64(-1)
|
||||
for _, c := range reSegEntry.FindAllStringSubmatch(m[1], -1) {
|
||||
attrs := c[1]
|
||||
tm := reSegT.FindStringSubmatch(attrs)
|
||||
dm := reSegDur.FindStringSubmatch(attrs)
|
||||
rm := reSegR.FindStringSubmatch(attrs)
|
||||
if tm != nil {
|
||||
tCur, _ = strconv.ParseInt(tm[1], 10, 64)
|
||||
}
|
||||
if tCur < 0 || dm == nil {
|
||||
continue
|
||||
}
|
||||
d, _ := strconv.ParseInt(dm[1], 10, 64)
|
||||
r := int64(0)
|
||||
if rm != nil {
|
||||
r, _ = strconv.ParseInt(rm[1], 10, 64)
|
||||
}
|
||||
for i := int64(0); i <= r; i++ {
|
||||
times = append(times, tCur)
|
||||
tCur += d
|
||||
}
|
||||
}
|
||||
return times
|
||||
}
|
||||
280
apps/pkg/mpd/synthetic.go
Normal file
280
apps/pkg/mpd/synthetic.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package mpd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SyntheticConfig describes a live encoder's DASH grid well enough to publish a
|
||||
// synthetic manifest for it. Every value a provider could differ on lives here;
|
||||
// nothing in this package names a provider.
|
||||
type SyntheticConfig struct {
|
||||
// Timescales.
|
||||
MSSTimescale int64 `yaml:"mss_timescale"`
|
||||
VideoTimescale int64 `yaml:"video_timescale"`
|
||||
AudioTimescale int64 `yaml:"audio_timescale"`
|
||||
|
||||
// Smooth Streaming stream names used to learn the segment grid.
|
||||
ManifestPath string `yaml:"manifest_path"`
|
||||
VideoName string `yaml:"video_name"`
|
||||
AudioName string `yaml:"audio_name"`
|
||||
|
||||
// Segment path appended to the origin base for DASH segments.
|
||||
SegmentPathSuffix string `yaml:"segment_path_suffix"`
|
||||
|
||||
// Representation attributes.
|
||||
VideoBandwidth int `yaml:"video_bandwidth"`
|
||||
AudioBandwidth int `yaml:"audio_bandwidth"`
|
||||
VideoCodecs string `yaml:"video_codecs"`
|
||||
AudioCodecs string `yaml:"audio_codecs"`
|
||||
Width int `yaml:"width"`
|
||||
Height int `yaml:"height"`
|
||||
AudioSamplingRate int `yaml:"audio_sampling_rate"`
|
||||
AudioChannels int `yaml:"audio_channels"`
|
||||
Lang string `yaml:"lang"`
|
||||
|
||||
// Segment naming. "{channel}" is substituted; empty means derive as
|
||||
// <channel>-<stream name>=<bandwidth>[-$Time$].<ext>.
|
||||
VideoInitTemplate string `yaml:"video_init_template"`
|
||||
VideoMediaTemplate string `yaml:"video_media_template"`
|
||||
AudioInitTemplate string `yaml:"audio_init_template"`
|
||||
AudioMediaTemplate string `yaml:"audio_media_template"`
|
||||
SegmentExt string `yaml:"segment_ext"`
|
||||
|
||||
// Timeline shape.
|
||||
LiveEdgeOffset float64 `yaml:"live_edge_offset_s"`
|
||||
WindowSegments int64 `yaml:"window_segments"`
|
||||
FallbackSegmentDuration int64 `yaml:"fallback_segment_duration"`
|
||||
|
||||
// MPD-level durations, as ISO-8601 strings.
|
||||
MinUpdatePeriod string `yaml:"min_update_period"`
|
||||
MinBufferTime string `yaml:"min_buffer_time"`
|
||||
TimeShiftBufferDepth string `yaml:"time_shift_buffer_depth"`
|
||||
MaxSegmentDuration string `yaml:"max_segment_duration"`
|
||||
}
|
||||
|
||||
// WithDefaults fills anything left zero with the common Smooth-to-DASH values.
|
||||
// These are container mechanics, not provider identity, so defaulting is safe.
|
||||
func (c SyntheticConfig) WithDefaults() SyntheticConfig {
|
||||
if c.MSSTimescale == 0 {
|
||||
c.MSSTimescale = DefaultMSSTimescale
|
||||
}
|
||||
if c.VideoTimescale == 0 {
|
||||
c.VideoTimescale = 600
|
||||
}
|
||||
if c.AudioTimescale == 0 {
|
||||
c.AudioTimescale = 48_000
|
||||
}
|
||||
if c.ManifestPath == "" {
|
||||
c.ManifestPath = "Manifest"
|
||||
}
|
||||
if c.VideoName == "" {
|
||||
c.VideoName = "video"
|
||||
}
|
||||
if c.AudioName == "" {
|
||||
c.AudioName = "audio_128k"
|
||||
}
|
||||
if c.SegmentPathSuffix == "" {
|
||||
c.SegmentPathSuffix = "dash/"
|
||||
}
|
||||
if c.VideoBandwidth == 0 {
|
||||
c.VideoBandwidth = 6_000_000
|
||||
}
|
||||
if c.AudioBandwidth == 0 {
|
||||
c.AudioBandwidth = 128_000
|
||||
}
|
||||
if c.VideoCodecs == "" {
|
||||
c.VideoCodecs = "avc1.640028"
|
||||
}
|
||||
if c.AudioCodecs == "" {
|
||||
c.AudioCodecs = "mp4a.40.2"
|
||||
}
|
||||
if c.Width == 0 {
|
||||
c.Width = 1920
|
||||
}
|
||||
if c.Height == 0 {
|
||||
c.Height = 1080
|
||||
}
|
||||
if c.AudioSamplingRate == 0 {
|
||||
c.AudioSamplingRate = 48_000
|
||||
}
|
||||
if c.AudioChannels == 0 {
|
||||
c.AudioChannels = 2
|
||||
}
|
||||
if c.Lang == "" {
|
||||
c.Lang = "en"
|
||||
}
|
||||
if c.SegmentExt == "" {
|
||||
c.SegmentExt = "dash"
|
||||
}
|
||||
if c.LiveEdgeOffset == 0 {
|
||||
c.LiveEdgeOffset = 12
|
||||
}
|
||||
if c.WindowSegments == 0 {
|
||||
c.WindowSegments = 6
|
||||
}
|
||||
if c.FallbackSegmentDuration == 0 {
|
||||
c.FallbackSegmentDuration = 38_400_000
|
||||
}
|
||||
if c.MinUpdatePeriod == "" {
|
||||
c.MinUpdatePeriod = "PT2S"
|
||||
}
|
||||
if c.MinBufferTime == "" {
|
||||
c.MinBufferTime = "PT8S"
|
||||
}
|
||||
if c.TimeShiftBufferDepth == "" {
|
||||
c.TimeShiftBufferDepth = "PT1M"
|
||||
}
|
||||
if c.MaxSegmentDuration == "" {
|
||||
c.MaxSegmentDuration = "PT4S"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// GridSpec is the LearnGrid view of this config, exported so a provider module can
|
||||
// learn the same encoder grid the builder uses.
|
||||
func (c SyntheticConfig) GridSpec() GridSpec {
|
||||
return GridSpec{
|
||||
ManifestPath: c.ManifestPath,
|
||||
VideoName: c.VideoName,
|
||||
AudioName: c.AudioName,
|
||||
VideoTimescale: c.VideoTimescale,
|
||||
AudioTimescale: c.AudioTimescale,
|
||||
MSSTimescale: c.MSSTimescale,
|
||||
FallbackSegmentDuration: c.FallbackSegmentDuration,
|
||||
}
|
||||
}
|
||||
|
||||
func (c SyntheticConfig) segName(tmpl, channel, stream string, bandwidth int, withTime bool) string {
|
||||
if tmpl != "" {
|
||||
return strings.ReplaceAll(tmpl, "{channel}", channel)
|
||||
}
|
||||
if withTime {
|
||||
return fmt.Sprintf("%s-%s=%d-$Time$.%s", channel, stream, bandwidth, c.SegmentExt)
|
||||
}
|
||||
return fmt.Sprintf("%s-%s=%d.%s", channel, stream, bandwidth, c.SegmentExt)
|
||||
}
|
||||
|
||||
type anchor struct {
|
||||
V, A int64
|
||||
}
|
||||
|
||||
var (
|
||||
anchorMu sync.Mutex
|
||||
anchors = map[string]*anchor{}
|
||||
)
|
||||
|
||||
const syntheticTemplate = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:cenc="urn:mpeg:cenc:2013"
|
||||
profiles="urn:mpeg:dash:profile:isoff-live:2011" type="dynamic"
|
||||
availabilityStartTime="1970-01-01T00:00:00Z" publishTime="%s"
|
||||
minimumUpdatePeriod="%s" minBufferTime="%s" timeShiftBufferDepth="%s"
|
||||
maxSegmentDuration="%s">
|
||||
<BaseURL>%s</BaseURL>
|
||||
<Period id="1" start="PT0S">
|
||||
<AdaptationSet id="1" contentType="video" mimeType="video/mp4" lang="%s">
|
||||
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="%s"/>
|
||||
<Representation id="r0" bandwidth="%d" codecs="%s" width="%d" height="%d">
|
||||
<SegmentTemplate timescale="%d" initialization="%s" media="%s" startNumber="1">
|
||||
<SegmentTimeline><S t="%d" d="%d" r="%d"/></SegmentTimeline>
|
||||
</SegmentTemplate>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
<AdaptationSet id="2" contentType="audio" mimeType="audio/mp4" lang="%s">
|
||||
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="%s"/>
|
||||
<Representation id="r1" bandwidth="%d" codecs="%s" audioSamplingRate="%d">
|
||||
<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="%d"/>
|
||||
<SegmentTemplate timescale="%d" initialization="%s" media="%s" startNumber="1">
|
||||
<SegmentTimeline><S t="%d" d="%d" r="%d"/></SegmentTimeline>
|
||||
</SegmentTemplate>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
</Period>
|
||||
</MPD>
|
||||
`
|
||||
|
||||
// BuildSynthetic publishes a dynamic MPD on the encoder's own segment grid, so a
|
||||
// downloader keeps fetching live segments even once the upstream (ad-stitched)
|
||||
// manifest session has expired.
|
||||
func BuildSynthetic(cfg SyntheticConfig, channel, originBase, kid string) ([]byte, error) {
|
||||
cfg = cfg.WithDefaults()
|
||||
if !strings.HasSuffix(originBase, "/") {
|
||||
originBase += "/"
|
||||
}
|
||||
segBase := originBase + cfg.SegmentPathSuffix
|
||||
kidU, err := KIDToUUID(kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grid, err := LearnGrid(channel, originBase, cfg.GridSpec())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Float seconds so sub-second phase stays close to the encoder.
|
||||
now := float64(time.Now().UTC().UnixNano())/1e9 - cfg.LiveEdgeOffset
|
||||
align := func(ts, dur, mod int64) int64 {
|
||||
raw := int64(now * float64(ts))
|
||||
return raw - (raw-mod)%dur
|
||||
}
|
||||
vt := align(cfg.VideoTimescale, grid.VDur, grid.VMod)
|
||||
at := align(cfg.AudioTimescale, grid.ADur, grid.AMod)
|
||||
maxCount := cfg.WindowSegments
|
||||
|
||||
anchorMu.Lock()
|
||||
a := anchors[channel]
|
||||
if a == nil {
|
||||
a = &anchor{
|
||||
V: vt - (maxCount-1)*grid.VDur,
|
||||
A: at - (maxCount-1)*grid.ADur,
|
||||
}
|
||||
anchors[channel] = a
|
||||
}
|
||||
vStart, aStart := a.V, a.A
|
||||
if vt > vStart+(maxCount-1)*grid.VDur {
|
||||
vStart = vt - (maxCount-1)*grid.VDur
|
||||
aStart = at - (maxCount-1)*grid.ADur
|
||||
a.V, a.A = vStart, aStart
|
||||
} else if vt < vStart {
|
||||
vStart = vt - (maxCount-1)*grid.VDur
|
||||
aStart = at - (maxCount-1)*grid.ADur
|
||||
a.V, a.A = vStart, aStart
|
||||
}
|
||||
anchorMu.Unlock()
|
||||
|
||||
vCount := (vt-vStart)/grid.VDur + 1
|
||||
aCount := (at-aStart)/grid.ADur + 1
|
||||
if vCount < 1 {
|
||||
vCount = 1
|
||||
}
|
||||
if aCount < 1 {
|
||||
aCount = 1
|
||||
}
|
||||
if vCount > maxCount {
|
||||
vStart = vt - (maxCount-1)*grid.VDur
|
||||
aStart = at - (maxCount-1)*grid.ADur
|
||||
vCount, aCount = maxCount, maxCount
|
||||
anchorMu.Lock()
|
||||
anchors[channel] = &anchor{V: vStart, A: aStart}
|
||||
anchorMu.Unlock()
|
||||
}
|
||||
|
||||
published := time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
|
||||
vInit := cfg.segName(cfg.VideoInitTemplate, channel, cfg.VideoName, cfg.VideoBandwidth, false)
|
||||
vMedia := cfg.segName(cfg.VideoMediaTemplate, channel, cfg.VideoName, cfg.VideoBandwidth, true)
|
||||
aInit := cfg.segName(cfg.AudioInitTemplate, channel, cfg.AudioName, cfg.AudioBandwidth, false)
|
||||
aMedia := cfg.segName(cfg.AudioMediaTemplate, channel, cfg.AudioName, cfg.AudioBandwidth, true)
|
||||
|
||||
out := fmt.Sprintf(syntheticTemplate,
|
||||
published, cfg.MinUpdatePeriod, cfg.MinBufferTime, cfg.TimeShiftBufferDepth, cfg.MaxSegmentDuration,
|
||||
XMLEscape(segBase),
|
||||
cfg.Lang, kidU,
|
||||
cfg.VideoBandwidth, cfg.VideoCodecs, cfg.Width, cfg.Height,
|
||||
cfg.VideoTimescale, vInit, vMedia, vStart, grid.VDur, vCount-1,
|
||||
cfg.Lang, kidU,
|
||||
cfg.AudioBandwidth, cfg.AudioCodecs, cfg.AudioSamplingRate, cfg.AudioChannels,
|
||||
cfg.AudioTimescale, aInit, aMedia, aStart, grid.ADur, aCount-1,
|
||||
)
|
||||
return []byte(out), nil
|
||||
}
|
||||
264
apps/pkg/mpd/synthetic_test.go
Normal file
264
apps/pkg/mpd/synthetic_test.go
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
package mpd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A Smooth Streaming manifest shaped like a live encoder serves: 4s segments at
|
||||
// the 10MHz MSS tick rate.
|
||||
const smoothManifest = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<SmoothStreamingMedia MajorVersion="2" MinorVersion="0" TimeScale="10000000" IsLive="TRUE">
|
||||
<StreamIndex Type="video" Name="video" Chunks="0" QualityLevels="1" Url="QualityLevels({bitrate})/Fragments(video={start time})">
|
||||
<QualityLevel Index="0" Bitrate="6000000" FourCC="H264" MaxWidth="1920" MaxHeight="1080"/>
|
||||
<c t="17000000000000" d="40000000" r="3"/>
|
||||
</StreamIndex>
|
||||
<StreamIndex Type="audio" Name="audio_128k" Chunks="0" QualityLevels="1" Url="QualityLevels({bitrate})/Fragments(audio_128k={start time})">
|
||||
<QualityLevel Index="0" Bitrate="128000" FourCC="AACL" SamplingRate="48000"/>
|
||||
<c t="17000000000000" d="40000000" r="3"/>
|
||||
</StreamIndex>
|
||||
</SmoothStreamingMedia>`
|
||||
|
||||
func originServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/Manifest") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(smoothManifest))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL + "/live/test.isml/"
|
||||
}
|
||||
|
||||
// resetGridCache keeps tests independent — the grid and anchor caches are global
|
||||
// so a live stream learns its encoder phase only once.
|
||||
func resetGridCache(channel string) {
|
||||
gridMu.Lock()
|
||||
delete(gridCache, channel)
|
||||
gridMu.Unlock()
|
||||
anchorMu.Lock()
|
||||
delete(anchors, channel)
|
||||
anchorMu.Unlock()
|
||||
}
|
||||
|
||||
func TestBuildSyntheticShape(t *testing.T) {
|
||||
resetGridCache("vctest")
|
||||
base := originServer(t)
|
||||
out, err := BuildSynthetic(SyntheticConfig{}, "vctest", base, "df163382-1ddd-fdd5-bec9-822c1ec0f052")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(out)
|
||||
|
||||
// The default grid is the one the live RTE pipeline was tuned with.
|
||||
for _, want := range []string{
|
||||
`type="dynamic"`,
|
||||
`minimumUpdatePeriod="PT2S"`,
|
||||
`minBufferTime="PT8S"`,
|
||||
`timeShiftBufferDepth="PT1M"`,
|
||||
`maxSegmentDuration="PT4S"`,
|
||||
`cenc:default_KID="df163382-1ddd-fdd5-bec9-822c1ec0f052"`,
|
||||
`timescale="600"`,
|
||||
`timescale="48000"`,
|
||||
`bandwidth="6000000"`,
|
||||
`bandwidth="128000"`,
|
||||
`codecs="avc1.640028"`,
|
||||
`codecs="mp4a.40.2"`,
|
||||
`width="1920" height="1080"`,
|
||||
`audioSamplingRate="48000"`,
|
||||
`initialization="vctest-video=6000000.dash"`,
|
||||
`media="vctest-video=6000000-$Time$.dash"`,
|
||||
`initialization="vctest-audio_128k=128000.dash"`,
|
||||
`media="vctest-audio_128k=128000-$Time$.dash"`,
|
||||
base + "dash/",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("synthetic MPD missing %q\n---\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The published window must be WindowSegments long: r = count-1.
|
||||
func TestBuildSyntheticWindowSize(t *testing.T) {
|
||||
resetGridCache("vcwin")
|
||||
base := originServer(t)
|
||||
out, err := BuildSynthetic(SyntheticConfig{WindowSegments: 6}, "vcwin", base, "df1633821dddfdd5bec9822c1ec0f052")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
re := regexp.MustCompile(`<S t="(\d+)" d="(\d+)" r="(\d+)"/>`)
|
||||
ms := re.FindAllStringSubmatch(string(out), -1)
|
||||
if len(ms) != 2 {
|
||||
t.Fatalf("want 2 SegmentTimelines, got %d", len(ms))
|
||||
}
|
||||
for i, m := range ms {
|
||||
r, _ := strconv.Atoi(m[3])
|
||||
if r != 5 {
|
||||
t.Errorf("timeline %d: r=%d, want 5 (6 segments)", i, r)
|
||||
}
|
||||
d, _ := strconv.Atoi(m[2])
|
||||
// 4s segments: 4*600=2400 video ticks, 4*48000=192000 audio ticks.
|
||||
want := []int{2400, 192000}[i]
|
||||
if d != want {
|
||||
t.Errorf("timeline %d: d=%d, want %d", i, d, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Segment times must land on the encoder's grid, i.e. share its phase offset,
|
||||
// or the downloader requests segments the origin does not have.
|
||||
func TestBuildSyntheticAlignsToEncoderPhase(t *testing.T) {
|
||||
resetGridCache("vcphase")
|
||||
base := originServer(t)
|
||||
grid, err := LearnGrid("vcphase", base, SyntheticConfig{}.WithDefaults().GridSpec())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := BuildSynthetic(SyntheticConfig{}, "vcphase", base, "df1633821dddfdd5bec9822c1ec0f052")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
re := regexp.MustCompile(`<S t="(\d+)" d="(\d+)" r="\d+"/>`)
|
||||
ms := re.FindAllStringSubmatch(string(out), -1)
|
||||
vStart, _ := strconv.ParseInt(ms[0][1], 10, 64)
|
||||
if got := ((vStart-grid.VMod)%grid.VDur + grid.VDur) % grid.VDur; got != 0 {
|
||||
t.Errorf("video start %d is off-grid by %d (dur=%d mod=%d)", vStart, got, grid.VDur, grid.VMod)
|
||||
}
|
||||
aStart, _ := strconv.ParseInt(ms[1][1], 10, 64)
|
||||
if got := ((aStart-grid.AMod)%grid.ADur + grid.ADur) % grid.ADur; got != 0 {
|
||||
t.Errorf("audio start %d is off-grid by %d (dur=%d mod=%d)", aStart, got, grid.ADur, grid.AMod)
|
||||
}
|
||||
}
|
||||
|
||||
// Every provider-shaped literal must be overridable from config.
|
||||
func TestBuildSyntheticHonoursConfig(t *testing.T) {
|
||||
resetGridCache("ch9")
|
||||
base := originServer(t)
|
||||
cfg := SyntheticConfig{
|
||||
VideoName: "video",
|
||||
AudioName: "audio_128k",
|
||||
VideoBandwidth: 3_000_000,
|
||||
AudioBandwidth: 96_000,
|
||||
VideoCodecs: "hvc1.1.6.L93.B0",
|
||||
AudioCodecs: "mp4a.40.5",
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
Lang: "ga",
|
||||
SegmentPathSuffix: "cmaf/",
|
||||
VideoInitTemplate: "{channel}-v-init.m4s",
|
||||
VideoMediaTemplate: "{channel}-v-$Time$.m4s",
|
||||
MinUpdatePeriod: "PT4S",
|
||||
}
|
||||
out, err := BuildSynthetic(cfg, "ch9", base, "df1633821dddfdd5bec9822c1ec0f052")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(out)
|
||||
for _, want := range []string{
|
||||
`bandwidth="3000000"`, `bandwidth="96000"`,
|
||||
`codecs="hvc1.1.6.L93.B0"`, `codecs="mp4a.40.5"`,
|
||||
`width="1280" height="720"`, `lang="ga"`,
|
||||
`minimumUpdatePeriod="PT4S"`,
|
||||
base + "cmaf/",
|
||||
`initialization="ch9-v-init.m4s"`,
|
||||
`media="ch9-v-$Time$.m4s"`,
|
||||
// audio templates were left empty, so they derive from name + bandwidth
|
||||
`initialization="ch9-audio_128k=96000.dash"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q\n---\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `width="1920"`) || strings.Contains(got, `codecs="avc1`) {
|
||||
t.Error("a default leaked past the config override")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSyntheticRejectsBadKID(t *testing.T) {
|
||||
resetGridCache("vcbad")
|
||||
base := originServer(t)
|
||||
if _, err := BuildSynthetic(SyntheticConfig{}, "vcbad", base, "not-a-kid"); err == nil {
|
||||
t.Fatal("expected an error for a malformed KID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaleMSSDoesNotOverflow(t *testing.T) {
|
||||
// A real MSS time (~1.7e16); mss*48000 overflows int64 if done naively.
|
||||
const mss = int64(17_000_000_000_000_000)
|
||||
got := ScaleMSS(mss, DefaultMSSTimescale, 48_000)
|
||||
want := int64(17_000_000_000_000_000 / 10_000_000 * 48_000)
|
||||
if got != want {
|
||||
t.Errorf("ScaleMSS = %d, want %d", got, want)
|
||||
}
|
||||
if got < 0 {
|
||||
t.Error("ScaleMSS overflowed to a negative value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMSSStreamTimesExpandsRepeats(t *testing.T) {
|
||||
times := ParseMSSStreamTimes(smoothManifest, "video")
|
||||
if len(times) != 4 {
|
||||
t.Fatalf("got %d segment times, want 4 (r=3)", len(times))
|
||||
}
|
||||
for i, ts := range times {
|
||||
want := int64(17_000_000_000_000) + int64(i)*40_000_000
|
||||
if ts != want {
|
||||
t.Errorf("time[%d] = %d, want %d", i, ts, want)
|
||||
}
|
||||
}
|
||||
if ParseMSSStreamTimes(smoothManifest, "nope") != nil {
|
||||
t.Error("unknown stream name should yield no times")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKIDToUUIDAndHex(t *testing.T) {
|
||||
u, err := KIDToUUID("DF1633821DDDFDD5BEC9822C1EC0F052")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u != "df163382-1ddd-fdd5-bec9-822c1ec0f052" {
|
||||
t.Errorf("KIDToUUID = %q", u)
|
||||
}
|
||||
if KIDHex(u) != "df1633821dddfdd5bec9822c1ec0f052" {
|
||||
t.Errorf("KIDHex = %q", KIDHex(u))
|
||||
}
|
||||
if _, err := KIDToUUID("abc"); err == nil {
|
||||
t.Error("short KID should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDefaultKID(t *testing.T) {
|
||||
xml := fmt.Sprintf(`<ContentProtection cenc:default_KID="%s"/>`, "DF163382-1DDD-FDD5-BEC9-822C1EC0F052")
|
||||
if got := ExtractDefaultKID(xml); got != "df163382-1ddd-fdd5-bec9-822c1ec0f052" {
|
||||
t.Errorf("ExtractDefaultKID = %q", got)
|
||||
}
|
||||
if ExtractDefaultKID(`<MPD/>`) != "" {
|
||||
t.Error("no KID should yield empty string")
|
||||
}
|
||||
}
|
||||
|
||||
// An unregistered or "none" rewriter must report that no rewrite is needed.
|
||||
func TestRewriterRegistry(t *testing.T) {
|
||||
if _, needed := Lookup("none"); needed {
|
||||
t.Error(`"none" must not need a rewrite`)
|
||||
}
|
||||
if _, needed := Lookup(""); needed {
|
||||
t.Error("empty name must not need a rewrite")
|
||||
}
|
||||
if _, needed := Lookup("nosuchprovider"); needed {
|
||||
t.Error("unknown name must not need a rewrite")
|
||||
}
|
||||
Register("testrw", func() Rewriter { return Passthrough{} })
|
||||
// Registering a Passthrough still means "no rewrite needed".
|
||||
if _, needed := Lookup("testrw"); needed {
|
||||
t.Error("a passthrough rewriter must not report needing a rewrite")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue