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