diff --git a/internal/protocol/hls/doc.go b/internal/protocol/hls/doc.go new file mode 100644 index 00000000..78699556 --- /dev/null +++ b/internal/protocol/hls/doc.go @@ -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 diff --git a/internal/protocol/hls/fetcher.go b/internal/protocol/hls/fetcher.go index a178c0db..85e7ff40 100644 --- a/internal/protocol/hls/fetcher.go +++ b/internal/protocol/hls/fetcher.go @@ -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 diff --git a/internal/protocol/hls/media_validate_test.go b/internal/protocol/hls/media_validate_test.go new file mode 100644 index 00000000..276d2fd0 --- /dev/null +++ b/internal/protocol/hls/media_validate_test.go @@ -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)) + } +} diff --git a/internal/protocol/hls/playlist.go b/internal/protocol/hls/playlist.go index 6c6b8d68..c62ab1e1 100644 --- a/internal/protocol/hls/playlist.go +++ b/internal/protocol/hls/playlist.go @@ -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") diff --git a/internal/protocol/hls/subset_test.go b/internal/protocol/hls/subset_test.go new file mode 100644 index 00000000..18ef4328 --- /dev/null +++ b/internal/protocol/hls/subset_test.go @@ -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) + } +} diff --git a/internal/protocol/hls/testdata/media/README.md b/internal/protocol/hls/testdata/media/README.md new file mode 100644 index 00000000..10afcfb9 --- /dev/null +++ b/internal/protocol/hls/testdata/media/README.md @@ -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). diff --git a/internal/protocol/hls/testdata/media/fmp4-init.mp4 b/internal/protocol/hls/testdata/media/fmp4-init.mp4 new file mode 100644 index 00000000..0c758c0b Binary files /dev/null and b/internal/protocol/hls/testdata/media/fmp4-init.mp4 differ diff --git a/internal/protocol/hls/testdata/media/fmp4-seg1.m4s b/internal/protocol/hls/testdata/media/fmp4-seg1.m4s new file mode 100644 index 00000000..e2735d3e Binary files /dev/null and b/internal/protocol/hls/testdata/media/fmp4-seg1.m4s differ diff --git a/internal/protocol/hls/testdata/media/fmp4-seg2.m4s b/internal/protocol/hls/testdata/media/fmp4-seg2.m4s new file mode 100644 index 00000000..f6fb5cad Binary files /dev/null and b/internal/protocol/hls/testdata/media/fmp4-seg2.m4s differ diff --git a/internal/protocol/hls/testdata/media/fmp4-seg3.m4s b/internal/protocol/hls/testdata/media/fmp4-seg3.m4s new file mode 100644 index 00000000..8d1b41aa Binary files /dev/null and b/internal/protocol/hls/testdata/media/fmp4-seg3.m4s differ diff --git a/internal/protocol/hls/testdata/media/sample-00.ts b/internal/protocol/hls/testdata/media/sample-00.ts new file mode 100644 index 00000000..4e301f77 Binary files /dev/null and b/internal/protocol/hls/testdata/media/sample-00.ts differ diff --git a/internal/protocol/hls/testdata/media/sample-01.ts b/internal/protocol/hls/testdata/media/sample-01.ts new file mode 100644 index 00000000..7b230d5b Binary files /dev/null and b/internal/protocol/hls/testdata/media/sample-01.ts differ