Files
gopeed/pkg/download/hls_live_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

98 lines
2.6 KiB
Go

package download
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
)
// TestDownloader_HlsLiveSite is a manual acceptance gate against a real site.
// It is skipped unless HLS_LIVE_URL is set, e.g.:
//
// HLS_LIVE_URL="https://host/path/index.m3u8" go test ./pkg/download/ -run TestDownloader_HlsLiveSite -v
func TestDownloader_HlsLiveSite(t *testing.T) {
playlistURL := os.Getenv("HLS_LIVE_URL")
if playlistURL == "" {
t.Skip("set HLS_LIVE_URL to run the live-site acceptance test")
}
referer := os.Getenv("HLS_LIVE_REFERER")
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 := &base.Request{URL: playlistURL}
if referer != "" {
req.Extra = map[string]any{"header": map[string]string{"Referer": referer}}
}
rr, err := downloader.Resolve(req, nil)
if err != nil {
t.Fatalf("resolve: %v", err)
}
name := rr.Res.Files[0].Name
size := rr.Res.Size
t.Logf("resolved: name=%s size=%d", name, size)
taskID, err := downloader.Create(rr.ID)
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(30 * time.Minute)
var lastLogged int64
for {
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
if task.Status == base.DownloadStatusDone {
break
}
if task.Status == base.DownloadStatusError {
t.Fatalf("task entered error state after %d bytes", task.Progress.Downloaded)
}
if time.Now().After(deadline) {
t.Fatalf("timeout, downloaded %d bytes", task.Progress.Downloaded)
}
if tick := task.Progress.Downloaded; tick-lastLogged > 10<<20 {
lastLogged = tick
t.Logf("progress: %d bytes", tick)
}
time.Sleep(500 * time.Millisecond)
}
data, err := os.ReadFile(filepath.Join(downloadDir, name))
if err != nil {
t.Fatal(err)
}
if int64(len(data)) == 0 {
t.Fatal("merged file is empty")
}
t.Logf("merged: %s (%d bytes)", name, len(data))
if filepath.Ext(name) == ".ts" {
// MPEG-TS packets are 188 bytes and start with the 0x47 sync byte.
for i := 0; i < 188*4 && i < len(data); i += 188 {
if data[i] != 0x47 {
t.Fatalf("invalid TS sync byte at offset %d: %#x", i, data[i])
}
}
} else if string(data[4:8]) != "ftyp" {
t.Fatalf("invalid fMP4 header: %q", data[:8])
}
fmt.Printf("LIVE ACCEPTANCE OK: %s (%d bytes)\n", filepath.Join(downloadDir, name), len(data))
}