mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
320 lines
9.8 KiB
Go
320 lines
9.8 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"crypto/cipher"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/blowfish"
|
|
)
|
|
|
|
func TestDownloaderHasNoClientTimeout(t *testing.T) {
|
|
d := NewWithOptions(true, false)
|
|
if d.http.Timeout != 0 {
|
|
t.Fatalf("http timeout = %v, want 0 (no global timeout)", d.http.Timeout)
|
|
}
|
|
}
|
|
|
|
func TestDownloaderFile(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("abc123"))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := New()
|
|
out := filepath.Join(t.TempDir(), "x", "a.bin")
|
|
if err := d.File(context.Background(), ts.URL, out); err != nil {
|
|
t.Fatalf("File() error = %v", err)
|
|
}
|
|
|
|
b, err := os.ReadFile(out)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error = %v", err)
|
|
}
|
|
if string(b) != "abc123" {
|
|
t.Fatalf("contents = %q, want %q", string(b), "abc123")
|
|
}
|
|
}
|
|
|
|
func TestManifestDetection(t *testing.T) {
|
|
if !isManifestResponse("application/dash+xml", []byte("x")) {
|
|
t.Fatalf("expected dash content-type to be manifest")
|
|
}
|
|
if !isManifestResponse("application/vnd.apple.mpegurl", []byte("x")) {
|
|
t.Fatalf("expected mpegurl content-type to be manifest")
|
|
}
|
|
if !isManifestResponse("application/octet-stream", []byte("<?xml version='1.0'?><MPD></MPD>")) {
|
|
t.Fatalf("expected MPD XML body to be manifest")
|
|
}
|
|
if !isManifestResponse("application/octet-stream", []byte("<MPD type='static'></MPD>")) {
|
|
t.Fatalf("expected MPD body without xml prolog to be manifest")
|
|
}
|
|
if !isManifestResponse("text/plain", []byte("#EXTM3U\n#EXT-X-VERSION:3")) {
|
|
t.Fatalf("expected HLS body to be manifest")
|
|
}
|
|
if isManifestResponse("audio/flac", []byte("fLaC")) {
|
|
t.Fatalf("did not expect flac to be manifest")
|
|
}
|
|
}
|
|
|
|
func TestDeezerBlowfishKeyDerivation(t *testing.T) {
|
|
trackID := "3135556"
|
|
key := deriveDeezerBlowfishKey(trackID)
|
|
if len(key) != 16 {
|
|
t.Fatalf("blowfish key len = %d, want 16", len(key))
|
|
}
|
|
}
|
|
|
|
func TestFileDeezerEncrypted(t *testing.T) {
|
|
trackID := "3135556"
|
|
plain := make([]byte, deezerBFChunkSize+777)
|
|
for i := range plain {
|
|
plain[i] = byte((i * 7) % 251)
|
|
}
|
|
enc := make([]byte, len(plain))
|
|
copy(enc, plain)
|
|
|
|
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
|
|
if err != nil {
|
|
t.Fatalf("cipher error: %v", err)
|
|
}
|
|
cbc := cipher.NewCBCEncrypter(block, deezerBFIV)
|
|
cbc.CryptBlocks(enc[:deezerBFChunkSize], enc[:deezerBFChunkSize])
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write(enc)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := NewWithOptions(true, false)
|
|
out := filepath.Join(t.TempDir(), "x", "a.flac")
|
|
if err = d.FileDeezerEncrypted(context.Background(), ts.URL, out, trackID); err != nil {
|
|
t.Fatalf("FileDeezerEncrypted() error = %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(out)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error = %v", err)
|
|
}
|
|
if string(got) != string(plain) {
|
|
t.Fatalf("decrypted file mismatch")
|
|
}
|
|
}
|
|
|
|
func TestDownloaderFileTruncatedResponseRemovesPartialFile(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", "10")
|
|
_, _ = w.Write([]byte("abc"))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := NewWithOptions(true, false)
|
|
out := filepath.Join(t.TempDir(), "x", "a.bin")
|
|
err := d.File(context.Background(), ts.URL, out)
|
|
if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
t.Fatalf("expected unexpected EOF, got %v", err)
|
|
}
|
|
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("expected no partial file, stat err=%v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestFileDeezerEncryptedTruncatedResponseRemovesPartialFile(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", "4096")
|
|
_, _ = w.Write([]byte("short"))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := NewWithOptions(true, false)
|
|
out := filepath.Join(t.TempDir(), "x", "a.flac")
|
|
err := d.FileDeezerEncrypted(context.Background(), ts.URL, out, "3135556")
|
|
if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
t.Fatalf("expected unexpected EOF, got %v", err)
|
|
}
|
|
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("expected no partial file, stat err=%v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestFileDeezerEncryptedBadStatus(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := NewWithOptions(true, false)
|
|
out := filepath.Join(t.TempDir(), "x", "a.flac")
|
|
err := d.FileDeezerEncrypted(context.Background(), ts.URL, out, "3135556")
|
|
if err == nil || !strings.Contains(err.Error(), "status=403") {
|
|
t.Fatalf("expected status error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDownloaderFileContextCancellationRemovesPartialFile(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write([]byte("partial-data"))
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
<-r.Context().Done()
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := NewWithOptions(true, false)
|
|
out := filepath.Join(t.TempDir(), "x", "cancel.bin")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
|
|
defer cancel()
|
|
err := d.File(ctx, ts.URL, out)
|
|
if err == nil {
|
|
t.Fatalf("expected context cancellation error")
|
|
}
|
|
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("expected no partial file, stat err=%v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestStreamManifestWithFFmpegMissing(t *testing.T) {
|
|
d := NewWithOptions(true, false)
|
|
t.Setenv("PATH", "")
|
|
err := d.streamManifestWithFFmpeg(context.Background(), "https://example.com/live.m3u8", filepath.Join(t.TempDir(), "out.m4a"), false)
|
|
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "ffmpeg not found") {
|
|
t.Fatalf("expected ffmpeg missing error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStreamManifestWithFFmpegFailureRemovesPartialFile(t *testing.T) {
|
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
t.Skip("ffmpeg not installed")
|
|
}
|
|
d := NewWithOptions(true, false)
|
|
out := filepath.Join(t.TempDir(), "out.m4a")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
err := d.streamManifestWithFFmpeg(ctx, "https://127.0.0.1:1/unreachable.m3u8", out, false)
|
|
if err == nil || !strings.Contains(err.Error(), "ffmpeg stream copy failed") {
|
|
t.Fatalf("expected ffmpeg failure error, got %v", err)
|
|
}
|
|
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("expected no partial file after ffmpeg failure, stat err=%v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestParseFFmpegDurationLine(t *testing.T) {
|
|
totalMS, ok := parseFFmpegDurationLine(" Duration: 00:04:52.57, start: 0.000000, bitrate: 975 kb/s")
|
|
if !ok {
|
|
t.Fatalf("expected duration parse to succeed")
|
|
}
|
|
if want := int64(292570); totalMS != want {
|
|
t.Fatalf("unexpected duration ms: got=%d want=%d", totalMS, want)
|
|
}
|
|
}
|
|
|
|
func TestParseFFmpegDurationBitrateBPS(t *testing.T) {
|
|
bps, ok := parseFFmpegDurationBitrateBPS(" Duration: 00:04:52.57, start: 0.000000, bitrate: 975 kb/s")
|
|
if !ok {
|
|
t.Fatalf("expected bitrate parse to succeed")
|
|
}
|
|
if want := int64(975000); bps != want {
|
|
t.Fatalf("unexpected bitrate: got=%d want=%d", bps, want)
|
|
}
|
|
}
|
|
|
|
func TestParseFFmpegProgressBitrateBPS(t *testing.T) {
|
|
bps, ok := parseFFmpegProgressBitrateBPS("bitrate=1706.8kbits/s")
|
|
if !ok {
|
|
t.Fatalf("expected progress bitrate parse to succeed")
|
|
}
|
|
if want := int64(1706800); bps != want {
|
|
t.Fatalf("unexpected bitrate: got=%d want=%d", bps, want)
|
|
}
|
|
}
|
|
|
|
func TestParseFFmpegTotalSize(t *testing.T) {
|
|
size, ok := parseFFmpegTotalSize("total_size=1234567")
|
|
if !ok {
|
|
t.Fatalf("expected total_size parse to succeed")
|
|
}
|
|
if want := int64(1234567); size != want {
|
|
t.Fatalf("unexpected total_size: got=%d want=%d", size, want)
|
|
}
|
|
}
|
|
|
|
func TestParseFFmpegOutTime(t *testing.T) {
|
|
currentMS, ok := parseFFmpegOutTime("out_time=00:01:02.340000")
|
|
if !ok {
|
|
t.Fatalf("expected out_time parse to succeed")
|
|
}
|
|
if want := int64(62340); currentMS != want {
|
|
t.Fatalf("unexpected out_time ms: got=%d want=%d", currentMS, want)
|
|
}
|
|
}
|
|
|
|
func TestParseClockDurationMSInvalid(t *testing.T) {
|
|
if _, ok := parseClockDurationMS("bad"); ok {
|
|
t.Fatalf("expected invalid duration to fail")
|
|
}
|
|
if _, ok := parseClockDurationMS("00:12"); ok {
|
|
t.Fatalf("expected short duration to fail")
|
|
}
|
|
}
|
|
|
|
func TestEstimateTotalBytesFromBitrate(t *testing.T) {
|
|
total := estimateTotalBytesFromBitrate(10000, 1600000)
|
|
if want := int64(2000000); total != want {
|
|
t.Fatalf("unexpected estimate from bitrate: got=%d want=%d", total, want)
|
|
}
|
|
}
|
|
|
|
func TestEstimateTotalBytesFromProgress(t *testing.T) {
|
|
total := estimateTotalBytesFromProgress(10000, 2500, 500000)
|
|
if want := int64(2000000); total != want {
|
|
t.Fatalf("unexpected estimate from progress: got=%d want=%d", total, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegStreamArgsAudioOnly(t *testing.T) {
|
|
args := buildFFmpegStreamArgs("https://example.com/master.m3u8", "/tmp/out.m4a", false)
|
|
if !containsArgPair(args, "-map", "0:a:0") {
|
|
t.Fatalf("expected audio map in args: %v", args)
|
|
}
|
|
if containsArgPair(args, "-map", "0:v:0?") {
|
|
t.Fatalf("did not expect video map in audio-only args: %v", args)
|
|
}
|
|
if containsArgPair(args, "-map", "0") {
|
|
t.Fatalf("did not expect broad map=0 in args: %v", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegStreamArgsIncludeVideo(t *testing.T) {
|
|
args := buildFFmpegStreamArgs("https://example.com/master.m3u8", "/tmp/out.mp4", true)
|
|
if !containsArgPair(args, "-map", "0:v:0?") {
|
|
t.Fatalf("expected video map in args: %v", args)
|
|
}
|
|
if !containsArgPair(args, "-map", "0:a:0?") {
|
|
t.Fatalf("expected audio map in args: %v", args)
|
|
}
|
|
if containsArgPair(args, "-map", "0") {
|
|
t.Fatalf("did not expect broad map=0 in args: %v", args)
|
|
}
|
|
}
|
|
|
|
func containsArgPair(args []string, key, value string) bool {
|
|
for i := 0; i+1 < len(args); i++ {
|
|
if args[i] == key && args[i+1] == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|