omagrab/download.go
28allday 1febf47dcb Initial commit: omagrab v0.1.0
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>
2026-06-08 18:53:53 +01:00

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]
}