The durable "I asked for this" record was called Want, and the one-shot search-and-grab attempt was called Request — names that didn't match what either actually did. Want is now Request, and the old Request/Item is now Download/DownloadItem, with a table-rename migration (download_wants -> download_requests, old download_requests -> download_downloads) safe against both fresh installs and existing data. Every anchored manual download now upserts/reuses a durable Request before running, so a "download now" that finds nothing is picked up by the background reconciler automatically instead of just failing with no trace — the gap that caused this session's repeated "no candidates found" failures on the same album. Also adds auto-download guardrails (file-size min/max with a preferred target, allowed file types) that gate what the pipeline may grab unattended, live-editable from a new settings section. The frontend's wanted-view becomes downloads-view, with a new Downloads tab showing attempt/transfer history that previously had no UI at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
383 lines
8.5 KiB
Go
383 lines
8.5 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// yt-dlp tests drive a stub shell script rather than the real binary:
|
|
// the adapter's contract is "what argv do we build and what do we do
|
|
// with the output", and a stub tests exactly that without a network,
|
|
// a YouTube account, or a 40MB dependency.
|
|
|
|
// stubYtDlp writes an executable script that echoes the given stdout
|
|
// and returns it as a provider config binary path.
|
|
func stubYtDlp(t *testing.T, script string) string {
|
|
t.Helper()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("stub binary test uses a shell script")
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "yt-dlp")
|
|
|
|
if err := os.WriteFile(
|
|
path, []byte("#!/bin/sh\n"+script), 0o700,
|
|
); err != nil {
|
|
t.Fatalf("write stub: %v", err)
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
// newStubYtDlp builds the provider over a stub binary.
|
|
func newStubYtDlp(t *testing.T, script string) *ytDlp {
|
|
t.Helper()
|
|
|
|
p, err := newYtDlp(
|
|
Config{
|
|
ID: 1,
|
|
Kind: KindYtDlp,
|
|
Name: "yt-dlp",
|
|
Enabled: true,
|
|
Priority: 50,
|
|
Settings: map[string]string{
|
|
"binary": stubYtDlp(t, script),
|
|
"audioFormat": "flac",
|
|
},
|
|
},
|
|
nil,
|
|
slogDiscard(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newYtDlp: %v", err)
|
|
}
|
|
|
|
y, ok := p.(*ytDlp)
|
|
if !ok {
|
|
t.Fatalf("provider is %T, want *ytDlp", p)
|
|
}
|
|
|
|
return y
|
|
}
|
|
|
|
func TestYtDlpCheckVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("recent version passes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
y := newStubYtDlp(t, `echo "2024.08.06"`)
|
|
|
|
if err := y.Check(context.Background()); err != nil {
|
|
t.Errorf("Check: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("old version is rejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
y := newStubYtDlp(t, `echo "2021.01.01"`)
|
|
|
|
if err := y.Check(context.Background()); !errors.Is(
|
|
err, ErrYtDlpTooOld,
|
|
) {
|
|
t.Errorf("error = %v, want ErrYtDlpTooOld", err)
|
|
}
|
|
})
|
|
|
|
t.Run("non-zero exit is reported", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
y := newStubYtDlp(t, `exit 1`)
|
|
|
|
if err := y.Check(context.Background()); !errors.Is(
|
|
err, ErrYtDlpFailed,
|
|
) {
|
|
t.Errorf("error = %v, want ErrYtDlpFailed", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestYtDlpMissingBinary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := newYtDlp(
|
|
Config{Settings: map[string]string{
|
|
"binary": "definitely-not-a-real-binary-xyzzy",
|
|
}},
|
|
nil,
|
|
slogDiscard(),
|
|
)
|
|
|
|
if !errors.Is(err, ErrYtDlpMissing) {
|
|
t.Errorf("error = %v, want ErrYtDlpMissing", err)
|
|
}
|
|
}
|
|
|
|
func TestYtDlpSearchParsesJSONLines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
y := newStubYtDlp(t, `
|
|
cat <<'EOF'
|
|
{"id":"aaa","title":"Airbag","webpage_url":"https://example.com/aaa","uploader":"Radiohead","duration":284,"filesize_approx":5000000}
|
|
{"id":"bbb","title":"Paranoid Android","webpage_url":"https://example.com/bbb","uploader":"Radiohead","duration":383}
|
|
EOF
|
|
`)
|
|
|
|
got, err := y.Search(context.Background(), Download{
|
|
Artist: "Radiohead",
|
|
Album: "OK Computer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d candidates, want 2", len(got))
|
|
}
|
|
|
|
if got[0].Title != "Airbag" {
|
|
t.Errorf("title = %q, want Airbag", got[0].Title)
|
|
}
|
|
|
|
if got[0].Protocol != ProtocolDirect {
|
|
t.Errorf("protocol = %q, want direct", got[0].Protocol)
|
|
}
|
|
|
|
if len(got[0].Files) != 1 {
|
|
t.Fatalf("got %d files, want 1", len(got[0].Files))
|
|
}
|
|
|
|
link := got[0].Payload[got[0].Files[0].Path]
|
|
if link != "https://example.com/aaa" {
|
|
t.Errorf("payload url = %q, want the webpage_url", link)
|
|
}
|
|
}
|
|
|
|
// Warning text mixed into stdout must not discard valid results.
|
|
func TestYtDlpSearchSkipsUnparseableLines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
y := newStubYtDlp(t, `
|
|
cat <<'EOF'
|
|
WARNING: something happened
|
|
{"id":"aaa","title":"Airbag","webpage_url":"https://example.com/aaa"}
|
|
not json at all
|
|
{"id":"bbb","title":"Lucky","webpage_url":"https://example.com/bbb"}
|
|
EOF
|
|
`)
|
|
|
|
got, err := y.Search(context.Background(), Download{Query: "radiohead"})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d candidates, want 2 valid ones", len(got))
|
|
}
|
|
}
|
|
|
|
// With a tracklist the adapter assembles an album from per-track
|
|
// searches, because a single "full album" video cannot be imported as
|
|
// separate tracks.
|
|
func TestYtDlpAssemblesAlbumFromTracklist(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
y := newStubYtDlp(t, `
|
|
echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https://example.com/x","filesize_approx":4000000}'
|
|
`)
|
|
|
|
dl := Download{
|
|
ID: "dl-1",
|
|
ReleaseMBID: "mbid-1",
|
|
Artist: "Radiohead",
|
|
Album: "OK Computer",
|
|
Expected: []ExpectedTrack{
|
|
{Position: 1, Title: "Airbag"},
|
|
{Position: 2, Title: "Paranoid Android"},
|
|
{Position: 3, Title: "Subterranean Homesick Alien"},
|
|
},
|
|
}
|
|
|
|
got, err := y.Search(context.Background(), dl)
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 1 {
|
|
t.Fatalf("got %d candidates, want 1 assembled album", len(got))
|
|
}
|
|
|
|
album := got[0]
|
|
|
|
if len(album.Files) != 3 {
|
|
t.Fatalf("got %d files, want 3", len(album.Files))
|
|
}
|
|
|
|
// Files are named after the expected tracks, not the video titles,
|
|
// because the import step matches on filename.
|
|
names := make([]string, 0, len(album.Files))
|
|
for _, f := range album.Files {
|
|
names = append(names, f.Path)
|
|
}
|
|
|
|
for _, want := range []string{
|
|
"01 - Airbag.flac",
|
|
"02 - Paranoid Android.flac",
|
|
"03 - Subterranean Homesick Alien.flac",
|
|
} {
|
|
if !containsString(names, want) {
|
|
t.Errorf("missing %q in %v", want, names)
|
|
}
|
|
}
|
|
|
|
if album.TotalSize != 12_000_000 {
|
|
t.Errorf("total size = %d, want 12000000", album.TotalSize)
|
|
}
|
|
}
|
|
|
|
// A track with no search result is left out, so completeness scoring
|
|
// can speak for the gap instead of the search failing outright.
|
|
func TestYtDlpAssembleToleratesMissingTracks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
y := newStubYtDlp(t, `
|
|
case "$*" in
|
|
*Airbag*) echo '{"id":"a","title":"Airbag","webpage_url":"https://example.com/a"}' ;;
|
|
*) exit 1 ;;
|
|
esac
|
|
`)
|
|
|
|
got, err := y.Search(context.Background(), Download{
|
|
ID: "req-1",
|
|
ReleaseMBID: "mbid-1",
|
|
Artist: "Radiohead",
|
|
Album: "OK Computer",
|
|
Expected: []ExpectedTrack{
|
|
{Position: 1, Title: "Airbag"},
|
|
{Position: 2, Title: "Paranoid Android"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 1 {
|
|
t.Fatalf("got %d candidates, want 1", len(got))
|
|
}
|
|
|
|
if len(got[0].Files) != 1 {
|
|
t.Errorf("got %d files, want just the one that was found", len(got[0].Files))
|
|
}
|
|
}
|
|
|
|
func TestYtDlpGrabWritesIntoStaging(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The stub writes a file at whatever --output stem it is given,
|
|
// mimicking yt-dlp's post-extraction naming.
|
|
y := newStubYtDlp(t, `
|
|
out=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--output) out="$2"; shift 2 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
target=$(printf '%s' "$out" | sed 's/%(ext)s/flac/')
|
|
printf 'audio' > "$target"
|
|
`)
|
|
|
|
dst := t.TempDir()
|
|
|
|
c := Candidate{
|
|
ID: "ytdlp:album:req-1",
|
|
Protocol: ProtocolDirect,
|
|
Files: []CandidateFile{
|
|
{Path: "01 - Airbag.flac", IsAudio: true},
|
|
},
|
|
Payload: map[string]string{
|
|
"01 - Airbag.flac": "https://example.com/a",
|
|
},
|
|
}
|
|
|
|
got, err := y.Grab(context.Background(), c, dst, nil)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 {
|
|
t.Fatalf("got %d files, want 1", len(got.Files))
|
|
}
|
|
|
|
if _, err := os.Stat(got.Files[0]); err != nil {
|
|
t.Errorf("downloaded file missing: %v", err)
|
|
}
|
|
|
|
if !strings.HasSuffix(got.Files[0], ".flac") {
|
|
t.Errorf("file = %s, want a .flac", got.Files[0])
|
|
}
|
|
}
|
|
|
|
// A search result is untrusted input, and yt-dlp accepts schemes that
|
|
// would read the local filesystem.
|
|
func TestYtDlpGrabRejectsNonHTTPURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
y := newStubYtDlp(t, `exit 0`)
|
|
|
|
c := Candidate{
|
|
Files: []CandidateFile{{Path: "x.flac", IsAudio: true}},
|
|
Payload: map[string]string{"x.flac": "file:///etc/passwd"},
|
|
}
|
|
|
|
_, err := y.Grab(context.Background(), c, t.TempDir(), nil)
|
|
if !errors.Is(err, ErrUnsafeURL) {
|
|
t.Errorf("error = %v, want ErrUnsafeURL", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateHTTPURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
url string
|
|
wantErr bool
|
|
}{
|
|
{"https://example.com/a", false},
|
|
{"http://example.com/a", false},
|
|
{"file:///etc/passwd", true},
|
|
{"ftp://example.com/a", true},
|
|
{"javascript:alert(1)", true},
|
|
{"://nonsense", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.url, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := validateHTTPURL(tt.url)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateHTTPURL(%q) error = %v, wantErr %v",
|
|
tt.url, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func containsString(haystack []string, needle string) bool {
|
|
for _, h := range haystack {
|
|
if h == needle {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|