summaryrefslogtreecommitdiff
path: root/internal/fetch/fetch.go
blob: 6dc4763040536669f414a2fdee4019d412d4c01c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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())
	}
}