Files
gopeed/pkg/download/hls_protocol_test.go
T
sun 4f74101b3a feat(hls): add native HLS (m3u8) download protocol
New hls protocol in internal/protocol/hls (registered ahead of http):
- master playlist variant picking (highest bandwidth), RFC3986 segment
  URL resolution, fMP4 init segments, EXT-X-BYTERANGE, EXT-X-GAP skip
- AES-128 decryption with mid-playlist key rotation and sequence-derived
  IVs; explicit errors for live streams, SAMPLE-AES and multi-period fMP4
- concurrent segment downloads with retry, journal-based resume across
  pause/app restarts, automatic in-order merge into a single .ts/.mp4
- best-effort Content-Length probing for determinate progress
- anti-hotlink Referer/Cookie/UA via task extra headers (Go transport
  does not filter them)

Core: fix SchemeFilter extension match for URLs with query strings
(also benefits .torrent links); Flutter: Protocol.hls enum, hls protocol
config model (prevents config round-trip wipe) and HLS settings block
with localized labels in all 22 locales.

Tests: parser/crypto unit tests, httptest e2e (Referer-enforced CDN,
encrypted streams, byterange, pause/resume, store/restore) and an
opt-in live-site acceptance gate (HLS_LIVE_URL).
2026-09-14 09:55:31 +08:00

173 lines
4.9 KiB
Go

package download
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
)
// hlsTestCdn spins up a master-playlist HLS site whose segments require a
// Referer header (anti-hotlink) and returns the server plus expected bytes.
func hlsTestCdn(t *testing.T, segmentDelay time.Duration) (*httptest.Server, []byte) {
t.Helper()
const total = 6
segments := make(map[int][]byte)
for i := 0; i < total; i++ {
segments[i] = []byte(fmt.Sprintf("ENGINE-SEGMENT-%02d", i))
}
var want []byte
for i := 0; i < total; i++ {
want = append(want, segments[i]...)
}
mux := http.NewServeMux()
mux.HandleFunc("/master.m3u8", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("#EXTM3U\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=500000\nlow/index.m3u8\n" +
"#EXT-X-STREAM-INF:BANDWIDTH=2000000\nhigh/index.m3u8\n"))
})
mux.HandleFunc("/low/index.m3u8", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("#EXTM3U\n#EXTINF:4,\nseg-0.ts\n#EXT-X-ENDLIST\n"))
})
mux.HandleFunc("/high/index.m3u8", func(w http.ResponseWriter, r *http.Request) {
var b strings.Builder
b.WriteString("#EXTM3U\n")
for i := 0; i < total; i++ {
b.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
}
b.WriteString("#EXT-X-ENDLIST\n")
w.Write([]byte(b.String()))
})
for i := 0; i < total; i++ {
idx := i
mux.HandleFunc(fmt.Sprintf("/high/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Referer") == "" {
w.WriteHeader(http.StatusForbidden)
return
}
if segmentDelay > 0 {
time.Sleep(segmentDelay)
}
w.Write(segments[idx])
})
}
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return server, want
}
func hlsResolveReferer() *base.Request {
return &base.Request{
URL: "",
Extra: map[string]any{"header": map[string]string{"Referer": "https://player.example.com/page"}},
}
}
func TestDownloader_HlsProtocolEndToEnd(t *testing.T) {
server, want := hlsTestCdn(t, 0)
downloader := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
downloadDir := t.TempDir()
cfg, err := downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
cfg.DownloadDir = downloadDir
if err := downloader.PutConfig(cfg); err != nil {
t.Fatal(err)
}
req := hlsResolveReferer()
req.URL = server.URL + "/master.m3u8"
rr, err := downloader.Resolve(req, nil)
if err != nil {
t.Fatal(err)
}
if got := rr.Res.Files[0].Name; got != "index.ts" {
t.Errorf("resolved name = %s, want index.ts", got)
}
taskID, err := downloader.Create(rr.ID)
if err != nil {
t.Fatal(err)
}
waitForTaskStatus(t, downloader, taskID, base.DownloadStatusDone, 10*time.Second)
task := downloader.GetTask(taskID)
if task.Protocol != "hls" {
t.Errorf("task protocol = %s, want hls", task.Protocol)
}
data, err := os.ReadFile(filepath.Join(downloadDir, "index.ts"))
if err != nil {
t.Fatal(err)
}
if string(data) != string(want) {
t.Errorf("merged file mismatch: %q", data)
}
}
func TestDownloader_HlsProtocolPauseContinue(t *testing.T) {
server, want := hlsTestCdn(t, 120*time.Millisecond)
downloader := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
downloadDir := t.TempDir()
cfg, err := downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
cfg.DownloadDir = downloadDir
// Throttle the protocol so the pause lands mid-download.
cfg.ProtocolConfig["hls"] = map[string]any{
"segmentConnections": 2,
"maxRetries": 3,
"timeoutSeconds": 5,
"prefetchContentLength": false,
}
if err := downloader.PutConfig(cfg); err != nil {
t.Fatal(err)
}
req := hlsResolveReferer()
req.URL = server.URL + "/master.m3u8"
req.Extra = map[string]any{"header": map[string]string{"Referer": "https://player.example.com/page"}}
// CreateDirect: engine resolves lazily on start, like a direct task.
taskID, err := downloader.CreateDirect(req, &base.Options{Name: "movie.ts"})
if err != nil {
t.Fatal(err)
}
waitForTaskStatus(t, downloader, taskID, base.DownloadStatusRunning, 10*time.Second)
if err := downloader.Pause(&TaskFilter{IDs: []string{taskID}}); err != nil {
t.Fatal(err)
}
waitForTaskStatus(t, downloader, taskID, base.DownloadStatusPause, 5*time.Second)
if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil {
t.Fatal(err)
}
waitForTaskStatus(t, downloader, taskID, base.DownloadStatusDone, 30*time.Second)
task := downloader.GetTask(taskID)
if task.Meta.Res.Size != int64(len(want)) {
t.Errorf("final size = %d, want %d", task.Meta.Res.Size, len(want))
}
data, err := os.ReadFile(filepath.Join(downloadDir, "movie.ts"))
if err != nil {
t.Fatal(err)
}
if string(data) != string(want) {
t.Errorf("merged file mismatch after pause/continue: %q", data)
}
}