Files
yellowjacket/backend/download/provider_ytdlp_test.go
T
yonluandClaude Sonnet 5 65333857e2
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
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
2026-08-10 14:35:57 -04:00

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
}