Files
gopeed/internal/protocol/hls/playlist_test.go
T

253 lines
7.9 KiB
Go

package hls
import (
"net/url"
"testing"
)
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u
}
func TestParseMediaTS(t *testing.T) {
content := "#EXTM3U\n" +
"#EXT-X-TARGETDURATION:20\n" +
"#EXT-X-VERSION:3\n" +
"#EXT-X-MEDIA-SEQUENCE:1\n" +
"#EXTINF:20.000,\n" +
"seg-1.ts\n" +
"#EXTINF:20.000,\n" +
"seg-2.ts\n" +
"#EXT-X-ENDLIST\n"
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
if err != nil {
t.Fatal(err)
}
if media.Live {
t.Error("VOD playlist should not be live")
}
if media.IsFMP4 {
t.Error("TS playlist should not be fMP4")
}
if len(media.Segments) != 2 {
t.Fatalf("want 2 segments, got %d", len(media.Segments))
}
want := "https://cdn.example.com/vod/seg-1.ts"
if media.Segments[0].URI != want {
t.Errorf("want uri %s, got %s", want, media.Segments[0].URI)
}
if media.Segments[0].Sequence != 1 {
t.Errorf("want sequence 1, got %d", media.Segments[0].Sequence)
}
if media.Segments[0].Key != nil {
t.Error("segment should be unencrypted")
}
}
func TestParseMediaWithoutExtinf(t *testing.T) {
// Some playlists list segment URIs without EXTINF lines.
content := "#EXTM3U\nseg-1.ts\nseg-2.ts\n#EXT-X-ENDLIST\n"
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/a/index.m3u8"))
if err != nil {
t.Fatal(err)
}
if len(media.Segments) != 2 {
t.Fatalf("want 2 segments, got %d", len(media.Segments))
}
}
func TestParseMediaLive(t *testing.T) {
content := "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6.0,\nseg-1.ts\n"
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/live/index.m3u8"))
if err != nil {
t.Fatal(err)
}
if !media.Live {
t.Error("playlist without ENDLIST should be live")
}
}
func TestParseMediaFMP4(t *testing.T) {
content := "#EXTM3U\n" +
"#EXT-X-VERSION:6\n" +
"#EXT-X-MAP:URI=\"init.mp4\"\n" +
"#EXTINF:5.0,\n" +
"seg-1.m4s\n" +
"#EXT-X-ENDLIST\n"
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
if err != nil {
t.Fatal(err)
}
if !media.IsFMP4 {
t.Error("playlist with MAP should be fMP4")
}
if len(media.Segments) != 2 {
t.Fatalf("want 2 segments (init + 1), got %d", len(media.Segments))
}
if !media.Segments[0].IsInit {
t.Error("first segment should be the init section")
}
if media.Segments[0].URI != "https://cdn.example.com/vod/init.mp4" {
t.Errorf("init uri wrong: %s", media.Segments[0].URI)
}
if media.OutputExtension() != ".mp4" {
t.Errorf("want .mp4, got %s", media.OutputExtension())
}
}
func TestParseMediaMultiMapError(t *testing.T) {
content := "#EXTM3U\n" +
"#EXT-X-MAP:URI=\"init1.mp4\"\n" +
"#EXTINF:5.0,\nseg-1.m4s\n" +
"#EXT-X-DISCONTINUITY\n" +
"#EXT-X-MAP:URI=\"init2.mp4\"\n" +
"#EXTINF:5.0,\nseg-2.m4s\n" +
"#EXT-X-ENDLIST\n"
if _, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")); err == nil {
t.Error("multi-period fMP4 should be rejected")
}
}
func TestParseMediaKeyRotation(t *testing.T) {
content := "#EXTM3U\n" +
"#EXT-X-KEY:METHOD=AES-128,URI=\"key1.bin\",IV=0x00000000000000000000000000000001\n" +
"#EXTINF:5.0,\nseg-1.ts\n" +
"#EXT-X-KEY:METHOD=AES-128,URI=\"key2.bin\"\n" +
"#EXTINF:5.0,\nseg-2.ts\n" +
"#EXT-X-KEY:METHOD=NONE\n" +
"#EXTINF:5.0,\nseg-3.ts\n" +
"#EXT-X-ENDLIST\n"
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
if err != nil {
t.Fatal(err)
}
if media.Segments[0].Key == nil || media.Segments[0].Key.URI != "https://cdn.example.com/vod/key1.bin" {
t.Fatalf("segment 1 key wrong: %+v", media.Segments[0].Key)
}
if len(media.Segments[0].Key.IV) != 16 || media.Segments[0].Key.IV[15] != 1 {
t.Error("explicit IV not parsed")
}
if media.Segments[1].Key == nil || media.Segments[1].Key.URI != "https://cdn.example.com/vod/key2.bin" {
t.Fatalf("segment 2 key wrong: %+v", media.Segments[1].Key)
}
if media.Segments[1].Key.IV != nil {
t.Error("key without IV should leave IV nil (sequence-derived)")
}
if media.Segments[2].Key != nil {
t.Error("segment 3 should be unencrypted after METHOD=NONE")
}
}
func TestParseMediaSampleAESError(t *testing.T) {
content := "#EXTM3U\n#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"skd://key\"\n#EXTINF:5.0,\nseg-1.ts\n"
if _, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")); err == nil {
t.Error("SAMPLE-AES should be rejected")
}
}
func TestParseMediaByterange(t *testing.T) {
content := "#EXTM3U\n" +
"#EXT-X-BYTERANGE:1000@0\nseg.ts\n" +
"#EXT-X-BYTERANGE:2000\nseg.ts\n" +
"#EXT-X-BYTERANGE:3000\nseg.ts\n" +
"#EXT-X-ENDLIST\n"
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
if err != nil {
t.Fatal(err)
}
ranges := [][2]int64{{0, 1000}, {1000, 2000}, {3000, 3000}}
for i, want := range ranges {
br := media.Segments[i].Byterange
if br == nil {
t.Fatalf("segment %d byterange missing", i)
}
if br.Offset != want[0] || br.Length != want[1] {
t.Errorf("segment %d: want offset %d length %d, got %d/%d", i, want[0], want[1], br.Offset, br.Length)
}
}
}
func TestParseMediaGap(t *testing.T) {
content := "#EXTM3U\n#EXTINF:5.0,\nseg-1.ts\n#EXT-X-GAP\n#EXTINF:5.0,\nseg-2.ts\n#EXT-X-ENDLIST\n"
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
if err != nil {
t.Fatal(err)
}
if media.Segments[0].Gap || !media.Segments[1].Gap {
t.Error("gap flags wrong")
}
}
func TestParseMediaInvalidHeader(t *testing.T) {
if _, err := ParseMedia("not a playlist", mustURL(t, "https://cdn.example.com/a.m3u8")); err == nil {
t.Error("invalid content should be rejected")
}
}
func TestParseMasterPickBest(t *testing.T) {
content := "#EXTM3U\n" +
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360\n" +
"360p/index.m3u8\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1920x1080,CODECS=\"avc1.640028,mp4a.40.2\"\n" +
"1080p/index.m3u8?sign=abc\n" +
"#EXT-X-ENDLIST\n"
variants, err := ParseMaster(content, mustURL(t, "https://cdn.example.com/vod/master.m3u8"))
if err != nil {
t.Fatal(err)
}
if len(variants) != 2 {
t.Fatalf("want 2 variants, got %d", len(variants))
}
best := PickBestVariant(variants)
if best.Bandwidth != 2500000 {
t.Errorf("want highest bandwidth variant, got %d", best.Bandwidth)
}
if best.URI != "https://cdn.example.com/vod/1080p/index.m3u8?sign=abc" {
t.Errorf("variant uri wrong: %s", best.URI)
}
}
func TestResolveURLQueryNotInherited(t *testing.T) {
base := mustURL(t, "https://cdn.example.com/vod/index.m3u8?sign=abc")
got := resolveURL(base, "seg-1.ts")
if got != "https://cdn.example.com/vod/seg-1.ts" {
t.Errorf("relative resolution should follow RFC3986 (no query inheritance), got %s", got)
}
}
func TestDeriveOutputName(t *testing.T) {
media := &Media{}
if got := DeriveOutputName("https://cdn.example.com/ts2/a/index-v1-a1.m3u8", media); got != "index-v1-a1.ts" {
t.Errorf("want index-v1-a1.ts, got %s", got)
}
fmp4 := &Media{IsFMP4: true}
if got := DeriveOutputName("https://cdn.example.com/ts2/a/index.m3u8", fmp4); got != "index.mp4" {
t.Errorf("want index.mp4, got %s", got)
}
if got := DeriveOutputName("https://cdn.example.com/play?token=x", media); got != "play.ts" {
t.Errorf("want play.ts, got %s", got)
}
if got := DeriveOutputName("https://cdn.example.com/", media); got != "cdn.example.com.ts" {
t.Errorf("want host fallback, got %s", got)
}
}
func TestParseAttributesQuotedComma(t *testing.T) {
attrs := parseAttributes(`URI="a,b.ts",IV=0xABCDEF00000000000000000000000001,NAME="x"`)
if attrs["URI"] != "a,b.ts" {
t.Errorf("quoted comma value wrong: %q", attrs["URI"])
}
if attrs["IV"] != "0xABCDEF00000000000000000000000001" {
t.Errorf("hex value wrong: %q", attrs["IV"])
}
if attrs["NAME"] != "x" {
t.Errorf("quoted value wrong: %q", attrs["NAME"])
}
}