summaryrefslogtreecommitdiff
path: root/internal/fetch
diff options
context:
space:
mode:
Diffstat (limited to 'internal/fetch')
-rw-r--r--internal/fetch/fetch.go185
-rw-r--r--internal/fetch/fetch_test.go144
2 files changed, 329 insertions, 0 deletions
diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go
new file mode 100644
index 0000000..6dc4763
--- /dev/null
+++ b/internal/fetch/fetch.go
@@ -0,0 +1,185 @@
+// 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())
+ }
+}
diff --git a/internal/fetch/fetch_test.go b/internal/fetch/fetch_test.go
new file mode 100644
index 0000000..e55b508
--- /dev/null
+++ b/internal/fetch/fetch_test.go
@@ -0,0 +1,144 @@
+package fetch
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+)
+
+func TestPrettifyName_RestoresSpaces(t *testing.T) {
+ got := prettifyName("01 - The_Law_of_Gravitation.mp3")
+ if got != "01 - The Law of Gravitation.mp3" {
+ t.Errorf("got %q", got)
+ }
+}
+
+// yt-dlp's --restrict-filenames collapses odd characters into underscores, and
+// replacing them can leave runs of spaces behind.
+func TestPrettifyName_CollapsesRepeatedSpaces(t *testing.T) {
+ got := prettifyName("02 - A___Gap.mp3")
+ if got != "02 - A Gap.mp3" {
+ t.Errorf("got %q", got)
+ }
+}
+
+// Sources whose titles already begin with a track number produce
+// "01 - 01 - April.mp3" once the output template prepends its own index. Seen
+// for real against a LibriVox collection on the first live run.
+func TestPrettifyName_CollapsesDuplicatedTrackPrefix(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"01 - 01 - April.mp3", "01 - April.mp3"},
+ {"02 - 02 - Ave Atque Vale.mp3", "02 - Ave Atque Vale.mp3"},
+ {"03 - 03_-_The_Average_Man.mp3", "03 - The Average Man.mp3"},
+ {"10 - 10 - Tenth.mp3", "10 - Tenth.mp3"},
+ }
+ for _, c := range cases {
+ if got := prettifyName(c.in); got != c.want {
+ t.Errorf("prettifyName(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// Only collapse when the two numbers agree. A title that genuinely starts with
+// a different number is not a duplicated prefix and must be left alone.
+func TestPrettifyName_KeepsDistinctLeadingNumbers(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"01 - 1964 - The Messenger Lectures.mp3", "01 - 1964 - The Messenger Lectures.mp3"},
+ {"05 - 12 Angry Men.mp3", "05 - 12 Angry Men.mp3"},
+ }
+ for _, c := range cases {
+ if got := prettifyName(c.in); got != c.want {
+ t.Errorf("prettifyName(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestPrettifyName_LeavesCleanNamesAlone(t *testing.T) {
+ for _, n := range []string{"01 - April.mp3", "Introduction.mp3"} {
+ if got := prettifyName(n); got != n {
+ t.Errorf("prettifyName(%q) = %q, want it unchanged", n, got)
+ }
+ }
+}
+
+func TestRename_RenamesOnDisk(t *testing.T) {
+ dir := t.TempDir()
+ for _, n := range []string{"01 - 01 - April.mp3", "02 - The_Average_Man.mp3", ".podkit-archive"} {
+ if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if _, err := Rename(dir); err != nil {
+ t.Fatalf("Rename: %v", err)
+ }
+
+ entries, _ := os.ReadDir(dir)
+ var names []string
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ sort.Strings(names)
+
+ want := []string{".podkit-archive", "01 - April.mp3", "02 - The Average Man.mp3"}
+ if len(names) != len(want) {
+ t.Fatalf("got %v, want %v", names, want)
+ }
+ for i := range want {
+ if names[i] != want[i] {
+ t.Errorf("got %v, want %v", names, want)
+ break
+ }
+ }
+}
+
+// Renaming must never destroy an existing file to make a name prettier.
+func TestRename_DoesNotClobber(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "01 - April.mp3"), []byte("original"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "01 - 01 - April.mp3"), []byte("candidate"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := Rename(dir); err != nil {
+ t.Fatalf("Rename: %v", err)
+ }
+
+ body, err := os.ReadFile(filepath.Join(dir, "01 - April.mp3"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(body) != "original" {
+ t.Error("an existing file was overwritten by the rename")
+ }
+}
+
+func TestRename_MissingDirectoryIsAnError(t *testing.T) {
+ if _, err := Rename(filepath.Join(t.TempDir(), "nope")); err == nil {
+ t.Error("expected an error for a missing directory")
+ }
+}
+
+// Check must explain what to install, not just report a lookup failure.
+func TestErrNotInstalled_SaysHowToFixIt(t *testing.T) {
+ e := &ErrNotInstalled{}
+ for _, want := range []string{"yt-dlp", "pip install", "ffmpeg"} {
+ if !contains(e.Error(), want) {
+ t.Errorf("error message should mention %q; got %q", want, e.Error())
+ }
+ }
+}
+
+func contains(h, n string) bool {
+ return len(h) >= len(n) && (func() bool {
+ for i := 0; i+len(n) <= len(h); i++ {
+ if h[i:i+len(n)] == n {
+ return true
+ }
+ }
+ return false
+ })()
+}