A yt-dlp TUI for Omarchy. Dual-mode (audio/video) paste-and-queue downloader built with Go + Bubble Tea. Live progress, config view, clipboard launch, and an install.sh that pulls in yt-dlp/ffmpeg/wl-clipboard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
180 lines
4.4 KiB
Go
180 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// progress lines are rendered by yt-dlp from this template; we tag them with a
|
|
// sentinel so they're trivially distinguished from yt-dlp's other chatter.
|
|
const progSentinel = "OMAGRABPROG|"
|
|
const progTemplate = "download:" + progSentinel +
|
|
"%(progress._percent_str)s|%(progress._speed_str)s|%(progress._eta_str)s"
|
|
|
|
// checkDeps returns the list of required external binaries that are missing.
|
|
func checkDeps() []string {
|
|
var missing []string
|
|
for _, bin := range []string{"yt-dlp", "ffmpeg"} {
|
|
if _, err := exec.LookPath(bin); err != nil {
|
|
missing = append(missing, bin)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// depPackages maps the missing binaries to their Arch package names.
|
|
func depPackages(missing []string) string {
|
|
pkg := map[string]string{"yt-dlp": "yt-dlp", "ffmpeg": "ffmpeg"}
|
|
var out []string
|
|
for _, m := range missing {
|
|
out = append(out, pkg[m])
|
|
}
|
|
return strings.Join(out, " ")
|
|
}
|
|
|
|
// buildArgs returns the yt-dlp arguments for one queued item, snapshotting the
|
|
// current config so later config changes don't affect an already-queued job.
|
|
func buildArgs(cfg Config, m mode) []string {
|
|
args := []string{
|
|
"--no-warnings", "--newline",
|
|
"--progress-template", progTemplate,
|
|
"-o", "%(title)s.%(ext)s",
|
|
}
|
|
if m == audioMode {
|
|
args = append(args,
|
|
"-P", ExpandHome(cfg.AudioDir),
|
|
"-x", "--audio-format", cfg.AudioFormat,
|
|
"--audio-quality", "0",
|
|
"--embed-metadata", "--embed-thumbnail",
|
|
)
|
|
} else {
|
|
var sel string
|
|
if cfg.VideoQuality == "best" {
|
|
sel = "bv*+ba/b"
|
|
} else {
|
|
sel = fmt.Sprintf("bv*[height<=%s]+ba/b[height<=%s]", cfg.VideoQuality, cfg.VideoQuality)
|
|
}
|
|
args = append(args,
|
|
"-P", ExpandHome(cfg.VideoDir),
|
|
"-f", sel,
|
|
"--merge-output-format", "mp4",
|
|
"--embed-metadata",
|
|
)
|
|
if cfg.Subtitles {
|
|
args = append(args, "--embed-subs", "--sub-langs", "en.*")
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
// worker processes the queue sequentially, streaming events back to the UI.
|
|
func worker(p *tea.Program, jobs <-chan *item) {
|
|
for it := range jobs {
|
|
runOne(p, it)
|
|
}
|
|
}
|
|
|
|
func runOne(p *tea.Program, it *item) {
|
|
// 1. resolve a human title (best effort; falls back to the URL).
|
|
p.Send(statusMsg{url: it.url, status: stFetching})
|
|
if title := fetchTitle(it.url); title != "" {
|
|
p.Send(titleMsg{url: it.url, title: title})
|
|
}
|
|
|
|
// 2. download, streaming progress.
|
|
p.Send(statusMsg{url: it.url, status: stDownloading})
|
|
args := append(append([]string{}, it.args...), it.url)
|
|
cmd := exec.Command("yt-dlp", args...)
|
|
|
|
pr, pw := io.Pipe()
|
|
cmd.Stdout = pw
|
|
cmd.Stderr = pw
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
p.Send(failMsg{url: it.url, err: err.Error()})
|
|
return
|
|
}
|
|
|
|
tail := newRing(8)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
sc := bufio.NewScanner(pr)
|
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if strings.HasPrefix(line, progSentinel) {
|
|
if pct, speed, eta, ok := parseProg(line); ok {
|
|
p.Send(progressMsg{url: it.url, percent: pct, speed: speed, eta: eta})
|
|
}
|
|
continue
|
|
}
|
|
tail.push(line)
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
err := cmd.Wait()
|
|
pw.Close()
|
|
<-done
|
|
|
|
if err != nil {
|
|
p.Send(failMsg{url: it.url, err: tail.last()})
|
|
return
|
|
}
|
|
p.Send(doneMsg{url: it.url})
|
|
}
|
|
|
|
// fetchTitle does a lightweight metadata-only lookup.
|
|
func fetchTitle(url string) string {
|
|
out, err := exec.Command("yt-dlp", "--no-warnings", "--skip-download",
|
|
"--no-playlist", "--print", "%(title)s", url).Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
|
}
|
|
|
|
func parseProg(line string) (pct float64, speed, eta string, ok bool) {
|
|
parts := strings.SplitN(strings.TrimPrefix(line, progSentinel), "|", 3)
|
|
if len(parts) != 3 {
|
|
return 0, "", "", false
|
|
}
|
|
ps := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(parts[0]), "%"))
|
|
pct, err := strconv.ParseFloat(ps, 64)
|
|
if err != nil {
|
|
return 0, "", "", false
|
|
}
|
|
return pct / 100, strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true
|
|
}
|
|
|
|
// ring is a tiny fixed-size line buffer for capturing the tail of stderr.
|
|
type ring struct {
|
|
buf []string
|
|
n int
|
|
}
|
|
|
|
func newRing(n int) *ring { return &ring{buf: make([]string, 0, n), n: n} }
|
|
|
|
func (r *ring) push(s string) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return
|
|
}
|
|
if len(r.buf) == r.n {
|
|
r.buf = r.buf[1:]
|
|
}
|
|
r.buf = append(r.buf, s)
|
|
}
|
|
|
|
func (r *ring) last() string {
|
|
if len(r.buf) == 0 {
|
|
return "download failed"
|
|
}
|
|
return r.buf[len(r.buf)-1]
|
|
}
|