// Package fetch wraps yt-dlp for audio extraction. package fetch import ( "bufio" "context" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" ) // Options configure a download run. type Options struct { URL string Dir string Format string // audio codec for --audio-format, e.g. "mp3" // Archive records completed downloads so a re-run skips them. This is what // makes an interrupted 7-hour lecture series resumable rather than starting // over, and makes re-running the whole pipeline cheap. Archive bool Stdout io.Writer Stderr io.Writer } // ErrNotInstalled is returned when yt-dlp cannot be found. type ErrNotInstalled struct{ Err error } func (e *ErrNotInstalled) Error() string { return "yt-dlp is not installed or not on PATH.\n" + "Install it with: pip install --user yt-dlp\n" + "(ffmpeg is also required for audio extraction.)" } func (e *ErrNotInstalled) Unwrap() error { return e.Err } // Check reports whether the external tools are available. // // Checked up front rather than letting the command fail mid-run: a missing // ffmpeg surfaces from yt-dlp as a post-processing error after the download has // already happened, which is a confusing place to learn about it. func Check() error { if _, err := exec.LookPath("yt-dlp"); err != nil { return &ErrNotInstalled{Err: err} } if _, err := exec.LookPath("ffmpeg"); err != nil { return fmt.Errorf("ffmpeg is not installed or not on PATH; yt-dlp needs it to extract audio") } return nil } // Run downloads and extracts audio into opts.Dir. func Run(ctx context.Context, opts Options) error { if opts.URL == "" { return fmt.Errorf("a playlist or video URL is required") } if err := Check(); err != nil { return err } if err := os.MkdirAll(opts.Dir, 0o755); err != nil { return fmt.Errorf("creating %s: %w", opts.Dir, err) } format := opts.Format if format == "" { format = "mp3" } // %(playlist_index)02d is empty for a single video, which would leave a // filename starting with " - ". The |NA default plus autonumber fallback // keeps single downloads sensibly named. template := filepath.Join(opts.Dir, "%(playlist_index|)02d - %(title)s.%(ext)s") args := []string{ "--extract-audio", "--audio-format", format, "--audio-quality", "0", "--embed-metadata", "--embed-thumbnail", // Keep going if one item in a long playlist is unavailable rather than // abandoning the rest. "--ignore-errors", "--no-overwrites", "--restrict-filenames", "--output", template, } if opts.Archive { args = append(args, "--download-archive", filepath.Join(opts.Dir, ".podkit-archive")) } args = append(args, opts.URL) cmd := exec.CommandContext(ctx, "yt-dlp", args...) cmd.Stdout = opts.Stdout cmd.Stderr = opts.Stderr if cmd.Stdout == nil { cmd.Stdout = os.Stdout } if cmd.Stderr == nil { cmd.Stderr = os.Stderr } if err := cmd.Run(); err != nil { return fmt.Errorf("yt-dlp failed: %w", err) } return nil } // Rename normalises yt-dlp's --restrict-filenames output back into readable // "NN - Title.ext" names. // // --restrict-filenames is used because unrestricted titles produce filenames // with quotes, slashes and emoji that make both shell use and URL construction // unpleasant. The cost is underscores everywhere, so they are converted back // here where it is safe to do so. func Rename(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("reading %s: %w", dir, err) } var renamed []string for _, e := range entries { if e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } pretty := prettifyName(e.Name()) if pretty == e.Name() { continue } from := filepath.Join(dir, e.Name()) to := filepath.Join(dir, pretty) if _, err := os.Stat(to); err == nil { // Never clobber an existing file to make a name prettier. continue } if err := os.Rename(from, to); err != nil { return renamed, fmt.Errorf("renaming %s: %w", e.Name(), err) } renamed = append(renamed, pretty) } return renamed, nil } // duplicatedPrefix matches "01 - 01 - Title", where the output template's // index has been prepended to a title that already carried the same number. var duplicatedPrefix = regexp.MustCompile(`^(\d+)\s*-\s*(\d+)\s*-\s*(.+)$`) func prettifyName(name string) string { ext := filepath.Ext(name) stem := strings.TrimSuffix(name, ext) stem = strings.ReplaceAll(stem, "_", " ") // Collapse the runs of spaces that replacing underscores tends to create. for strings.Contains(stem, " ") { stem = strings.ReplaceAll(stem, " ", " ") } stem = strings.TrimSpace(stem) // Sources whose own titles start with a track number end up doubled, since // the output template prepends the playlist index regardless: a LibriVox // collection produced "01 - 01 - April.mp3" on the first live run. // // Only collapse when the two numbers agree. "01 - 1964 - Lecture" is a year, // not a repeat, and "05 - 12 Angry Men" is a title that happens to start // with a digit — neither should be touched. if m := duplicatedPrefix.FindStringSubmatch(stem); m != nil { first, err1 := strconv.Atoi(m[1]) second, err2 := strconv.Atoi(m[2]) if err1 == nil && err2 == nil && first == second { stem = m[1] + " - " + strings.TrimSpace(m[3]) } } return stem + ext } // StreamLines copies r to w line by line, for surfacing yt-dlp progress. func StreamLines(r io.Reader, w io.Writer) { sc := bufio.NewScanner(r) for sc.Scan() { fmt.Fprintln(w, sc.Text()) } }