fix(hls): enforce supported HLS subset with explicit errors

The merge pipeline concatenates stored bytes, so features it cannot
reproduce faithfully are now detected and rejected with a clear error
instead of silently succeeding with a broken output:

- EXT-X-GAP (a hole in the merged file) and EXT-X-DISCONTINUITY
  (a broken timeline)
- separate audio renditions referenced by the selected variant: the
  master EXT-X-MEDIA groups are parsed and an AUDIO group with its own
  URI fails the resolve; unreferenced groups are ignored
- changing init sections: a repeated EXT-X-MAP is compared by all of
  its attributes (URI and BYTERANGE), not just the URI
- encrypted EXT-X-MAP without an explicit IV: the spec does not define
  a sequence-derived IV for init sections
- non-identity AES-128 KEYFORMAT values

The supported matrix is documented in the package doc.

Add regression tests for every rejection plus real media validation:
ffmpeg-generated TS segments and fMP4 init/fragments are committed as
fixtures, downloaded through the fetcher (including an AES-128 key
rotation variant), and the merged outputs are validated structurally -
TS on the 188 byte grid with continuity counter rules, PAT/PMT and PES
presence, fMP4 by walking the ISO-BMFF box hierarchy.
This commit is contained in:
sun committed 2026-09-15 11:42:06 +08:00
1 parent 675028c64a
commit f1f17948c6
12 files changed
+611 -32

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
// Package hls implements native HTTP Live Streaming downloads for m3u8
// playlists. A task produces exactly one merged, playable output file:
// MPEG-2 TS segments are concatenated as-is, fMP4 streams are merged as
// init section followed by the media fragments, and AES-128 encrypted
// segments are decrypted before they are stored.
//
// Supported subset:
// - VOD media playlists (EXT-X-ENDLIST present) and master playlists whose
// selected variant carries its own audio
// - AES-128 with the identity KEYFORMAT, explicit IV or media-sequence
// derived IV, including key rotation mid-playlist
// - EXT-X-BYTERANGE (including offset inheritance) and fMP4 init sections
// via a single, constant EXT-X-MAP
//
// Everything the merge pipeline cannot reproduce faithfully is rejected with
// an explicit error instead of silently producing a broken output file:
// - live streams (no EXT-X-ENDLIST) and SAMPLE-AES / DRM encryption
// - EXT-X-GAP (would leave a hole) and EXT-X-DISCONTINUITY (broken timeline)
// - changing init sections (a repeated EXT-X-MAP is compared by all its
// attributes, not just the URI) and encrypted EXT-X-MAP without an
// explicit IV
// - separate audio renditions referenced by the selected variant (the
// merged output would have no audio) and non-identity KEYFORMATs
package hls
+7 -4
View File
@@ -227,11 +227,14 @@ func (f *Fetcher) resolvePlanLocked(ctx context.Context, keepName string) (*fetc
mediaURL := finalURL
if IsMasterPlaylist(content) {
variants, err := ParseMaster(content, baseURL)
master, err := ParseMasterPlaylist(content, baseURL)
if err != nil {
return nil, "", err
}
best := PickBestVariant(variants)
best := PickBestVariant(master.Variants)
if err := checkAudioRendition(master, best); err != nil {
return nil, "", err
}
variantContent, variantURL, err := f.fetchText(ctx, http.MethodGet, best.URI)
if err != nil {
return nil, "", fmt.Errorf("fetch variant playlist failed: %w", err)
@@ -248,8 +251,8 @@ func (f *Fetcher) resolvePlanLocked(ctx context.Context, keepName string) (*fetc
if err != nil {
return nil, "", err
}
if media.Live {
return nil, "", errors.New("live streams are not supported yet")
if err := validateSupported(media); err != nil {
return nil, "", err
}
outputName := keepName
@@ -0,0 +1,297 @@
package hls
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/GopeedLab/gopeed/pkg/base"
)
// The validators below check real media structure, not just byte
// concatenation: TS packets are verified on the 188 byte grid with
// continuity-counter rules, PAT/PMT presence and PES start codes; fMP4
// output is verified by walking the ISO-BMFF box hierarchy.
func be32(b []byte) int {
return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
}
// validateTS asserts structural validity of a merged MPEG-2 TS stream.
func validateTS(t *testing.T, data []byte) {
t.Helper()
if len(data) == 0 || len(data)%188 != 0 {
t.Fatalf("TS size %d is not a positive multiple of 188", len(data))
}
type pidState struct{ cc int }
ccByPID := map[int]int{}
patSeen, pmtSeen, pesSeen := false, false, false
for off := 0; off < len(data); off += 188 {
pkt := data[off : off+188]
if pkt[0] != 0x47 {
t.Fatalf("bad TS sync byte at offset %d", off)
}
pid := (int(pkt[1]&0x1F) << 8) | int(pkt[2])
pusi := pkt[1]&0x40 != 0
afc := (pkt[3] >> 4) & 0x3
if afc == 0 {
continue // reserved
}
idx := 4
if afc&0x2 != 0 { // adaptation field present
afLen := int(pkt[4])
if afLen > 0 && pkt[5]&0x80 != 0 {
// discontinuity_indicator: restart the CC expectation
delete(ccByPID, pid)
}
idx += 1 + afLen
if afc == 2 {
continue // adaptation only, no payload: no CC rule
}
}
if idx > 188 {
t.Fatalf("adaptation field overruns packet at offset %d", off)
}
if pid == 0x1FFF {
continue // null packet
}
payload := pkt[idx:]
if pusi && len(payload) >= 3 && pid != 0 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
pesSeen = true
}
if pid == 0 {
if ptr := int(payload[0]); 1+ptr < len(payload) && payload[1+ptr] == 0x00 {
patSeen = true // program_association_section table_id
}
} else if pusi && len(payload) >= 2 && payload[0] == 0 && payload[1] == 0x02 {
pmtSeen = true // program_map_section table_id
}
cc := int(pkt[3] & 0x0F)
prev, ok := ccByPID[pid]
if !ok {
ccByPID[pid] = cc
continue
}
// CC increments by one per payload-bearing packet; a repeated value
// models the single legal duplicate packet.
if cc != (prev+1)%16 && cc != prev {
t.Fatalf("continuity error on PID %d: %d after %d at offset %d", pid, cc, prev, off)
}
ccByPID[pid] = cc
}
if !patSeen {
t.Error("no PAT found in merged TS")
}
if !pmtSeen {
t.Error("no PMT found in merged TS")
}
if !pesSeen {
t.Error("no PES start code found in merged TS")
}
}
// walkBoxes iterates the top-level ISO-BMFF boxes of data[off:end].
func walkBoxes(t *testing.T, data []byte, off, end int) []string {
t.Helper()
var types []string
for off+8 <= end {
size := be32(data[off:])
typ := string(data[off+4 : off+8])
if size < 8 || off+size > end {
t.Fatalf("invalid box %q at offset %d (size %d)", typ, off, size)
}
types = append(types, typ)
off += size
}
return types
}
// validateFMP4 asserts the merged fMP4 output is init section followed by
// well-formed moof+mdat fragments.
func validateFMP4(t *testing.T, merged []byte, wantFragments int) {
t.Helper()
types := walkBoxes(t, merged, 0, len(merged))
hasFtyp, hasMoov := false, false
fragments := 0
expectFragment := false
for i, typ := range types {
switch typ {
case "ftyp":
if i != 0 {
t.Errorf("ftyp not at stream start")
}
hasFtyp = true
case "moov":
if i != 1 {
t.Errorf("moov must follow ftyp, found at index %d", i)
}
hasMoov = true
case "styp":
expectFragment = true
case "moof":
if !hasMoov {
t.Errorf("fragment before moov")
}
expectFragment = true
case "mdat":
if !expectFragment {
t.Errorf("mdat outside of a fragment at index %d", i)
}
expectFragment = false
fragments++
case "mfra":
// optional trailer
default:
t.Errorf("unexpected top-level box %q", typ)
}
}
if !hasFtyp || !hasMoov {
t.Error("merged fMP4 missing ftyp/moov init section")
}
if fragments != wantFragments {
t.Errorf("want %d fragments in merged fMP4, got %d", wantFragments, fragments)
}
}
func mediaFixture(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile("testdata/media/" + name)
if err != nil {
t.Fatal(err)
}
return data
}
func serveFixtureServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
fs := http.FileServer(http.Dir("testdata/media"))
mux.Handle("/", fs)
return httptest.NewServer(mux)
}
func TestHLSRealMedia_TS(t *testing.T) {
seg0 := mediaFixture(t, "sample-00.ts")
seg1 := mediaFixture(t, "sample-01.ts")
playlist := "#EXTM3U\n" +
"#EXTINF:0.5,\nsample-00.ts\n" +
"#EXTINF:0.5,\nsample-01.ts\n" +
"#EXT-X-ENDLIST\n"
mux := http.NewServeMux()
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(playlist))
})
mux.Handle("/", http.FileServer(http.Dir("testdata/media")))
server := httptest.NewServer(mux)
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
t.Fatal(err)
}
if err := f.Wait(); err != nil {
t.Fatal(err)
}
got := readOutput(t, f)
if !bytes.Equal(got, append(append([]byte{}, seg0...), seg1...)) {
t.Fatal("merged TS is not the exact concatenation of the segments")
}
validateTS(t, got)
}
func TestHLSRealMedia_FMP4(t *testing.T) {
init := mediaFixture(t, "fmp4-init.mp4")
frag1 := mediaFixture(t, "fmp4-seg1.m4s")
frag2 := mediaFixture(t, "fmp4-seg2.m4s")
frag3 := mediaFixture(t, "fmp4-seg3.m4s")
playlist := "#EXTM3U\n" +
"#EXT-X-MAP:URI=\"fmp4-init.mp4\"\n" +
"#EXTINF:0.5,\nfmp4-seg1.m4s\n" +
"#EXTINF:0.5,\nfmp4-seg2.m4s\n" +
"#EXTINF:0.5,\nfmp4-seg3.m4s\n" +
"#EXT-X-ENDLIST\n"
mux := http.NewServeMux()
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(playlist))
})
mux.Handle("/", http.FileServer(http.Dir("testdata/media")))
server := httptest.NewServer(mux)
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
t.Fatal(err)
}
if err := f.Wait(); err != nil {
t.Fatal(err)
}
got := readOutput(t, f)
want := append(append(append(append([]byte{}, init...), frag1...), frag2...), frag3...)
if !bytes.Equal(got, want) {
t.Fatal("merged fMP4 is not the exact concatenation of init + fragments")
}
validateFMP4(t, got, 3)
}
func TestHLSRealMedia_AES128(t *testing.T) {
keys := [][]byte{[]byte("key-A-0123456789"), []byte("key-B-9876543210")}
iv := []byte("iv-1337-01234567")
plain := [][]byte{mediaFixture(t, "sample-00.ts"), mediaFixture(t, "sample-01.ts")}
playlist := "#EXTM3U\n" +
"#EXT-X-MEDIA-SEQUENCE:0\n" +
"#EXT-X-KEY:METHOD=AES-128,URI=\"key-0.bin\",IV=0x" + hexEncode(iv) + "\n" +
"#EXTINF:0.5,\nenc-0.ts\n" +
"#EXT-X-KEY:METHOD=AES-128,URI=\"key-1.bin\"\n" +
"#EXTINF:0.5,\nenc-1.ts\n" +
"#EXT-X-ENDLIST\n"
mux := http.NewServeMux()
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(playlist))
})
for i, key := range keys {
k := key
mux.HandleFunc(fmt.Sprintf("/key-%d.bin", i), func(w http.ResponseWriter, r *http.Request) {
w.Write(k)
})
// Segment 0 has an explicit IV, segment 1 derives it from its media
// sequence number (1).
enc := encryptSegment(plain[i], k, map[bool][]byte{true: iv, false: sequenceIV(int64(i))}[i == 0])
mux.HandleFunc(fmt.Sprintf("/enc-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
w.Write(enc)
})
}
server := httptest.NewServer(mux)
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
t.Fatal(err)
}
if f.meta.Res.Size != 0 {
t.Errorf("encrypted plan must report indeterminate size, got %d", f.meta.Res.Size)
}
if err := f.Start(); err != nil {
t.Fatal(err)
}
if err := f.Wait(); err != nil {
t.Fatal(err)
}
got := readOutput(t, f)
if !bytes.Equal(got, append(append([]byte{}, plain[0]...), plain[1]...)) {
t.Fatal("decrypted output mismatch")
}
validateTS(t, got)
if p := f.Progress(); p.TotalDownloaded() != int64(len(got)) {
t.Errorf("final progress %d != merged size %d", p.TotalDownloaded(), len(got))
}
}
+127 -28
View File
@@ -18,6 +18,21 @@ type Variant struct {
Resolution string
Codecs string
Name string
AudioGroup string // AUDIO attribute: group id of the audio renditions
}
// MediaRendition is one EXT-X-MEDIA entry of a master playlist. Only the
// fields needed to detect unsupported track layouts are kept.
type MediaRendition struct {
Type string
GroupID string
URI string
}
// MasterPlaylist is a parsed master playlist with its rendition groups.
type MasterPlaylist struct {
Variants []*Variant
Rendition map[string]*MediaRendition // group id -> representative entry
}
// Key is the decryption key applied to the segments that follow its tag, until
@@ -49,10 +64,11 @@ type Segment struct {
// Media is a parsed media playlist.
type Media struct {
Segments []*Segment
Version int
Live bool // no EXT-X-ENDLIST tag
IsFMP4 bool // playlist declares an init section via EXT-X-MAP
Segments []*Segment
Version int
Live bool // no EXT-X-ENDLIST tag
IsFMP4 bool // playlist declares an init section via EXT-X-MAP
Discontinuity bool // playlist contains EXT-X-DISCONTINUITY
}
const (
@@ -82,10 +98,20 @@ func validateM3U8Header(content string) error {
// ParseMaster parses a master playlist and returns all variants.
func ParseMaster(content string, baseURL *url.URL) ([]*Variant, error) {
master, err := ParseMasterPlaylist(content, baseURL)
if err != nil {
return nil, err
}
return master.Variants, nil
}
// ParseMasterPlaylist parses a master playlist including its EXT-X-MEDIA
// rendition groups.
func ParseMasterPlaylist(content string, baseURL *url.URL) (*MasterPlaylist, error) {
if err := validateM3U8Header(content); err != nil {
return nil, err
}
var variants []*Variant
master := &MasterPlaylist{Rendition: make(map[string]*MediaRendition)}
var pending *Variant
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
@@ -100,21 +126,59 @@ func ParseMaster(content string, baseURL *url.URL) ([]*Variant, error) {
Resolution: attrs["RESOLUTION"],
Codecs: unquote(attrs["CODECS"]),
Name: unquote(attrs["NAME"]),
AudioGroup: unquote(attrs["AUDIO"]),
}
continue
}
if strings.HasPrefix(line, "#EXT-X-MEDIA:") {
attrs := parseAttributes(line[len("#EXT-X-MEDIA:"):])
rendition := &MediaRendition{
Type: strings.ToUpper(attrs["TYPE"]),
GroupID: unquote(attrs["GROUP-ID"]),
}
// Only an explicit URI means a separate track; resolving an empty
// reference would fabricate one out of the playlist URL.
if uri := unquote(attrs["URI"]); uri != "" {
rendition.URI = resolveURL(baseURL, uri)
}
if rendition.GroupID != "" {
// Keep the first entry per group: a group is unsupported as a
// whole when any of its members carries its own URI.
if _, ok := master.Rendition[rendition.GroupID]; !ok {
master.Rendition[rendition.GroupID] = rendition
}
}
continue
}
if pending != nil {
if line[0] != '#' {
pending.URI = resolveURL(baseURL, line)
variants = append(variants, pending)
master.Variants = append(master.Variants, pending)
}
pending = nil
}
}
if len(variants) == 0 {
if len(master.Variants) == 0 {
return nil, errors.New("master playlist has no variants")
}
return variants, nil
return master, nil
}
// checkAudioRendition rejects the selected variant when its AUDIO group
// carries a separate audio track: downloading only the video variant would
// silently produce a silent file. Groups the variant does not reference are
// ignored (variants without an AUDIO attribute are assumed self-contained).
func checkAudioRendition(master *MasterPlaylist, best *Variant) error {
if best == nil || best.AudioGroup == "" {
return nil
}
rendition, ok := master.Rendition[best.AudioGroup]
if !ok || rendition == nil || rendition.Type != "AUDIO" || rendition.URI == "" {
return nil
}
return fmt.Errorf(
"selected variant uses a separate audio rendition (group %q), which is not supported: the merged output would have no audio",
best.AudioGroup)
}
// PickBestVariant selects the highest-bandwidth variant.
@@ -141,7 +205,7 @@ func ParseMedia(content string, baseURL *url.URL) (*Media, error) {
var pendingDuration float64
var lastRangeEnd int64
var sequence int64
var initURI string
var mapIdentity string // normalized identity of the current EXT-X-MAP
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
@@ -190,27 +254,39 @@ func ParseMedia(content string, baseURL *url.URL) (*Media, error) {
return nil, errors.New("EXT-X-MAP without URI is not supported")
}
resolved := resolveURL(baseURL, uriAttr)
if initURI != "" && initURI != resolved {
return nil, errors.New("multi-period fMP4 streams are not supported")
}
initURI = resolved
if !media.IsFMP4 {
// The init section is prepended to the segment plan, it takes
// the media sequence number of the playlist for IV derivation.
init := &Segment{
URI: resolved,
Sequence: sequence,
IsInit: true,
Key: currentKey,
// The init section identity covers every attribute, not just the
// URI: a repeated tag with a different BYTERANGE addresses
// different bytes and changes the init section.
identity := resolved + "|" + attrs["BYTERANGE"]
if mapIdentity != "" {
if mapIdentity != identity {
return nil, errors.New("changing init sections are not supported")
}
if attrs["BYTERANGE"] != "" {
init.Byterange = parseByteRange(attrs["BYTERANGE"], 0)
if init.Byterange == nil {
return nil, fmt.Errorf("invalid EXT-X-MAP BYTERANGE: %s", attrs["BYTERANGE"])
}
}
media.Segments = append(media.Segments, init)
media.IsFMP4 = true
continue
}
mapIdentity = identity
init := &Segment{
URI: resolved,
Sequence: sequence,
IsInit: true,
Key: currentKey,
}
if attrs["BYTERANGE"] != "" {
init.Byterange = parseByteRange(attrs["BYTERANGE"], 0)
if init.Byterange == nil {
return nil, fmt.Errorf("invalid EXT-X-MAP BYTERANGE: %s", attrs["BYTERANGE"])
}
}
if init.Key != nil && init.Key.IV == nil {
// RFC 8216 only defines sequence-derived IVs for media
// segments; guessing one for the init section produces
// corrupt output, so require the playlist to be explicit.
return nil, errors.New("encrypted EXT-X-MAP without an explicit IV is not supported")
}
// The init section is prepended to the segment plan, it takes
// the media sequence number of the playlist for IV derivation.
media.Segments = append(media.Segments, init)
media.IsFMP4 = true
case strings.HasPrefix(line, "#EXT-X-BYTERANGE:"):
pendingByterange = parseByteRange(line[len("#EXT-X-BYTERANGE:"):], lastRangeEnd)
@@ -219,6 +295,8 @@ func ParseMedia(content string, baseURL *url.URL) (*Media, error) {
}
case line == "#EXT-X-GAP":
pendingGap = true
case line == "#EXT-X-DISCONTINUITY":
media.Discontinuity = true
case line == "#EXT-X-ENDLIST":
media.Live = false
case strings.HasPrefix(line, "#EXT-X-MEDIA-SEQUENCE:"):
@@ -240,6 +318,24 @@ func ParseMedia(content string, baseURL *url.URL) (*Media, error) {
return media, nil
}
// validateSupported rejects playlist features the byte-concatenation merge
// cannot reproduce faithfully. Skipping such features silently would report
// success while producing a broken file.
func validateSupported(media *Media) error {
if media.Live {
return errors.New("live streams are not supported yet")
}
if media.Discontinuity {
return errors.New("EXT-X-DISCONTINUITY is not supported: the merged output would have a broken timeline")
}
for _, seg := range media.Segments {
if seg.Gap {
return errors.New("EXT-X-GAP is not supported: the missing segment would leave a hole in the merged output")
}
}
return nil
}
func parseKey(attrs string, baseURL *url.URL) (*Key, error) {
parsed := parseAttributes(attrs)
switch method := parsed["METHOD"]; method {
@@ -250,6 +346,9 @@ func parseKey(attrs string, baseURL *url.URL) (*Key, error) {
if uri == "" {
return nil, errors.New("AES-128 EXT-X-KEY without URI")
}
if format := unquote(parsed["KEYFORMAT"]); format != "" && format != "identity" {
return nil, fmt.Errorf("unsupported EXT-X-KEY KEYFORMAT: %s", format)
}
key := &Key{Method: keyMethodAES128, URI: resolveURL(baseURL, uri)}
if iv, ok := parsed["IV"]; ok {
value := strings.TrimPrefix(strings.ToLower(iv), "0x")
+146
View File
@@ -0,0 +1,146 @@
package hls
import (
"net/url"
"strings"
"testing"
)
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u
}
// subsetCase is a media playlist plus the error substring the pipeline must
// reject it with; wantOK playlists must parse AND validate cleanly.
type subsetCase struct {
name string
playlist string
wantErr string
}
func TestSubsetRejections(t *testing.T) {
cases := []subsetCase{
{
name: "gap",
playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n#EXT-X-GAP\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n",
wantErr: "EXT-X-GAP",
},
{
name: "gap-before-first-segment",
playlist: "#EXTM3U\n#EXT-X-GAP\n#EXTINF:4,\na.ts\n#EXT-X-ENDLIST\n",
wantErr: "EXT-X-GAP",
},
{
name: "discontinuity",
playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n#EXT-X-DISCONTINUITY\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n",
wantErr: "EXT-X-DISCONTINUITY",
},
{
name: "live",
playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n",
wantErr: "live",
},
{
name: "keyformat",
playlist: "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"k\",KEYFORMAT=\"com.apple.streamingkeydelivery\"\n#EXTINF:4,\na.ts\n#EXT-X-ENDLIST\n",
wantErr: "KEYFORMAT",
},
{
name: "encrypted-map-without-iv",
playlist: "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"k\"\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4,\na.m4s\n#EXT-X-ENDLIST\n",
wantErr: "explicit IV",
},
{
name: "map-change-byterange",
playlist: "#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n#EXTINF:4,\na.m4s\n#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@100\"\n#EXTINF:4,\nb.m4s\n#EXT-X-ENDLIST\n",
wantErr: "changing init sections",
},
{
name: "map-change-uri",
playlist: "#EXTM3U\n#EXT-X-MAP:URI=\"init1.mp4\"\n#EXTINF:4,\na.m4s\n#EXT-X-MAP:URI=\"init2.mp4\"\n#EXTINF:4,\nb.m4s\n#EXT-X-ENDLIST\n",
wantErr: "changing init sections",
},
}
baseURL := mustParseURL(t, "https://cdn.example.com/vod/index.m3u8")
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
media, parseErr := ParseMedia(tc.playlist, baseURL)
if parseErr != nil {
if !strings.Contains(parseErr.Error(), tc.wantErr) {
t.Fatalf("parse error %q does not mention %q", parseErr, tc.wantErr)
}
return
}
err := validateSupported(media)
if err == nil {
t.Fatalf("playlist should be rejected with %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error %q does not mention %q", err, tc.wantErr)
}
})
}
}
func TestSubsetRepeatedSameMapAllowed(t *testing.T) {
playlist := "#EXTM3U\n" +
"#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n" +
"#EXTINF:4,\na.m4s\n" +
"#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n" +
"#EXTINF:4,\nb.m4s\n" +
"#EXT-X-ENDLIST\n"
media, err := ParseMedia(playlist, mustParseURL(t, "https://cdn.example.com/vod/index.m3u8"))
if err != nil {
t.Fatal(err)
}
if err := validateSupported(media); err != nil {
t.Fatalf("identical repeated MAP must be allowed: %v", err)
}
if len(media.Segments) != 3 { // init + 2 segments, no duplicate init
t.Fatalf("want 3 segments (init + 2), got %d", len(media.Segments))
}
}
func TestMasterAudioRenditionDetection(t *testing.T) {
masterURL := mustParseURL(t, "https://cdn.example.com/master.m3u8")
separateAudio := "#EXTM3U\n" +
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO=\"aud\"\nvideo/index.m3u8\n"
master, err := ParseMasterPlaylist(separateAudio, masterURL)
if err != nil {
t.Fatal(err)
}
if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err == nil {
t.Fatal("selected variant with a separate audio rendition must be rejected")
}
// A group the variant does not reference must not cause a false positive.
unrelatedGroup := "#EXTM3U\n" +
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=1000000\nvideo/index.m3u8\n"
master, err = ParseMasterPlaylist(unrelatedGroup, masterURL)
if err != nil {
t.Fatal(err)
}
if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err != nil {
t.Fatalf("unreferenced audio group must not be rejected: %v", err)
}
// A rendition group without URIs (muxed tracks) is fine.
groupWithoutURI := "#EXTM3U\n" +
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO=\"aud\"\nvideo/index.m3u8\n"
master, err = ParseMasterPlaylist(groupWithoutURI, masterURL)
if err != nil {
t.Fatal(err)
}
if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err != nil {
t.Fatalf("audio group without URI must not be rejected: %v", err)
}
}
+10
View File
@@ -0,0 +1,10 @@
Media fixtures generated locally with FFmpeg 7.0.2 (testsrc + sine sources,
no third-party content):
- sample-00.ts / sample-01.ts: one continuous MPEG-2 TS encode split at a
packet boundary, so continuity counters and PCR are consistent across the
segment join exactly like segments cut from a single real-time stream.
- fmp4-init.mp4: ftyp+moov init section of a fragmented MP4.
- fmp4-seg1.m4s .. fmp4-seg3.m4s: moof+mdat media fragments (styp omitted by
the muxer, which is legal) split at moof boundaries from one fragmented MP4
(-movflags +frag_keyframe+empty_moov+default_base_moof -frag_duration 500000).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.