mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 22:07:30 +02:00
internal/download The ~40-line progress-bar decorator stack was copy-pasted into three download paths and had started to drift; it is now newDownloadBar, which returns a nil bar when progress is off (so callers use it unconditionally) plus a finish func to defer. The three hand-rolled read/write loops that differed only in whether they touched a bar become copyWithProgress. FileDeezerEncrypted and FileYandexEncrypted were ~90% identical: mkdir, GET, status check, create output, bar, cleanup-on-failure, Content-Length check, fsync. That scaffolding is now streamEncrypted, and each function supplies only its decryption step. Also switches the io.EOF/io.ErrUnexpectedEOF comparisons to errors.Is. internal/app Three copies of "coerce a JSON array that may be []any or []map[string]any" become anySlice, which also reports whether the value was an array at all so callers can still distinguish missing from empty. The worker-pool dispatch existed three times and the copies disagreed: two counted failures under a mutex and logged inline with the track id, while the third collected errors in a channel and logged them all after wg.Wait() without the id. runTrackWorkers unifies them on the inline form, which reports failures as they happen and names the track, and keeps the mutex held across the log so failure lines cannot interleave. Tests The progress-bar paths had no coverage at all, because progress is disabled when not attached to a terminal; four new tests force it on via the existing STREAMRIP_GO_FORCE_PROGRESS escape hatch and cover the determinate bar, the unknown-length spinner, Deezer decryption with a bar attached, and the failure path still removing the partial file. internal/download coverage 53% -> 63%. Three cancellation tests pin the Ctrl-C fix. Verified as genuine regression tests by reverting the guards: without them the rip walks all 40 tracks of a cancelled album, logs a failure for each, and Rip() returns nil. Adds store.SQLite.IsFailed, the read complement to MarkFailed, used by the bookkeeping test to confirm failure records still land under a cancelled context. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
513 lines
16 KiB
Go
513 lines
16 KiB
Go
package download
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/blowfish"
|
|
)
|
|
|
|
func TestDownloaderHasNoClientTimeout(t *testing.T) {
|
|
d := NewWithOptions(true, false, 0)
|
|
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(defaultDeezerBlowfishSecret, 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(defaultDeezerBlowfishSecret, 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, 0)
|
|
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 TestFileYandexEncrypted(t *testing.T) {
|
|
plain := make([]byte, 8192+333)
|
|
for i := range plain {
|
|
plain[i] = byte((i * 11) % 251)
|
|
}
|
|
keyHex := "00112233445566778899aabbccddeeff"
|
|
key, err := hex.DecodeString(keyHex)
|
|
if err != nil {
|
|
t.Fatalf("DecodeString() error = %v", err)
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
t.Fatalf("NewCipher() error = %v", err)
|
|
}
|
|
enc := make([]byte, len(plain))
|
|
copy(enc, plain)
|
|
stream := cipher.NewCTR(block, make([]byte, aes.BlockSize))
|
|
stream.XORKeyStream(enc, enc)
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write(enc)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := NewWithOptions(true, false, 0)
|
|
out := filepath.Join(t.TempDir(), "x", "a.m4a")
|
|
if err = d.FileYandexEncrypted(context.Background(), ts.URL, out, keyHex); err != nil {
|
|
t.Fatalf("FileYandexEncrypted() 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, 0)
|
|
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, 0)
|
|
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, 0)
|
|
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 TestFileYandexEncryptedBadKey(t *testing.T) {
|
|
d := NewWithOptions(true, false, 0)
|
|
out := filepath.Join(t.TempDir(), "x", "a.m4a")
|
|
err := d.FileYandexEncrypted(context.Background(), "https://example.com/file", out, "abcd")
|
|
if err == nil || !strings.Contains(err.Error(), "invalid yandex key length") {
|
|
t.Fatalf("expected key length 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, 0)
|
|
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, 0)
|
|
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, 0)
|
|
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
|
|
}
|
|
|
|
// newProgressDownloader builds a Downloader with the progress bars actually
|
|
// enabled. The bar code paths are otherwise never exercised, because tests do
|
|
// not run on a terminal; STREAMRIP_GO_FORCE_PROGRESS is the documented escape
|
|
// hatch. Output goes to stderr, which `go test` captures.
|
|
func newProgressDownloader(t *testing.T) *Downloader {
|
|
t.Helper()
|
|
t.Setenv("STREAMRIP_GO_FORCE_PROGRESS", "1")
|
|
d := NewWithOptions(true, true, 0)
|
|
if !d.ProgressEnabled() {
|
|
t.Fatal("expected progress to be enabled")
|
|
}
|
|
t.Cleanup(d.Close)
|
|
return d
|
|
}
|
|
|
|
// TestFileWithProgressBar covers the determinate-bar path (Content-Length
|
|
// known) through the shared newDownloadBar/copyWithProgress helpers.
|
|
func TestFileWithProgressBar(t *testing.T) {
|
|
payload := bytes.Repeat([]byte("abcd"), 4096)
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
|
|
_, _ = w.Write(payload)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := newProgressDownloader(t)
|
|
out := filepath.Join(t.TempDir(), "sub", "a.flac")
|
|
if err := d.File(context.Background(), ts.URL, out); err != nil {
|
|
t.Fatalf("File() error = %v", err)
|
|
}
|
|
got, err := os.ReadFile(out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(got, payload) {
|
|
t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(payload))
|
|
}
|
|
}
|
|
|
|
// TestFileWithSpinnerUnknownLength covers the spinner path, taken when the
|
|
// server does not send a Content-Length.
|
|
func TestFileWithSpinnerUnknownLength(t *testing.T) {
|
|
payload := bytes.Repeat([]byte("xyz"), 3000)
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Transfer-Encoding", "chunked")
|
|
_, _ = w.Write(payload)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := newProgressDownloader(t)
|
|
out := filepath.Join(t.TempDir(), "a.flac")
|
|
if err := d.File(context.Background(), ts.URL, out); err != nil {
|
|
t.Fatalf("File() error = %v", err)
|
|
}
|
|
got, err := os.ReadFile(out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(got, payload) {
|
|
t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(payload))
|
|
}
|
|
}
|
|
|
|
// TestFileDeezerEncryptedWithProgressBar runs the Deezer decryption through
|
|
// the progress-enabled path, so the shared streamEncrypted scaffolding is
|
|
// covered with a bar attached as well as without.
|
|
func TestFileDeezerEncryptedWithProgressBar(t *testing.T) {
|
|
plain := make([]byte, deezerBFChunkSize*4+100)
|
|
for i := range plain {
|
|
plain[i] = byte((i * 7) % 253)
|
|
}
|
|
trackID := "123456"
|
|
enc := encryptDeezerPayload(t, plain, trackID)
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(enc)))
|
|
_, _ = w.Write(enc)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := newProgressDownloader(t)
|
|
out := filepath.Join(t.TempDir(), "a.mp3")
|
|
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.Fatal(err)
|
|
}
|
|
if !bytes.Equal(got, plain) {
|
|
t.Fatal("decrypted output does not match the original plaintext")
|
|
}
|
|
}
|
|
|
|
// encryptDeezerPayload applies Deezer's scheme: Blowfish-CBC on every third
|
|
// full 2048-byte chunk, everything else left in the clear.
|
|
func encryptDeezerPayload(t *testing.T, plain []byte, trackID string) []byte {
|
|
t.Helper()
|
|
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(defaultDeezerBlowfishSecret, trackID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
enc := make([]byte, len(plain))
|
|
copy(enc, plain)
|
|
for i, off := 0, 0; off < len(plain); i, off = i+1, off+deezerBFChunkSize {
|
|
end := off + deezerBFChunkSize
|
|
if end > len(plain) {
|
|
break
|
|
}
|
|
if i%3 != 0 {
|
|
continue
|
|
}
|
|
mode := cipher.NewCBCEncrypter(block, deezerBFIV)
|
|
mode.CryptBlocks(enc[off:end], plain[off:end])
|
|
}
|
|
return enc
|
|
}
|
|
|
|
// TestProgressBarFailurePathRemovesPartialFile checks that a mid-download
|
|
// failure still runs the bar cleanup and removes the partial output, which is
|
|
// the behaviour the finishBar(success) defer replaced.
|
|
func TestProgressBarFailurePathRemovesPartialFile(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", "100000")
|
|
_, _ = w.Write(bytes.Repeat([]byte("a"), 10))
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := newProgressDownloader(t)
|
|
out := filepath.Join(t.TempDir(), "a.flac")
|
|
if err := d.File(context.Background(), ts.URL, out); err == nil {
|
|
t.Fatal("expected a truncated-response error")
|
|
}
|
|
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
|
t.Fatalf("partial file not removed, stat err = %v", err)
|
|
}
|
|
}
|