mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 17:10:29 +00:00
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:
1 parent
675028c64a
commit
f1f17948c6
12 files changed
+611
-32
No files matched your search
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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.
Reference in new issue
Block a user