Files
yellowjacket/backend/download/provider_ytdlp.go
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

533 lines
13 KiB
Go

package download
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
// yt-dlp is the local-subprocess shape: no server for the user to run,
// no credentials, but everything comes back as text from a binary whose
// output format is not a stable contract. The defences are: pin a
// minimum version and check it before use, ask for JSON rather than
// parsing human output, and never build a shell command — every
// invocation is an argv slice, so a track title containing `; rm -rf`
// is an argument and not a command.
// yt-dlp provider errors.
var (
// ErrYtDlpMissing means the binary was not found.
ErrYtDlpMissing = errors.New("yt-dlp was not found")
// ErrYtDlpTooOld means the installed version predates the output
// format this adapter relies on.
ErrYtDlpTooOld = errors.New("yt-dlp is too old")
// ErrYtDlpFailed wraps a non-zero exit.
ErrYtDlpFailed = errors.New("yt-dlp failed")
// ErrUnsafeURL rejects a URL that is not plain http(s). yt-dlp
// accepts things like file:// that must never come from a search
// result.
ErrUnsafeURL = errors.New("refusing to fetch a non-http URL")
)
// minYtDlpVersion is the oldest release known to support the
// --progress-template and --dump-json output this adapter parses.
// yt-dlp versions are date-stamped, so this compares lexically.
const minYtDlpVersion = "2023.01.01"
// ytSearchCount is how many results to ask for per query.
const ytSearchCount = 5
// ytTrackConcurrency bounds parallel per-track searches when assembling
// an album. YouTube throttles aggressively; three is fast enough to
// finish inside the search timeout without tripping it.
const ytTrackConcurrency = 3
func init() {
Register(
Descriptor{
Kind: KindYtDlp,
Name: "yt-dlp",
Summary: "Download audio from YouTube, SoundCloud, Bandcamp and other sites yt-dlp supports.",
Caps: Caps{
CanSearch: true,
CanTransport: true,
CanCancel: true,
ReportsSize: true,
},
Fields: []Field{
{
Key: "binary",
Label: "yt-dlp path",
Placeholder: "yt-dlp",
Help: "Leave blank to find yt-dlp on your PATH.",
Default: "yt-dlp",
},
{
Key: "audioFormat",
Label: "Audio format",
Help: "flac, mp3, opus, m4a, or 'best' to keep the source format.",
Default: "flac",
},
{
Key: "searchPrefix",
Label: "Search source",
Help: "ytsearch for YouTube, ytmsearch for YouTube Music. " +
"Defaults to ytsearch.",
Default: "ytsearch",
},
},
},
newYtDlp,
)
}
// ytDlp is the yt-dlp provider.
type ytDlp struct {
info ProviderInfo
logger *slog.Logger
binary string
audioFormat string
searchPrefix string
}
// newYtDlp builds a yt-dlp provider from config.
func newYtDlp(
cfg Config,
_ SecretLookup,
logger *slog.Logger,
) (Provider, error) {
binary := cfg.Setting("binary", "yt-dlp")
resolved, err := exec.LookPath(binary)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrYtDlpMissing, binary)
}
return &ytDlp{
info: ProviderInfo{
ID: cfg.ID,
Kind: KindYtDlp,
Name: cfg.Name,
Enabled: cfg.Enabled,
Priority: cfg.Priority,
Caps: Caps{
CanSearch: true,
CanTransport: true,
CanCancel: true,
ReportsSize: true,
},
},
logger: logger.With("provider", "yt-dlp"),
binary: resolved,
audioFormat: cfg.Setting("audioFormat", "flac"),
searchPrefix: cfg.Setting("searchPrefix", "ytsearch"),
}, nil
}
// Info returns the provider's identity.
func (y *ytDlp) Info() ProviderInfo {
return y.info
}
// Close is a no-op; each invocation is its own process.
func (y *ytDlp) Close() error {
return nil
}
// Check verifies the binary runs and is new enough. Version drift is
// yt-dlp's defining trait, so this is the difference between a clear
// error at configuration time and garbled output at download time.
func (y *ytDlp) Check(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, y.binary, "--version").Output()
if err != nil {
return fmt.Errorf("%w: %w", ErrYtDlpFailed, err)
}
version := strings.TrimSpace(string(out))
if version < minYtDlpVersion {
return fmt.Errorf(
"%w: found %s, need %s or newer",
ErrYtDlpTooOld, version, minYtDlpVersion,
)
}
return nil
}
// ytEntry is the subset of yt-dlp's --dump-json output this adapter
// uses. yt-dlp emits far more; naming only what is needed means a
// field being added or reordered upstream cannot break parsing.
type ytEntry struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
WebURL string `json:"webpage_url"`
Uploader string `json:"uploader"`
Duration float64 `json:"duration"`
Filesize int64 `json:"filesize_approx"`
}
// link returns the entry's best usable URL.
func (e ytEntry) link() string {
if e.WebURL != "" {
return e.WebURL
}
return e.URL
}
// Search assembles candidates. With an expected tracklist it searches
// per track and offers the assembled album as one candidate, which is
// how yt-dlp is actually useful for albums — a single "full album"
// video is one file and cannot be imported as tracks. Without a
// tracklist it falls back to returning the top individual results.
func (y *ytDlp) Search(ctx context.Context, dl Download) ([]Candidate, error) {
if len(dl.Expected) > 0 {
c, err := y.assembleAlbum(ctx, dl)
if err != nil {
return nil, err
}
if len(c.Files) > 0 {
return []Candidate{c}, nil
}
}
entries, err := y.search(ctx, dl.SearchText(), ytSearchCount)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(entries))
for _, e := range entries {
link := e.link()
if link == "" {
continue
}
name := sanitizePathPart(e.Title) + "." + y.extension()
out = append(out, Candidate{
ID: "ytdlp:" + e.ID,
Kind: KindYtDlp,
Protocol: ProtocolDirect,
Title: e.Title,
Artist: e.Uploader,
Origin: "yt-dlp",
Files: []CandidateFile{{
Path: name,
Size: e.Filesize,
IsAudio: true,
}},
TotalSize: e.Filesize,
// yt-dlp results are always available; there is no peer to
// be offline, so health carries no information here.
Health: 0.75,
Payload: map[string]string{name: link},
})
}
return out, nil
}
// assembleAlbum searches once per expected track and builds a single
// multi-file candidate. Tracks that find no result are left out; the
// completeness score then reflects the gap, and the importer's
// threshold decides whether what arrived is enough.
func (y *ytDlp) assembleAlbum(
ctx context.Context,
dl Download,
) (Candidate, error) {
type hit struct {
index int
entry ytEntry
}
var (
mu sync.Mutex
hits []hit
)
group, gctx := errgroup.WithContext(ctx)
group.SetLimit(ytTrackConcurrency)
for i, track := range dl.Expected {
group.Go(func() error {
query := strings.TrimSpace(
dl.Artist + " " + track.Title,
)
entries, err := y.search(gctx, query, 1)
if err != nil || len(entries) == 0 {
// One missing track is not a failed search. Recording
// nothing lets completeness scoring speak for it.
return nil //nolint:nilerr // partial results are expected
}
mu.Lock()
hits = append(hits, hit{index: i, entry: entries[0]})
mu.Unlock()
return nil
})
}
if err := group.Wait(); err != nil {
return Candidate{}, fmt.Errorf("assemble album: %w", err)
}
c := Candidate{
ID: "ytdlp:album:" + dl.ID,
Kind: KindYtDlp,
Protocol: ProtocolDirect,
Title: dl.Album,
Artist: dl.Artist,
Origin: "yt-dlp (assembled per track)",
Health: 0.75,
Payload: map[string]string{},
Files: make([]CandidateFile, 0, len(hits)),
}
for _, h := range hits {
track := dl.Expected[h.index]
link := h.entry.link()
if link == "" {
continue
}
// Name the staged file after the expected track, not the video
// title: the video is called whatever the uploader felt like,
// and the import step matches on filename.
name := trackToken(track.Position) + " - " +
sanitizePathPart(track.Title) + "." + y.extension()
c.Files = append(c.Files, CandidateFile{
Path: name,
Size: h.entry.Filesize,
IsAudio: true,
MatchedTo: track.Position,
})
c.TotalSize += h.entry.Filesize
c.Payload[name] = link
}
return c, nil
}
// search runs one yt-dlp search and decodes its JSON lines.
func (y *ytDlp) search(
ctx context.Context,
query string,
count int,
) ([]ytEntry, error) {
if strings.TrimSpace(query) == "" {
return nil, nil
}
// The search term is one argv element; yt-dlp parses the
// "ytsearchN:" prefix itself. No shell is involved at any point.
target := y.searchPrefix + strconv.Itoa(count) + ":" + query
args := []string{
"--dump-json",
"--flat-playlist",
"--no-warnings",
"--no-playlist",
"--ignore-config",
"--socket-timeout", "15",
target,
}
cmd := exec.CommandContext(ctx, y.binary, args...)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("%w: search: %w", ErrYtDlpFailed, err)
}
return decodeYtEntries(strings.NewReader(string(out))), nil
}
// decodeYtEntries reads newline-delimited JSON, skipping lines that do
// not parse. yt-dlp mixes warnings into stdout in some versions, and
// one bad line must not discard the rest of the results.
func decodeYtEntries(r io.Reader) []ytEntry {
var out []ytEntry
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "{") {
continue
}
var e ytEntry
if err := json.Unmarshal([]byte(line), &e); err != nil {
continue
}
if e.ID == "" {
continue
}
out = append(out, e)
}
return out
}
// Grab downloads each of the candidate's files into dst.
func (y *ytDlp) Grab(
ctx context.Context,
c Candidate,
dst string,
onProgress ProgressFunc,
) (Result, error) {
result := Result{Dir: dst, Files: make([]string, 0, len(c.Files))}
for i, f := range c.Files {
link, ok := c.Payload[f.Path]
if !ok {
continue
}
if err := validateHTTPURL(link); err != nil {
return Result{}, err
}
if onProgress != nil {
onProgress(Progress{
Current: int64(i),
Total: int64(len(c.Files)),
Phase: fmt.Sprintf(
"Downloading %d of %d", i+1, len(c.Files),
),
})
}
path, err := y.fetchOne(ctx, link, dst, f.Path)
if err != nil {
return Result{}, err
}
result.Files = append(result.Files, path)
}
return result, nil
}
// fetchOne downloads a single URL to a known filename inside dst.
func (y *ytDlp) fetchOne(
ctx context.Context,
link, dst, name string,
) (string, error) {
// Strip the extension from the output template: yt-dlp appends the
// real one after extraction, and forcing it here produces
// double-extensioned files.
stem := strings.TrimSuffix(name, filepath.Ext(name))
template := filepath.Join(dst, stem) + ".%(ext)s"
args := []string{
"--extract-audio",
"--no-playlist",
"--no-warnings",
"--ignore-config",
"--newline",
"--no-part",
"--socket-timeout", "30",
"--output", template,
}
if y.audioFormat != "" && y.audioFormat != "best" {
args = append(args, "--audio-format", y.audioFormat)
}
args = append(args, "--", link)
cmd := exec.CommandContext(ctx, y.binary, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf(
"%w: download: %w: %s",
ErrYtDlpFailed, err, lastLine(string(out)),
)
}
// The extracted extension is whatever yt-dlp produced, so find the
// file by stem rather than assuming.
matches, err := filepath.Glob(filepath.Join(dst, stem) + ".*")
if err != nil || len(matches) == 0 {
return "", fmt.Errorf(
"%w: no output file for %s", ErrYtDlpFailed, stem,
)
}
return matches[0], nil
}
// extension returns the file extension downloads will have.
func (y *ytDlp) extension() string {
if y.audioFormat == "" || y.audioFormat == "best" {
return "opus"
}
return y.audioFormat
}
// validateHTTPURL rejects anything that is not plain http(s). yt-dlp
// happily accepts file:// and other schemes, and a search result is
// untrusted input.
func validateHTTPURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("%w: %s", ErrUnsafeURL, raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%w: %s", ErrUnsafeURL, raw)
}
return nil
}
// lastLine returns the final non-empty line of output, which is where
// yt-dlp puts its error message.
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return line
}
}
return ""
}