From 746b1749984ad1ab528c6424ade40b27ca51f042 Mon Sep 17 00:00:00 2001 From: abzcoding Date: Thu, 14 May 2026 12:33:38 +0330 Subject: [PATCH 1/3] trying to add youtube download support Signed-off-by: abzcoding --- cmd/hget/main.go | 74 ++++- internal/extractor/detect.go | 40 +++ internal/extractor/detect_test.go | 91 ++++++ internal/extractor/meta.go | 128 ++++++++ internal/extractor/options_test.go | 151 ++++++++++ internal/extractor/run.go | 109 +++++++ internal/extractor/run_test.go | 184 ++++++++++++ internal/extractor/ytdlp.go | 321 ++++++++++++++++++++ internal/ui/extractor_tui.go | 401 +++++++++++++++++++++++++ internal/ui/extractor_tui_test.go | 294 ++++++++++++++++++ internal/ui/mixer.go | 422 ++++++++++++++++++++++++++ internal/ui/ui.go | 13 + internal/ui/vcr.go | 467 +++++++++++++++++++++++++++++ 13 files changed, 2694 insertions(+), 1 deletion(-) create mode 100644 internal/extractor/detect.go create mode 100644 internal/extractor/detect_test.go create mode 100644 internal/extractor/meta.go create mode 100644 internal/extractor/options_test.go create mode 100644 internal/extractor/run.go create mode 100644 internal/extractor/run_test.go create mode 100644 internal/extractor/ytdlp.go create mode 100644 internal/ui/extractor_tui.go create mode 100644 internal/ui/extractor_tui_test.go create mode 100644 internal/ui/mixer.go create mode 100644 internal/ui/vcr.go diff --git a/cmd/hget/main.go b/cmd/hget/main.go index be5dd9c..fe5db75 100644 --- a/cmd/hget/main.go +++ b/cmd/hget/main.go @@ -13,6 +13,7 @@ import ( "github.com/abzcoding/hget/internal/batch" "github.com/abzcoding/hget/internal/downloader" + "github.com/abzcoding/hget/internal/extractor" "github.com/abzcoding/hget/internal/state" "github.com/abzcoding/hget/internal/ui" "github.com/abzcoding/hget/internal/util" @@ -24,7 +25,7 @@ var GitCommit string func main() { flag.Usage = ui.PrintHelp - var proxy, filePath, bwLimit, resumeTask string + var proxy, filePath, bwLimit, resumeTask, extractorMode, cookiesFile, cookiesBrowser string conn := flag.Int("n", runtime.NumCPU(), "number of connections") skiptls := flag.Bool("skip-tls", false, "skip certificate verification for https") @@ -33,6 +34,9 @@ func main() { flag.StringVar(&filePath, "file", "", "path to a file that contains one URL per line") flag.StringVar(&bwLimit, "rate", "", "bandwidth limit during download, e.g. -rate 10kB or -rate 10MiB") flag.StringVar(&resumeTask, "resume", "", "resume download task with given task name (or URL)") + flag.StringVar(&extractorMode, "extractor", "auto", "extractor mode: auto | yt-dlp | none (auto picks yt-dlp for known media hosts)") + flag.StringVar(&cookiesFile, "cookies", "", "path to Netscape-format cookies.txt for the extractor (forwarded to yt-dlp --cookies)") + flag.StringVar(&cookiesBrowser, "cookies-from-browser", "", "browser to extract cookies from for the extractor, e.g. firefox, chrome:Default (forwarded to yt-dlp --cookies-from-browser)") probe := flag.String("probe", "", "probe URL for range and content-length without downloading") timeout := flag.Duration("timeout", 15*time.Second, "timeout for awaiting response headers (e.g., 30s, 1m)") @@ -77,6 +81,18 @@ func main() { // Single URL download. downloadURL := args[0] + + // Extractor mode (yt-dlp pipeline). Picked when explicitly forced + // or when --extractor=auto and the URL host matches a known media + // site that hget's HTTP engine can't handle directly (YouTube etc.). + if shouldUseExtractor(extractorMode, downloadURL) { + runExtractor(rootCtx, downloadURL, extractor.Options{ + CookiesFile: cookiesFile, + CookiesFromBrowser: cookiesBrowser, + }) + return + } + destFile := util.TaskFromURL(downloadURL) // Check if final file already exists @@ -166,6 +182,62 @@ func runResume(rootCtx context.Context, resumeTask string, conn int, skiptls boo runOne(rootCtx, st.URL, st, conn, skiptls, proxy, bwLimit, timeout) } +// shouldUseExtractor decides whether the URL should be routed through +// the yt-dlp pipeline. Modes: +// - "yt-dlp": always use yt-dlp +// - "auto": use yt-dlp when the host looks like a media site +// - "none": never use yt-dlp (force the plain HTTP engine) +func shouldUseExtractor(mode, url string) bool { + switch mode { + case "yt-dlp", "ytdlp": + return true + case "none", "off", "false": + return false + default: // "auto" and unknown values fall through to detection + return extractor.LooksExtractable(url) + } +} + +// runExtractor drives the yt-dlp pipeline behind the VCR + Mixer TUI. +// On success the resolved output file is left in the current working +// directory (yt-dlp's default), matching hget's existing behaviour. +// +// Cookie sources are validated BEFORE we start the TUI so a typo'd path +// produces a clean error in the terminal instead of a cryptic message +// flashing inside the alt-screen for half a second before exit. +func runExtractor(rootCtx context.Context, url string, opts extractor.Options) { + if opts.CookiesFile != "" { + if _, err := os.Stat(opts.CookiesFile); err != nil { + ui.ShowMessage(ui.MessageError, "COOKIES FILE NOT FOUND", + fmt.Sprintf("--cookies %s: %v", opts.CookiesFile, err)) + os.Exit(1) + } + } + if opts.CookiesFile != "" && opts.CookiesFromBrowser != "" { + ui.ShowMessage(ui.MessageWarning, "COOKIE SOURCES", + "both --cookies and --cookies-from-browser are set; yt-dlp will pick the browser source") + } + + itemCtx, cancelItem := context.WithCancelCause(rootCtx) + defer cancelItem(nil) + + err := ui.RunExtractorTUI(ui.ExtractorRunOptions{ + Ctx: itemCtx, + URL: url, + OnQuit: func() { cancelItem(downloader.ErrUserQuit) }, + }, func() error { + return extractor.Pipeline(itemCtx, url, "", opts) + }) + + if err != nil && + !errors.Is(err, downloader.ErrUserQuit) && + !errors.Is(err, downloader.ErrAbortBatch) && + !errors.Is(err, context.Canceled) { + ui.Errorln(err) + os.Exit(1) + } +} + func runOne(rootCtx context.Context, url string, st *state.State, conn int, skiptls bool, proxy, bwLimit string, timeout time.Duration) { itemCtx, cancelItem := context.WithCancelCause(rootCtx) defer cancelItem(nil) diff --git a/internal/extractor/detect.go b/internal/extractor/detect.go new file mode 100644 index 0000000..13aaedb --- /dev/null +++ b/internal/extractor/detect.go @@ -0,0 +1,40 @@ +package extractor + +import ( + "net/url" + "strings" +) + +// extractorHosts — non-exhaustive list of hostnames that yt-dlp handles +// natively but hget's HTTP engine cannot (manifest-based delivery, +// signed expiring URLs, post-processing required). Used to auto-route +// without forcing the user to pass --extractor. +// +// We deliberately keep this short and obvious — anything more exotic +// requires explicit --extractor=yt-dlp so power users opt in knowingly. +var extractorHosts = []string{ + "youtube.com", "youtu.be", "music.youtube.com", + "vimeo.com", "twitch.tv", "soundcloud.com", + "dailymotion.com", "bilibili.com", "twitter.com", + "x.com", "tiktok.com", "instagram.com", "facebook.com", + "reddit.com", "v.redd.it", +} + +// LooksExtractable returns true when rawURL points at a host that yt-dlp +// is known to handle and hget's plain HTTP engine is not. Used to suggest +// (or, with --extractor=auto, automatically pick) the extractor pipeline. +func LooksExtractable(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return false + } + host := strings.ToLower(u.Host) + host = strings.TrimPrefix(host, "www.") + host = strings.TrimPrefix(host, "m.") + for _, h := range extractorHosts { + if host == h || strings.HasSuffix(host, "."+h) { + return true + } + } + return false +} diff --git a/internal/extractor/detect_test.go b/internal/extractor/detect_test.go new file mode 100644 index 0000000..827306a --- /dev/null +++ b/internal/extractor/detect_test.go @@ -0,0 +1,91 @@ +package extractor + +import "testing" + +func TestLooksExtractable(t *testing.T) { + cases := []struct { + url string + want bool + }{ + {"https://www.youtube.com/watch?v=abc", true}, + {"https://youtu.be/abc", true}, + {"https://music.youtube.com/watch?v=abc", true}, + {"https://m.youtube.com/watch?v=abc", true}, + {"https://vimeo.com/12345", true}, + {"https://www.twitch.tv/clips/abc", true}, + {"https://soundcloud.com/artist/track", true}, + {"https://example.com/file.iso", false}, + {"https://releases.ubuntu.com/24.04/ubuntu.iso", false}, + {"not a url", false}, + {"", false}, + } + for _, c := range cases { + if got := LooksExtractable(c.url); got != c.want { + t.Errorf("LooksExtractable(%q)=%v want %v", c.url, got, c.want) + } + } +} + +func TestParseProgressLine(t *testing.T) { + line := "HGET| 12.5%|1048576|8388608|524288|45|3|10" + p, ok := parseProgressLine(line) + if !ok { + t.Fatal("expected parse ok") + } + if p.Percent != 12.5 { + t.Errorf("percent=%v want 12.5", p.Percent) + } + if p.Downloaded != 1048576 { + t.Errorf("downloaded=%d want 1048576", p.Downloaded) + } + if p.Total != 8388608 { + t.Errorf("total=%d want 8388608", p.Total) + } + if p.SpeedBPS != 524288 { + t.Errorf("speed=%v want 524288", p.SpeedBPS) + } + if p.ETA.Seconds() != 45 { + t.Errorf("eta=%v want 45s", p.ETA) + } + if p.Fragment != 3 || p.FragmentN != 10 { + t.Errorf("fragment=%d/%d want 3/10", p.Fragment, p.FragmentN) + } +} + +func TestParseProgressLineMalformed(t *testing.T) { + if _, ok := parseProgressLine("HGET|nope"); ok { + t.Error("expected parse failure on short line") + } +} + +func TestParseProgressLineNAFields(t *testing.T) { + line := "HGET| 50.0%|500|NA|None|NA|NA|NA" + p, ok := parseProgressLine(line) + if !ok { + t.Fatal("expected ok despite NA fields") + } + if p.Total != 0 || p.SpeedBPS != 0 { + t.Errorf("expected zeros for NA fields; got total=%d speed=%v", p.Total, p.SpeedBPS) + } +} + +func TestMetaNeedsMux(t *testing.T) { + if !(Meta{VideoFormat: "248", AudioFormat: "251"}).NeedsMux() { + t.Error("split v+a should need mux") + } + if (Meta{VideoFormat: "22", AudioFormat: "22"}).NeedsMux() { + t.Error("identical format ids = single stream, no mux") + } + if (Meta{VideoFormat: "22"}).NeedsMux() { + t.Error("video-only should not need mux") + } +} + +func TestMetaSafeFilename(t *testing.T) { + m := Meta{Title: "Hello / World", Container: "mp4"} + got := m.SafeFilename() + want := "Hello _ World.mp4" + if got != want { + t.Errorf("SafeFilename=%q want %q", got, want) + } +} diff --git a/internal/extractor/meta.go b/internal/extractor/meta.go new file mode 100644 index 0000000..d4c5629 --- /dev/null +++ b/internal/extractor/meta.go @@ -0,0 +1,128 @@ +package extractor + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// rawMeta mirrors the subset of yt-dlp -J fields we care about. yt-dlp +// emits a deeply-nested JSON document; we deliberately keep this struct +// flat and forgiving so a missing field never breaks the pipeline. +type rawMeta struct { + Title string `json:"title"` + Uploader string `json:"uploader"` + Channel string `json:"channel"` + Duration float64 `json:"duration"` + Width int `json:"width"` + Height int `json:"height"` + FPS float64 `json:"fps"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Ext string `json:"ext"` + FormatID string `json:"format_id"` + Filesize int64 `json:"filesize"` + FilesizeApprox int64 `json:"filesize_approx"` + RequestedFormats []struct { + FormatID string `json:"format_id"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Ext string `json:"ext"` + Width int `json:"width"` + Height int `json:"height"` + FPS float64 `json:"fps"` + Filesize int64 `json:"filesize"` + } `json:"requested_formats"` +} + +func parseMetaJSON(data []byte) (Meta, error) { + var r rawMeta + if err := json.Unmarshal(data, &r); err != nil { + return Meta{}, fmt.Errorf("decode yt-dlp metadata: %w", err) + } + m := Meta{ + Title: r.Title, + Uploader: firstNonEmpty(r.Uploader, r.Channel), + Duration: time.Duration(r.Duration * float64(time.Second)), + Container: r.Ext, + FPS: r.FPS, + VCodec: r.VCodec, + ACodec: r.ACodec, + Filesize: pickSize(r.Filesize, r.FilesizeApprox), + } + if r.Width > 0 && r.Height > 0 { + m.Resolution = fmt.Sprintf("%dx%d", r.Width, r.Height) + } + // When yt-dlp picks two streams (video + audio) it surfaces them in + // requested_formats; we use those for a cleaner v/a split readout. + for _, f := range r.RequestedFormats { + switch { + case f.VCodec != "" && f.VCodec != "none": + m.VideoFormat = f.FormatID + if m.Resolution == "" && f.Width > 0 && f.Height > 0 { + m.Resolution = fmt.Sprintf("%dx%d", f.Width, f.Height) + } + if m.VCodec == "" || m.VCodec == "none" { + m.VCodec = f.VCodec + } + if m.FPS == 0 { + m.FPS = f.FPS + } + case f.ACodec != "" && f.ACodec != "none": + m.AudioFormat = f.FormatID + if m.ACodec == "" || m.ACodec == "none" { + m.ACodec = f.ACodec + } + } + } + if m.VideoFormat == "" { + m.VideoFormat = r.FormatID + } + if m.Title == "" { + return m, fmt.Errorf("yt-dlp metadata had no title") + } + return m, nil +} + +// NeedsMux reports whether the chosen pipeline will require ffmpeg. +// True whenever yt-dlp will merge separate video + audio streams. +func (m Meta) NeedsMux() bool { + return m.AudioFormat != "" && m.VideoFormat != "" && m.AudioFormat != m.VideoFormat +} + +// SafeFilename renders a candidate output filename for display in the +// VCR plate. yt-dlp resolves the real one — this is just for the UI. +func (m Meta) SafeFilename() string { + t := strings.TrimSpace(m.Title) + if t == "" { + t = "video" + } + t = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|': + return '_' + } + return r + }, t) + if m.Container != "" { + return t + "." + m.Container + } + return t + ".mp4" +} + +func firstNonEmpty(s ...string) string { + for _, v := range s { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func pickSize(a, b int64) int64 { + if a > 0 { + return a + } + return b +} diff --git a/internal/extractor/options_test.go b/internal/extractor/options_test.go new file mode 100644 index 0000000..0761aea --- /dev/null +++ b/internal/extractor/options_test.go @@ -0,0 +1,151 @@ +package extractor + +import ( + "context" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestOptions_AuthArgs_Empty(t *testing.T) { + if got := (Options{}).authArgs(); len(got) != 0 { + t.Errorf("zero Options should produce no args, got %v", got) + } +} + +func TestOptions_AuthArgs_CookiesFile(t *testing.T) { + got := Options{CookiesFile: "/tmp/cookies.txt"}.authArgs() + want := []string{"--cookies", "/tmp/cookies.txt"} + if !reflect.DeepEqual(got, want) { + t.Errorf("CookiesFile authArgs=%v want %v", got, want) + } +} + +func TestOptions_AuthArgs_CookiesFromBrowser(t *testing.T) { + got := Options{CookiesFromBrowser: "firefox:Default"}.authArgs() + want := []string{"--cookies-from-browser", "firefox:Default"} + if !reflect.DeepEqual(got, want) { + t.Errorf("CookiesFromBrowser authArgs=%v want %v", got, want) + } +} + +func TestOptions_AuthArgs_Both(t *testing.T) { + // When both are set we forward both — yt-dlp itself decides + // precedence (latest winning). This keeps the wrapper agnostic. + got := Options{ + CookiesFile: "/tmp/c.txt", + CookiesFromBrowser: "chrome", + }.authArgs() + want := []string{ + "--cookies", "/tmp/c.txt", + "--cookies-from-browser", "chrome", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("authArgs=%v want %v", got, want) + } +} + +// TestRun_ForwardsCookieFlagsToYTDLP boots a shim that records its argv +// and verifies the cookie flags actually land on the yt-dlp command-line. +// Without this test, a refactor of args[] could silently drop them. +func TestRun_ForwardsCookieFlagsToYTDLP(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shim is POSIX-only") + } + tmp := t.TempDir() + shim := filepath.Join(tmp, "yt-dlp") + argDump := filepath.Join(tmp, "argv") + script := `#!/bin/sh +printf '%s\n' "$@" > ` + argDump + ` +echo "[download] Destination: /tmp/out.mp4" +echo "HGET|100.0%|10|10|10|0|NA|NA" +exit 0 +` + if err := os.WriteFile(shim, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", tmp) + + sink := &fakeSink{} + _, err := Run(context.Background(), "https://example.com/x", "", + Options{CookiesFile: "/tmp/c.txt", CookiesFromBrowser: "firefox"}, + sink) + if err != nil { + t.Fatalf("Run: %v", err) + } + raw, err := os.ReadFile(argDump) + if err != nil { + t.Fatalf("read argv dump: %v", err) + } + args := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + mustHaveSeq(t, args, "--cookies", "/tmp/c.txt") + mustHaveSeq(t, args, "--cookies-from-browser", "firefox") +} + +// TestProbe_ForwardsCookieFlagsToYTDLP — same idea, but for the JSON +// probe path. YouTube's bot challenge gates the probe too, so cookies +// must reach yt-dlp -J as well. +func TestProbe_ForwardsCookieFlagsToYTDLP(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shim is POSIX-only") + } + tmp := t.TempDir() + shim := filepath.Join(tmp, "yt-dlp") + argDump := filepath.Join(tmp, "argv") + // shim must emit a valid -J JSON document so parseMetaJSON succeeds. + script := `#!/bin/sh +printf '%s\n' "$@" > ` + argDump + ` +echo '{"title":"Probe Sample","ext":"mp4","duration":10}' +exit 0 +` + if err := os.WriteFile(shim, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", tmp) + + _, err := Probe(context.Background(), "https://example.com/x", + Options{CookiesFile: "/tmp/c.txt"}) + if err != nil { + t.Fatalf("Probe: %v", err) + } + raw, err := os.ReadFile(argDump) + if err != nil { + t.Fatal(err) + } + args := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + mustHaveSeq(t, args, "--cookies", "/tmp/c.txt") + // -J still present? + mustContainArg(t, args, "-J") +} + +// mustHaveSeq fails the test unless `seq` appears as consecutive +// elements in args. +func mustHaveSeq(t *testing.T, args []string, seq ...string) { + t.Helper() + for i := 0; i+len(seq) <= len(args); i++ { + match := true + for j, s := range seq { + if args[i+j] != s { + match = false + break + } + } + if match { + return + } + } + t.Errorf("expected sequence %v in args; got %v", seq, args) +} + +func mustContainArg(t *testing.T, args []string, want string) { + t.Helper() + for _, a := range args { + if a == want { + return + } + } + t.Errorf("expected arg %q in args; got %v", want, args) +} diff --git a/internal/extractor/run.go b/internal/extractor/run.go new file mode 100644 index 0000000..f74358d --- /dev/null +++ b/internal/extractor/run.go @@ -0,0 +1,109 @@ +package extractor + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/abzcoding/hget/internal/ui" +) + +// uiSink adapts the extractor's MetaSink interface onto the active TUI +// program by translating each event into a ui.Extractor* Tea message. +// When ui.Program is nil (no TUI), events are dropped — caller is +// expected to print a friendly summary in that path. +type uiSink struct{ meta Meta } + +func (s *uiSink) OnMeta(m Meta) { + s.meta = m + if ui.Program == nil { + return + } + ui.Program.Send(ui.ExtractorMetaMsg{ + Title: m.Title, + Channel: m.Uploader, + Duration: m.Duration, + Resolution: m.Resolution, + FPS: m.FPS, + VCodec: m.VCodec, + ACodec: m.ACodec, + Container: m.Container, + HasAudio: m.NeedsMux() || (m.ACodec != "" && m.ACodec != "none"), + OutputFile: m.SafeFilename(), + }) +} + +func (s *uiSink) OnDownloadProgress(p DownloadProgress) { + if ui.Program == nil { + return + } + ui.Program.Send(ui.ExtractorProgressMsg{ + Percent: p.Percent, + Downloaded: p.Downloaded, + Total: p.Total, + SpeedBPS: p.SpeedBPS, + ETA: p.ETA, + Fragment: p.Fragment, + FragmentN: p.FragmentN, + }) +} + +func (s *uiSink) OnPhaseChange(ph Phase) { + if ui.Program == nil { + return + } + switch ph { + case PhaseDownloading: + ui.Program.Send(ui.ExtractorPhaseMsg{Phase: "downloading"}) + case PhaseMuxing: + ui.Program.Send(ui.ExtractorPhaseMsg{Phase: "muxing"}) + case PhaseDone: + // 'done' is sent by the TUI runner after worker returns nil so + // it lines up with the actual exit code; we don't fire it here. + case PhaseError: + // likewise — the worker's returned error drives the error UI. + } +} + +func (s *uiSink) OnLog(level, line string) { + if ui.Program == nil { + return + } + ui.Program.Send(ui.ExtractorLogMsg{Level: level, Text: line}) +} + +// Pipeline runs the full extractor pipeline: probe → download/mux → done. +// The TUI is started by RunExtractorTUI; this function is the worker +// passed to it. It blocks until the yt-dlp child exits or ctx is +// cancelled. +// +// `outDir` is forwarded to yt-dlp via -P (download path). Empty means +// "current working directory" — matching hget's existing behaviour for +// HTTP downloads. `opts` carries optional auth (cookies file / browser). +func Pipeline(ctx context.Context, url, outDir string, opts Options) error { + sink := &uiSink{} + + // ── Probe phase. ──────────────────────────────────────────────── + // Probe is fast (single HTTP roundtrip) — so we wrap it in a tight + // timeout so a wedged extractor process can't hang the TUI before + // the user even sees a frame. 60s is generous: YouTube's bot + // challenge can add several seconds to the initial extraction even + // when valid cookies are supplied. + probeCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + meta, err := Probe(probeCtx, url, opts) + cancel() + if err != nil { + if errors.Is(err, ErrNotInstalled) { + return fmt.Errorf("%w — install with `brew install yt-dlp` (macOS) or `pipx install yt-dlp`", err) + } + return err + } + sink.OnMeta(meta) + + // ── Run yt-dlp. ───────────────────────────────────────────────── + if _, err := Run(ctx, url, outDir, opts, sink); err != nil { + return err + } + return nil +} diff --git a/internal/extractor/run_test.go b/internal/extractor/run_test.go new file mode 100644 index 0000000..4dc6674 --- /dev/null +++ b/internal/extractor/run_test.go @@ -0,0 +1,184 @@ +package extractor + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +// fakeSink captures every event the parser emits. +type fakeSink struct { + mu sync.Mutex + meta *Meta + progress []DownloadProgress + phases []Phase + logs []string +} + +func (f *fakeSink) OnMeta(m Meta) { + f.mu.Lock() + defer f.mu.Unlock() + f.meta = &m +} +func (f *fakeSink) OnDownloadProgress(p DownloadProgress) { + f.mu.Lock() + defer f.mu.Unlock() + f.progress = append(f.progress, p) +} +func (f *fakeSink) OnPhaseChange(ph Phase) { + f.mu.Lock() + defer f.mu.Unlock() + f.phases = append(f.phases, ph) +} +func (f *fakeSink) OnLog(level, line string) { + f.mu.Lock() + defer f.mu.Unlock() + f.logs = append(f.logs, level+":"+line) +} + +// fakeYTDLPScript writes a deterministic stream of HGET| lines to stdout +// so we can exercise the parser without depending on the network or on a +// specific yt-dlp version. We swap PATH temporarily so the subprocess +// dispatch picks up our shim binary instead of the real yt-dlp. +const fakeScript = `#!/bin/sh +echo "[vimeo] Extracting URL" +echo "[download] Destination: /tmp/Test.f137.mp4" +echo "HGET| 0.0%|0|1000000|0|10|NA|NA" +echo "HGET| 25.0%|250000|1000000|500000|6|NA|NA" +echo "HGET| 50.0%|500000|1000000|800000|3|NA|NA" +echo "HGET| 75.0%|750000|1000000|1200000|1|NA|NA" +echo "HGET|100.0%|1000000|1000000|1500000|0|NA|NA" +echo "[Merger] Merging formats into \"/tmp/Test.mp4\"" +exit 0 +` + +func TestRun_StreamsProgressAndPhasesViaSink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shim script is POSIX-only") + } + + // Build a temp PATH containing only our shim. The real yt-dlp on + // the developer's machine never gets invoked in this test. + tmp := t.TempDir() + shim := filepath.Join(tmp, "yt-dlp") + if err := os.WriteFile(shim, []byte(fakeScript), 0o755); err != nil { + t.Fatalf("write shim: %v", err) + } + t.Setenv("PATH", tmp) + + sink := &fakeSink{} + out, err := Run(context.Background(), "https://example.com/x", "", Options{}, sink) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if out != "/tmp/Test.mp4" { + t.Errorf("output path: got %q want /tmp/Test.mp4", out) + } + + if got := len(sink.progress); got != 5 { + t.Errorf("expected 5 progress events, got %d", got) + } + if len(sink.progress) >= 5 { + want := []float64{0.0, 25.0, 50.0, 75.0, 100.0} + for i, w := range want { + if sink.progress[i].Percent != w { + t.Errorf("progress[%d].Percent = %v, want %v", i, sink.progress[i].Percent, w) + } + } + mid := sink.progress[2] + if mid.Downloaded != 500000 || mid.Total != 1000000 { + t.Errorf("midpoint downloaded/total = %d/%d, want 500000/1000000", mid.Downloaded, mid.Total) + } + if mid.SpeedBPS != 800000 { + t.Errorf("midpoint speed = %v, want 800000", mid.SpeedBPS) + } + if mid.ETA != 3*time.Second { + t.Errorf("midpoint ETA = %v, want 3s", mid.ETA) + } + } + + // Phase ordering: Downloading first, then Muxing on the [Merger] line. + if len(sink.phases) < 2 { + t.Fatalf("expected at least 2 phase changes, got %d (%v)", len(sink.phases), sink.phases) + } + if sink.phases[0] != PhaseDownloading { + t.Errorf("first phase = %v, want PhaseDownloading", sink.phases[0]) + } + sawMuxing := false + for _, p := range sink.phases { + if p == PhaseMuxing { + sawMuxing = true + } + } + if !sawMuxing { + t.Errorf("expected PhaseMuxing after [Merger] line, got phases=%v", sink.phases) + } + + // Done phase fires from sink at the end of Run(). + if sink.phases[len(sink.phases)-1] != PhaseDone { + t.Errorf("last phase = %v, want PhaseDone", sink.phases[len(sink.phases)-1]) + } +} + +// TestRun_StderrLinesSurfaceAsWarnLogs proves stderr output flows +// through the log channel rather than getting silently dropped. +func TestRun_StderrLinesSurfaceAsWarnLogs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shim script is POSIX-only") + } + tmp := t.TempDir() + shim := filepath.Join(tmp, "yt-dlp") + stderrScript := `#!/bin/sh +echo "WARNING: this is on stderr" 1>&2 +echo "HGET| 50.0%|500|1000|100|2|NA|NA" +exit 0 +` + if err := os.WriteFile(shim, []byte(stderrScript), 0o755); err != nil { + t.Fatalf("write shim: %v", err) + } + t.Setenv("PATH", tmp) + + sink := &fakeSink{} + if _, err := Run(context.Background(), "https://x.com/y", "", Options{}, sink); err != nil { + t.Fatalf("Run: %v", err) + } + gotWarn := false + for _, l := range sink.logs { + if strings.HasPrefix(l, "warn:") && strings.Contains(l, "WARNING") { + gotWarn = true + } + } + if !gotWarn { + t.Errorf("expected stderr to surface as warn log; got logs=%v", sink.logs) + } +} + +// confirms our shim mechanism actually replaces yt-dlp on PATH (sanity check) +func TestShimResolution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shim script is POSIX-only") + } + tmp := t.TempDir() + shim := filepath.Join(tmp, "yt-dlp") + if err := os.WriteFile(shim, []byte("#!/bin/sh\necho shim-output\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", tmp) + out, err := exec.Command("yt-dlp").Output() + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(out)) != "shim-output" { + t.Errorf("PATH override didn't pick up shim; got %q", out) + } +} + +// silence unused import warning when building without exec usage above +var _ = io.EOF diff --git a/internal/extractor/ytdlp.go b/internal/extractor/ytdlp.go new file mode 100644 index 0000000..5dc31ac --- /dev/null +++ b/internal/extractor/ytdlp.go @@ -0,0 +1,321 @@ +// Package extractor wraps external media extractors (currently yt-dlp) +// and the post-processing toolchain (ffmpeg muxing). The package emits +// structured Tea messages so the UI layer can render bespoke animations +// (VCR for download, mixer for muxing) without parsing subprocess output +// itself. +package extractor + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// ErrNotInstalled is returned when yt-dlp is missing from $PATH. +var ErrNotInstalled = errors.New("yt-dlp is not installed or not on $PATH") + +// Options carries optional knobs threaded into every yt-dlp invocation +// (probe + download). The zero value is valid and means "no extras". +// +// We deliberately group these into a struct so adding the next auth / +// proxy / format knob doesn't ripple through every Probe/Run/Pipeline +// signature. +type Options struct { + // CookiesFile is a path to a Netscape-format cookies.txt that + // yt-dlp will use for authentication. Equivalent to passing + // `--cookies ` to yt-dlp. Empty means no cookie file. + CookiesFile string + + // CookiesFromBrowser is a yt-dlp browser spec + // `BROWSER[+KEYRING][:PROFILE][::CONTAINER]` — e.g. "firefox", + // "chrome:Default", "firefox:my-profile". Equivalent to passing + // `--cookies-from-browser `. Empty means no browser cookie + // extraction. + CookiesFromBrowser string +} + +// authArgs returns the yt-dlp CLI fragments the Options struct contributes. +// Returns an empty slice when no auth knobs are set. Validation of the +// values themselves is left to yt-dlp (it produces good error messages). +func (o Options) authArgs() []string { + var args []string + if o.CookiesFile != "" { + args = append(args, "--cookies", o.CookiesFile) + } + if o.CookiesFromBrowser != "" { + args = append(args, "--cookies-from-browser", o.CookiesFromBrowser) + } + return args +} + +// MetaSink receives streaming events as the yt-dlp child process emits +// them. Implementations are expected to be cheap (channel send / Tea +// Program.Send) — the parser does not buffer. +type MetaSink interface { + OnMeta(Meta) + OnDownloadProgress(DownloadProgress) + OnPhaseChange(Phase) + OnLog(level, line string) +} + +// Phase is the high-level stage of the extractor pipeline. +type Phase int + +const ( + PhaseProbing Phase = iota // metadata fetch (yt-dlp -J) + PhaseDownloading + PhaseMuxing // yt-dlp post-processing (Merger / Audio extraction) + PhaseDone + PhaseError +) + +// Meta carries the resolved video metadata. +type Meta struct { + Title string + Uploader string + Duration time.Duration + VideoFormat string // chosen video stream id ("248") + AudioFormat string // chosen audio stream id ("251") — empty when single-stream + Container string // final container ("mp4", "webm", "mkv") + OutputFile string // final output file path (resolved post-merge) + Resolution string // e.g. "1920x1080" + FPS float64 + VCodec string + ACodec string + Filesize int64 // best-effort estimate from -J ("filesize" or "filesize_approx") +} + +// DownloadProgress is the parsed state of one yt-dlp [download] line. +type DownloadProgress struct { + Percent float64 + Downloaded int64 + Total int64 + SpeedBPS float64 + ETA time.Duration + Fragment int // current fragment index (HLS / DASH); -1 if N/A + FragmentN int // total fragments; -1 if N/A + StreamLabel string // "video" / "audio" / "" — derived from format id transitions + RawSpeedText string +} + +// Probe runs `yt-dlp -J ` and returns metadata. Honours ctx. +// Auth knobs in opts are forwarded so probes against gated sites +// (YouTube bot challenge, age-gates, members-only videos) work too. +func Probe(ctx context.Context, url string, opts Options) (Meta, error) { + if _, err := exec.LookPath("yt-dlp"); err != nil { + return Meta{}, ErrNotInstalled + } + args := []string{"-J", "--no-warnings", "--no-playlist"} + args = append(args, opts.authArgs()...) + args = append(args, url) + cmd := exec.CommandContext(ctx, "yt-dlp", args...) + out, err := cmd.Output() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) && len(ee.Stderr) > 0 { + return Meta{}, fmt.Errorf("yt-dlp probe failed: %s", strings.TrimSpace(string(ee.Stderr))) + } + return Meta{}, fmt.Errorf("yt-dlp probe failed: %w", err) + } + return parseMetaJSON(out) +} + +// progressTemplate uses yt-dlp's --progress-template to print a stable, +// pipe-delimited progress line we can parse without regex tap-dancing. +// +// The leading `download:` is required — without an explicit TYPE prefix +// yt-dlp silently discards the template instead of using it for download +// progress, and our VCR panel ends up with zero updates. +// +// Fields, in order: +// +// PCT|DOWNLOADED|TOTAL|SPEED|ETA|FRAGINDEX|FRAGTOTAL +// +// We re-parse the numeric values in Go because yt-dlp's pre-formatted +// `_*_str` values are locale-sensitive and noisy. +const progressTemplate = "download:HGET|%(progress._percent_str)s|%(progress.downloaded_bytes)s|%(progress.total_bytes,progress.total_bytes_estimate)s|%(progress.speed)s|%(progress.eta)s|%(progress.fragment_index)s|%(progress.fragment_count)s" + +// Run streams a download via yt-dlp. Events flow through sink. The +// child process is killed when ctx is cancelled (CommandContext does the +// SIGKILL). Returns the chosen output path on success. +func Run(ctx context.Context, url, outDir string, opts Options, sink MetaSink) (string, error) { + if _, err := exec.LookPath("yt-dlp"); err != nil { + return "", ErrNotInstalled + } + + // `--print` is intentionally omitted: in yt-dlp it implies `--quiet` + // which silences both download progress and the [Merger] line we use + // to detect the muxing phase. Instead we parse `[download] + // Destination:` and `[Merger] Merging formats into "..."` ourselves. + args := []string{ + "--no-warnings", + "--no-playlist", + "--newline", // emit one progress line per update + "--progress-template", progressTemplate, + "-f", "bv*+ba/b", // best video+audio, fall back to single + "--merge-output-format", "mp4", + "-o", "%(title)s.%(ext)s", + } + args = append(args, opts.authArgs()...) + args = append(args, url) + if outDir != "" { + args = append([]string{"-P", outDir}, args...) + } + + cmd := exec.CommandContext(ctx, "yt-dlp", args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return "", err + } + + if err := cmd.Start(); err != nil { + return "", err + } + + var ( + outPath string + mu sync.Mutex + setPath = func(p string) { mu.Lock(); outPath = p; mu.Unlock() } + wg sync.WaitGroup + curPhase = PhaseDownloading + ) + sink.OnPhaseChange(PhaseDownloading) + + scan := func(r io.Reader, isErr bool) { + defer wg.Done() + s := bufio.NewScanner(r) + s.Buffer(make([]byte, 0, 1<<16), 1<<20) + for s.Scan() { + line := s.Text() + if strings.HasPrefix(line, "HGET|") { + if dp, ok := parseProgressLine(line); ok { + sink.OnDownloadProgress(dp) + } + continue + } + // Resolved output path discovery — non-mux downloads + // surface as `[download] Destination: `, merged + // downloads finish with `[Merger] Merging formats into + // ""`. Both shapes get extracted here so callers + // always see the final filename. + if p, ok := extractDestination(line); ok { + setPath(p) + } + // Phase transitions detected from yt-dlp's own log lines. + switch { + case strings.HasPrefix(line, "[Merger]"), + strings.HasPrefix(line, "[ExtractAudio]"), + strings.HasPrefix(line, "[ffmpeg]"), + strings.HasPrefix(line, "[VideoConvertor]"): + if curPhase != PhaseMuxing { + curPhase = PhaseMuxing + sink.OnPhaseChange(PhaseMuxing) + } + sink.OnLog("info", line) + default: + lvl := "info" + if isErr { + lvl = "warn" + } + if line != "" { + sink.OnLog(lvl, line) + } + } + } + } + + wg.Add(2) + go scan(stdout, false) + go scan(stderr, true) + wg.Wait() + + if err := cmd.Wait(); err != nil { + if ctx.Err() != nil { + return outPath, ctx.Err() + } + return outPath, fmt.Errorf("yt-dlp exited: %w", err) + } + sink.OnPhaseChange(PhaseDone) + return outPath, nil +} + +// ── parsers ──────────────────────────────────────────────────────────── + +var ( + floatRE = regexp.MustCompile(`-?\d+(\.\d+)?`) + // `[download] Destination: ` — fired before the bytes start + // flowing. Robust against absolute/relative paths and spaces. + destRE = regexp.MustCompile(`^\[download\] Destination:\s+(.+)$`) + // `[Merger] Merging formats into ""` — only present when + // yt-dlp invokes ffmpeg to combine separate v+a streams. We prefer + // this path when both lines fire, since it reflects the merged file. + mergeRE = regexp.MustCompile(`^\[Merger\] Merging formats into "([^"]+)"`) +) + +// extractDestination pulls the resolved output path out of a yt-dlp log +// line. Returns ("", false) when the line is unrelated. +func extractDestination(line string) (string, bool) { + if m := mergeRE.FindStringSubmatch(line); len(m) >= 2 { + return strings.TrimSpace(m[1]), true + } + if m := destRE.FindStringSubmatch(line); len(m) >= 2 { + return strings.TrimSpace(m[1]), true + } + return "", false +} + +func parseProgressLine(line string) (DownloadProgress, bool) { + // HGET|PCT|DOWNLOADED|TOTAL|SPEED|ETA|FRAGINDEX|FRAGTOTAL + parts := strings.Split(line, "|") + if len(parts) < 8 { + return DownloadProgress{}, false + } + dp := DownloadProgress{Fragment: -1, FragmentN: -1} + + if m := floatRE.FindString(parts[1]); m != "" { + if v, err := strconv.ParseFloat(m, 64); err == nil { + dp.Percent = v + } + } + dp.Downloaded = parseInt64(parts[2]) + dp.Total = parseInt64(parts[3]) + if v, err := strconv.ParseFloat(parts[4], 64); err == nil { + dp.SpeedBPS = v + } + if v, err := strconv.Atoi(parts[5]); err == nil { + dp.ETA = time.Duration(v) * time.Second + } + if v, err := strconv.Atoi(parts[6]); err == nil { + dp.Fragment = v + } + if v, err := strconv.Atoi(parts[7]); err == nil { + dp.FragmentN = v + } + return dp, true +} + +func parseInt64(s string) int64 { + s = strings.TrimSpace(s) + if s == "" || s == "NA" || s == "None" { + return 0 + } + if v, err := strconv.ParseInt(s, 10, 64); err == nil { + return v + } + if v, err := strconv.ParseFloat(s, 64); err == nil { + return int64(v) + } + return 0 +} diff --git a/internal/ui/extractor_tui.go b/internal/ui/extractor_tui.go new file mode 100644 index 0000000..6218b4b --- /dev/null +++ b/internal/ui/extractor_tui.go @@ -0,0 +1,401 @@ +package ui + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-isatty" +) + +// ── Extractor-mode Tea messages ────────────────────────────────────────────── +// +// Kept in this file (rather than ui.go) because they're specific to the +// extractor pipeline and must not collide with the existing TUI model's +// download / verify message vocabulary. + +// ExtractorMetaMsg seeds the VCR header with metadata from yt-dlp -J. +type ExtractorMetaMsg struct { + Title string + Channel string + Duration time.Duration + Resolution string + FPS float64 + VCodec string + ACodec string + Container string + HasAudio bool + OutputFile string // hint — actual path resolved post-merge +} + +// ExtractorProgressMsg pushes one progress update from yt-dlp. +type ExtractorProgressMsg struct { + Percent float64 // 0..100 + Downloaded int64 + Total int64 + SpeedBPS float64 + ETA time.Duration + Fragment int + FragmentN int +} + +// ExtractorPhaseMsg switches the active panel: VCR vs Mixer vs done. +type ExtractorPhaseMsg struct { + Phase string // "downloading" | "muxing" | "done" | "error" +} + +// ExtractorOutputMsg surfaces yt-dlp's resolved output path post-merge. +type ExtractorOutputMsg struct{ Path string } + +// ExtractorLogMsg adds a line to the on-screen log strip. +type ExtractorLogMsg struct { + Level string // "info" | "warn" | "error" + Text string +} + +// ExtractorErrorMsg signals a fatal error in the extractor pipeline. +type ExtractorErrorMsg struct{ Err error } + +// ExtractorDoneMsg signals success — auto-quit countdown begins. +type ExtractorDoneMsg struct{} + +// extractorTickMsg drives animations. +type extractorTickMsg time.Time + +// ── Model ──────────────────────────────────────────────────────────────────── + +type extractorModel struct { + url string + width int + height int + spinner spinner.Model + + vcr VCRAnimation + mixer MixerAnimation + phase string // "downloading" | "muxing" | "done" | "error" + + meta ExtractorMetaMsg + logs []logEntry + maxLogs int + outputPath string + + stopping bool + hasError bool + errMsg string + done bool + + onQuit func() + startT time.Time +} + +// NewExtractorModel constructs the extractor-mode TUI model. +func NewExtractorModel(url string, onQuit func()) extractorModel { + s := spinner.New() + s.Spinner = signalPulse + s.Style = lipgloss.NewStyle().Foreground(Theme.Magenta) + return extractorModel{ + url: url, + width: 80, + spinner: s, + vcr: NewVCR(), + mixer: NewMixer(), + phase: "downloading", + maxLogs: 5, + onQuit: onQuit, + startT: time.Now(), + } +} + +func (m extractorModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, extractorTickCmd()) +} + +func extractorTickCmd() tea.Cmd { + return tea.Tick(16*time.Millisecond, func(t time.Time) tea.Msg { return extractorTickMsg(t) }) +} + +func (m extractorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "q", "Q", "ctrl+c": + if m.stopping { + return m, tea.Quit + } + m.stopping = true + if m.onQuit != nil { + m.onQuit() + } + return m, m.spinner.Tick + } + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case extractorTickMsg: + m.vcr.Tick() + m.mixer.Tick() + return m, extractorTickCmd() + + case ExtractorMetaMsg: + m.meta = msg + m.vcr.SetMeta(VCRMeta{ + Title: msg.Title, + Channel: msg.Channel, + Duration: msg.Duration, + Resolution: msg.Resolution, + FPS: msg.FPS, + VCodec: msg.VCodec, + ACodec: msg.ACodec, + Container: msg.Container, + HasAudio: msg.HasAudio, + }) + m.mixer.SetMeta(MixerMeta{ + VideoCodec: msg.VCodec, + AudioCodec: msg.ACodec, + Container: msg.Container, + OutputFile: msg.OutputFile, + }) + return m, nil + + case ExtractorPhaseMsg: + m.phase = msg.Phase + switch msg.Phase { + case "downloading": + m.vcr.SetMode(VCRRecording) + m.mixer.SetMode(MixerIdle) + case "muxing": + m.vcr.SetMode(VCREjecting) + m.mixer.SetMode(MixerMixing) + case "done": + m.vcr.SetMode(VCREjecting) + m.mixer.SetMode(MixerDone) + m.done = true + return m, tea.Tick(2500*time.Millisecond, func(t time.Time) tea.Msg { return ExtractorDoneMsg{} }) + case "error": + m.vcr.SetMode(VCRError) + m.mixer.SetMode(MixerError) + } + return m, nil + + case ExtractorProgressMsg: + m.vcr.Update(msg.Percent, msg.Downloaded, msg.Total, msg.SpeedBPS, msg.ETA, msg.Fragment, msg.FragmentN) + return m, nil + + case ExtractorOutputMsg: + m.outputPath = msg.Path + mm := m.mixer + mm.meta.OutputFile = msg.Path + m.mixer = mm + return m, nil + + case ExtractorLogMsg: + m.logs = append(m.logs, logEntry{level: msg.Level, text: msg.Text}) + if len(m.logs) > m.maxLogs { + m.logs = m.logs[len(m.logs)-m.maxLogs:] + } + return m, nil + + case ExtractorErrorMsg: + m.hasError = true + m.phase = "error" + if msg.Err != nil { + m.errMsg = msg.Err.Error() + } else { + m.errMsg = "unknown error" + } + m.vcr.SetMode(VCRError) + m.mixer.SetMode(MixerError) + return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg { return ExtractorDoneMsg{} }) + + case ExtractorDoneMsg: + return m, tea.Quit + } + return m, nil +} + +// View renders the extractor-mode screen. +func (m extractorModel) View() string { + var b strings.Builder + w := m.width + if w == 0 { + w = 80 + } + + sep := styleSep.Render(strings.Repeat("┄", sepWidth(w))) + accentRule := lipgloss.NewStyle().Foreground(colorMagenta).Render(strings.Repeat("═", sepWidth(w))) + + // Banner — repurposed wordmark with a "VCR/MIX" strap. + b.WriteString(styleBanner.Render(banner)) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(colorSteel).Render( + " ░▒▓ extractor link · yt-dlp pipeline · vcr → mixer ▓▒░")) + b.WriteString("\n") + b.WriteString(accentRule) + b.WriteString("\n\n") + + // URL line. + b.WriteString(" " + styleLabel.Render("source") + + styleAccentValue.Render(truncate(m.url, w-12)) + "\n") + if m.outputPath != "" { + b.WriteString(" " + styleLabel.Render("output") + + styleValue.Render(truncate(m.outputPath, w-12)) + "\n") + } else if m.meta.OutputFile != "" { + b.WriteString(" " + styleLabel.Render("output") + + styleValue.Render(truncate(m.meta.OutputFile, w-12)) + "\n") + } + + b.WriteString("\n") + + // Active panel — VCR while downloading, Mixer while muxing. We + // always render the VCR (it carries the metadata), and overlay the + // Mixer below it once the post-processing phase begins. + b.WriteString(centreBlock(m.vcr.View(), w)) + b.WriteString("\n") + + if m.phase == "muxing" || m.phase == "done" || m.phase == "error" { + b.WriteString("\n") + b.WriteString(centreBlock(m.mixer.View(), w)) + b.WriteString("\n") + } + + // Log strip. + if len(m.logs) > 0 { + b.WriteString("\n") + b.WriteString(" " + styleSectionChip.Render("EVENTS") + "\n") + for _, e := range m.logs { + ico, sty := logIconStyle(e.level) + b.WriteString(" " + sty.Render(ico) + " " + styleValue.Render(truncate(e.text, w-6)) + "\n") + } + } + + // Footer. + b.WriteString("\n" + sep + "\n") + switch { + case m.stopping: + b.WriteString(" " + styleHelp.Render("press ") + + styleKeyCap.Render("q") + + styleHelp.Render(" again to force-quit")) + case m.done: + b.WriteString(" " + styleDone.Render("◆ extraction complete") + + styleHelp.Render(" — closing in 3s")) + case m.hasError: + b.WriteString(" " + styleError.Render("✗ ") + + styleErrBox.Render(truncate(m.errMsg, w-10))) + default: + b.WriteString(" " + styleHelp.Render("press ") + + styleKeyCap.Render("q") + + styleHelp.Render(" to abort and exit")) + } + + return b.String() +} + +// centreBlock pads a multi-line block to centre it within terminal width. +func centreBlock(block string, termW int) string { + lines := strings.Split(block, "\n") + maxW := 0 + for _, ln := range lines { + if w := lipgloss.Width(ln); w > maxW { + maxW = w + } + } + pad := (termW - maxW) / 2 + if pad < 0 { + pad = 0 + } + prefix := strings.Repeat(" ", pad) + for i, ln := range lines { + lines[i] = prefix + ln + } + return strings.Join(lines, "\n") +} + +// ── Run loop ───────────────────────────────────────────────────────────────── + +// ExtractorRunOptions configures RunExtractorTUI. +type ExtractorRunOptions struct { + Ctx context.Context + URL string + OnQuit func() +} + +// RunExtractorTUI starts a Bubble Tea program using the extractor model +// and runs `worker` in a goroutine. The worker is expected to send +// Extractor* messages via the package-level Program handle. Mirrors the +// shape of RunWithTUI: when stdout isn't a TTY, the worker runs directly +// and TUI sends are no-ops (the package-level Program is nil). +func RunExtractorTUI(opts ExtractorRunOptions, worker func() error) error { + if !(isatty.IsTerminal(os.Stdout.Fd()) && DisplayProgress) { + // Non-TTY fallback — run the worker directly, log progress + // through charmbracelet/log via ui.Printf(). Program stays nil + // so the extractor sink drops UI events. + return worker() + } + + model := NewExtractorModel(opts.URL, opts.OnQuit) + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithoutSignalHandler()) + Program = p + + // External-cancel watchdog. Mirrors RunWithTUI so SIGINT propagates + // uniformly across both pipelines. + stopWatch := make(chan struct{}) + if opts.Ctx != nil { + go func() { + select { + case <-opts.Ctx.Done(): + // Switch the VCR/Mixer into eject/error mode and request quit. + p.Send(ExtractorErrorMsg{Err: context.Cause(opts.Ctx)}) + case <-stopWatch: + } + }() + } + + workerDone := make(chan struct{}) + var workerErr error + go func() { + defer close(workerDone) + defer func() { + if r := recover(); r != nil { + if err, ok := r.(error); ok { + workerErr = err + } else { + workerErr = fmt.Errorf("%v", r) + } + } + if workerErr != nil { + p.Send(ExtractorErrorMsg{Err: workerErr}) + } else { + p.Send(ExtractorPhaseMsg{Phase: "done"}) + } + }() + workerErr = worker() + }() + + if _, err := p.Run(); err != nil { + Program = nil + close(stopWatch) + <-workerDone + if workerErr != nil { + return fmt.Errorf("extractor TUI: %w (extractor error: %v)", err, workerErr) + } + return fmt.Errorf("extractor TUI: %w", err) + } + Program = nil + close(stopWatch) + <-workerDone + return workerErr +} diff --git a/internal/ui/extractor_tui_test.go b/internal/ui/extractor_tui_test.go new file mode 100644 index 0000000..fb92046 --- /dev/null +++ b/internal/ui/extractor_tui_test.go @@ -0,0 +1,294 @@ +package ui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// step routes a sequence of Tea messages through the model and returns +// the resulting view. Mirrors what the live program does, minus the +// real timer goroutine. +func step(t *testing.T, m extractorModel, msgs ...tea.Msg) extractorModel { + t.Helper() + var mod tea.Model = m + for _, msg := range msgs { + mod, _ = mod.Update(msg) + } + return mod.(extractorModel) +} + +// tickN advances the animation N times so spring physics settle. +func tickN(t *testing.T, m extractorModel, n int) extractorModel { + t.Helper() + for i := 0; i < n; i++ { + m = step(t, m, extractorTickMsg(time.Now())) + } + return m +} + +func TestExtractorModel_RendersMetaInVCRPanel(t *testing.T) { + m := NewExtractorModel("https://vimeo.com/76979871", func() {}) + m.width = 100 + m.height = 60 + + m = step(t, m, + ExtractorMetaMsg{ + Title: "The New Vimeo Player", + Channel: "Vimeo Staff", + Duration: 2*time.Minute + 30*time.Second, + Resolution: "1280x720", + FPS: 30, + VCodec: "avc1.42001f", + ACodec: "mp4a.40.2", + Container: "mp4", + HasAudio: true, + OutputFile: "The New Vimeo Player.mp4", + }, + ExtractorPhaseMsg{Phase: "downloading"}, + ) + m = tickN(t, m, 5) + + view := m.View() + mustContain(t, view, "HGET·VCR/4-HEAD", "VCR brand plate missing") + mustContain(t, view, "TRANSPORT", "TRANSPORT label missing") + mustContain(t, view, "The New Vimeo Player", "title row missing") + mustContain(t, view, "Vimeo Staff", "channel row missing") + mustContain(t, view, "1280x720", "resolution missing from video row") + mustContain(t, view, "30fps", "fps missing from video row") + mustContain(t, view, "avc1.42001f", "video codec missing") + mustContain(t, view, "mp4a.40.2", "audio codec missing") + mustContain(t, view, "00:02:30", "total duration missing from counter") + mustContain(t, view, "● REC", "REC indicator missing from progress row") + mustContain(t, view, "AUDIO L", "VU meter L channel missing") + mustContain(t, view, "AUDIO", "AUDIO label missing") + mustContain(t, view, "vimeo.com/76979871", "source URL missing") +} + +func TestExtractorModel_ProgressMessagesAdvanceTheBar(t *testing.T) { + m := NewExtractorModel("https://vimeo.com/76979871", func() {}) + m.width = 100 + m.height = 60 + + // Seed metadata so the panel has stable context. + m = step(t, m, + ExtractorMetaMsg{Title: "X", Container: "mp4", HasAudio: true, Duration: time.Minute}, + ExtractorPhaseMsg{Phase: "downloading"}, + ) + + // 0% → 50% → 100% — settle the spring after each step. + m = step(t, m, ExtractorProgressMsg{ + Percent: 0.0, Downloaded: 0, Total: 1_000_000, SpeedBPS: 0, + }) + m = tickN(t, m, 60) + v0 := m.View() + + m = step(t, m, ExtractorProgressMsg{ + Percent: 50.0, Downloaded: 500_000, Total: 1_000_000, SpeedBPS: 1_500_000, + ETA: 5 * time.Second, + }) + m = tickN(t, m, 90) // give the harmonic spring time to settle near target + v50 := m.View() + + m = step(t, m, ExtractorProgressMsg{ + Percent: 100.0, Downloaded: 1_000_000, Total: 1_000_000, SpeedBPS: 1_500_000, + }) + m = tickN(t, m, 90) + v100 := m.View() + + // The percent string in the progress row must change monotonically. + pct0 := extractPct(t, v0) + pct50 := extractPct(t, v50) + pct100 := extractPct(t, v100) + t.Logf("rendered percentages: %.1f → %.1f → %.1f", pct0, pct50, pct100) + if !(pct0 < pct50 && pct50 < pct100) { + t.Errorf("expected monotonic %% (got %.1f, %.1f, %.1f)", pct0, pct50, pct100) + } + if pct100 < 99.0 { + t.Errorf("expected ~100%% after settling, got %.1f", pct100) + } + + // Rate row shows the speed at 50%. + mustContain(t, v50, "1.4 MB/s", "rate row missing speed at 50%") + mustContain(t, v50, "488.3 KB of 976.6 KB", "rate row missing byte-count at 50%") +} + +func TestExtractorModel_FragmentReadout(t *testing.T) { + m := NewExtractorModel("https://example.com/m3u8", func() {}) + m.width = 100 + m.height = 60 + m = step(t, m, + ExtractorMetaMsg{Title: "HLS Stream", HasAudio: true, Container: "mp4"}, + ExtractorPhaseMsg{Phase: "downloading"}, + ExtractorProgressMsg{Percent: 25, Downloaded: 100, Total: 400, Fragment: 137, FragmentN: 320, SpeedBPS: 50_000}, + ) + m = tickN(t, m, 5) + view := m.View() + mustContain(t, view, "FRAG", "fragment label missing") + mustContain(t, view, "0137", "current fragment missing") + mustContain(t, view, "0320", "total fragments missing") +} + +func TestExtractorModel_MuxingPhaseShowsMixerPanel(t *testing.T) { + m := NewExtractorModel("https://vimeo.com/76979871", func() {}) + m.width = 100 + m.height = 80 + + m = step(t, m, + ExtractorMetaMsg{ + Title: "Sample", VCodec: "vp9", ACodec: "opus", Container: "mp4", + HasAudio: true, Duration: time.Minute, + }, + ExtractorPhaseMsg{Phase: "downloading"}, + ExtractorProgressMsg{Percent: 100, Downloaded: 1000, Total: 1000, SpeedBPS: 0}, + ExtractorPhaseMsg{Phase: "muxing"}, + ) + m = tickN(t, m, 20) + + view := m.View() + mustContain(t, view, "HGET·MIX·M-808", "mixer brand plate missing") + mustContain(t, view, "CONSOLE", "mixer console label missing") + mustContain(t, view, "CH 1 VIDEO", "video channel strip missing") + mustContain(t, view, "CH 2 AUDIO", "audio channel strip missing") + mustContain(t, view, "MASTER BUS OUT", "master strip missing") + mustContain(t, view, "muxing", "muxing status text missing") + mustContain(t, view, "60", "EQ frequency axis missing") + mustContain(t, view, "16k", "EQ high-frequency band missing") +} + +func TestExtractorModel_OutputPathSurfaced(t *testing.T) { + m := NewExtractorModel("https://vimeo.com/x", func() {}) + m.width = 100 + m.height = 60 + m = step(t, m, + ExtractorMetaMsg{Title: "X", Container: "mp4", HasAudio: false}, + ExtractorPhaseMsg{Phase: "downloading"}, + ExtractorOutputMsg{Path: "/tmp/Resolved Output Path.mp4"}, + ) + m = tickN(t, m, 5) + view := m.View() + mustContain(t, view, "Resolved Output Path.mp4", "resolved output path missing") +} + +func TestExtractorModel_ErrorRenders(t *testing.T) { + m := NewExtractorModel("https://vimeo.com/x", func() {}) + m.width = 100 + m.height = 60 + m = step(t, m, + ExtractorPhaseMsg{Phase: "downloading"}, + ExtractorErrorMsg{Err: errString("yt-dlp probe failed: oh no")}, + ) + m = tickN(t, m, 5) + view := m.View() + mustContain(t, view, "yt-dlp probe failed", "error message missing") +} + +// ── helpers ───────────────────────────────────────────────────────────── + +type errString string + +func (e errString) Error() string { return string(e) } + +func mustContain(t *testing.T, view, needle, msg string) { + t.Helper() + plain := stripANSI(view) + if !strings.Contains(plain, needle) { + t.Errorf("%s: expected to find %q in view\n--- view ---\n%s", msg, needle, plain) + } +} + +// extractPct finds the "NN.N%" text inside the rendered VCR progress row. +// Looks for the line containing "● REC" (the progress strip) and parses +// the percent token at the end of it. +func extractPct(t *testing.T, view string) float64 { + t.Helper() + plain := stripANSI(view) + for _, line := range strings.Split(plain, "\n") { + if !strings.Contains(line, "● REC") { + continue + } + // Find the rightmost token ending in '%' + fields := strings.Fields(line) + for i := len(fields) - 1; i >= 0; i-- { + tok := strings.TrimRight(fields[i], "%") + if tok == fields[i] { + continue + } + var f float64 + if _, err := stringsScanf(tok, &f); err == nil { + return f + } + } + } + t.Fatalf("no progress row found in view:\n%s", plain) + return 0 +} + +// stringsScanf is a tiny float parser that returns (n, err) like fmt.Sscanf +// but tolerates leading whitespace and trailing junk. Avoids the import +// dance for the only call site above. +func stringsScanf(s string, f *float64) (int, error) { + s = strings.TrimSpace(s) + var v float64 + var sign float64 = 1 + if strings.HasPrefix(s, "-") { + sign = -1 + s = s[1:] + } + dot := false + frac := 0.0 + div := 1.0 + for _, r := range s { + switch { + case r >= '0' && r <= '9': + if dot { + div *= 10 + frac += float64(r-'0') / div + } else { + v = v*10 + float64(r-'0') + } + case r == '.': + dot = true + default: + goto done + } + } +done: + *f = sign * (v + frac) + return 1, nil +} + +// stripANSI removes ANSI escape sequences from s so test assertions are +// stable regardless of palette / terminal capabilities. +func stripANSI(s string) string { + var b strings.Builder + b.Grow(len(s)) + i := 0 + for i < len(s) { + if s[i] == 0x1b { + // Skip until the terminating letter of the CSI sequence. + i++ + if i < len(s) && s[i] == '[' { + i++ + for i < len(s) { + c := s[i] + i++ + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') { + break + } + } + continue + } + // Other escape — skip one char. + if i < len(s) { + i++ + } + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} diff --git a/internal/ui/mixer.go b/internal/ui/mixer.go new file mode 100644 index 0000000..f375a5b --- /dev/null +++ b/internal/ui/mixer.go @@ -0,0 +1,422 @@ +package ui + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// mixerWidth — fixed inner width for the mixer panel. Matches vcrWidth +// so the two panels stack into a coherent rack. +const mixerWidth = 70 + +// MixerMode is the mixer panel's operational state. +type MixerMode int + +const ( + MixerIdle MixerMode = iota + MixerMixing + MixerDone + MixerError +) + +// MixerMeta — labels shown on the channel strips and master section. +type MixerMeta struct { + VideoCodec string // tag for video channel strip + AudioCodec string // tag for audio channel strip + Container string // master section "BUS OUT" caption (mp4 / mkv / webm) + OutputFile string // shown on the master strip + Bitrate string // optional +} + +// MixerAnimation — analog-mixer themed visualization for the +// post-processing (ffmpeg mux) phase. Even though we don't get true +// progress from yt-dlp's internal ffmpeg call, we drive a believable +// rolling animation: VU meters on V/A channel strips, a sweeping EQ +// graph, motorized fader catch-up, and a master meter that pegs as +// the mux finishes. +type MixerAnimation struct { + mode MixerMode + frame int + meta MixerMeta + + // Synthetic progress — climbs steadily to 0.95 while mixing, then + // snaps to 1.0 on MixerDone. Mux is usually fast enough that real + // progress wouldn't be more honest than this. + pct float64 + finished bool + startT time.Time +} + +// NewMixer builds a fresh idle mixer animation. +func NewMixer() MixerAnimation { + return MixerAnimation{mode: MixerIdle, startT: time.Now()} +} + +func (m *MixerAnimation) SetMode(mode MixerMode) { + if mode == MixerMixing && m.mode != MixerMixing { + m.startT = time.Now() + } + if mode == MixerDone { + m.finished = true + m.pct = 1.0 + } + m.mode = mode +} + +func (m *MixerAnimation) SetMeta(meta MixerMeta) { m.meta = meta } +func (m *MixerAnimation) Mode() MixerMode { return m.mode } +func (m *MixerAnimation) Frame() int { return m.frame } + +// Tick advances the animation. Call once per render frame. +func (m *MixerAnimation) Tick() { + m.frame++ + if m.finished { + return + } + if m.mode == MixerMixing { + // Asymptotic approach to 0.95 — ramps up fast, then crawls. + // Real ffmpeg mux without re-encode finishes in seconds for + // small videos, minutes for large ones. This curve "feels" + // right at both ends. + dt := time.Since(m.startT).Seconds() + m.pct = 0.95 * (1 - math.Exp(-dt/4.0)) + } +} + +// View renders the mixer panel. +func (m MixerAnimation) View() string { + chrome := fgStyle(Theme.Phosphor) + frame := fgStyle(Theme.Slate) + steel := fgStyle(Theme.Steel) + frost := fgBoldStyle(Theme.Frost) + amber := fgBoldStyle(Theme.Amber) + mint := fgBoldStyle(Theme.Mint) + + inner := mixerWidth - 2 + pad := func(s string, w int) string { + gap := w - lipgloss.Width(s) + if gap < 0 { + gap = 0 + } + return s + strings.Repeat(" ", gap) + } + centre := func(s string, w int) string { + gap := w - lipgloss.Width(s) + if gap < 0 { + gap = 0 + } + l := gap / 2 + r := gap - l + return strings.Repeat(" ", l) + s + strings.Repeat(" ", r) + } + + var b strings.Builder + + // ── Top bezel. ────────────────────────────────────────────────────── + b.WriteString(chrome.Render("╔") + + chrome.Render(strings.Repeat("═", inner)) + + chrome.Render("╗") + "\n") + + // ── Brand plate. ──────────────────────────────────────────────────── + plate := amber.Render("▓▓ HGET·MIX·M-808 ▓▓") + + steel.Render(" 8-BUS ANALOG ") + + frost.Render("+4 dBu") + b.WriteString(chrome.Render("║") + centre(plate, inner) + chrome.Render("║") + "\n") + + // ── Divider. ──────────────────────────────────────────────────────── + b.WriteString(chrome.Render("╠") + + frame.Render(strings.Repeat("═", inner)) + + chrome.Render("╣") + "\n") + + // ── Status caption with mode-driven LEDs. ─────────────────────────── + pwr := true + mix := m.mode == MixerMixing && (m.frame/8)%2 == 0 + clip := m.mode == MixerMixing && (m.frame/30)%5 == 0 // occasional clip blink + bus := m.mode == MixerMixing + sync := m.mode == MixerDone + + chip := func(name string, on bool, col lipgloss.Color) string { + c := Theme.Slate + if on { + c = col + } + return fgBoldStyle(c).Render("◉") + " " + steel.Render(name) + } + chips := strings.Join([]string{ + chip("PWR", pwr, Theme.Mint), + chip("MIX", mix, Theme.Amber), + chip("BUS", bus, Theme.Phosphor), + chip("CLIP", clip, Theme.Magenta), + chip("LOCK", sync, Theme.Mint), + }, " ") + b.WriteString(chrome.Render("║ ") + pad("[ "+steel.Render("CONSOLE")+" ] "+chips, inner-2) + chrome.Render(" ║") + "\n") + + // ── EQ window framing (top). ──────────────────────────────────────── + b.WriteString(chrome.Render("║ ") + + frame.Render("┌"+strings.Repeat("─", inner-4)+"┐") + + chrome.Render(" ║") + "\n") + + // ── Spectrum analyser (8-band rolling EQ). ────────────────────────── + for row := 0; row < 5; row++ { + line := m.renderSpectrumRow(row, inner-6) + b.WriteString(chrome.Render("║ ") + + frame.Render("│") + " " + pad(line, inner-6) + " " + + frame.Render("│") + + chrome.Render(" ║") + "\n") + } + // Frequency axis labels under the spectrum. + axis := steel.Render("60 125 250 500 1k 2k 4k 8k 16k") + b.WriteString(chrome.Render("║ ") + + frame.Render("│") + " " + pad(axis, inner-6) + " " + + frame.Render("│") + + chrome.Render(" ║") + "\n") + + // ── EQ window framing (bottom). ───────────────────────────────────── + b.WriteString(chrome.Render("║ ") + + frame.Render("└"+strings.Repeat("─", inner-4)+"┘") + + chrome.Render(" ║") + "\n") + + // ── Channel strip header. ─────────────────────────────────────────── + hdr := steel.Render(" CH 1 ") + amber.Render("VIDEO") + + strings.Repeat(" ", 8) + + steel.Render("CH 2 ") + amber.Render("AUDIO") + + strings.Repeat(" ", 8) + + steel.Render("MASTER ") + mint.Render("BUS OUT") + b.WriteString(chrome.Render("║ ") + pad(hdr, inner-2) + chrome.Render(" ║") + "\n") + + // ── Three vertical fader strips drawn as one row of segmented bars. + for row := 0; row < 6; row++ { + line := m.renderFaderRow(row) + b.WriteString(chrome.Render("║ ") + pad(line, inner-2) + chrome.Render(" ║") + "\n") + } + + // ── Channel labels under the faders. ──────────────────────────────── + labels := steel.Render(" ") + steel.Render(rightPad(short(m.meta.VideoCodec, 12), 14)) + + steel.Render(rightPad(short(m.meta.AudioCodec, 12), 14)) + + steel.Render(rightPad(short(strings.ToUpper(m.meta.Container), 12), 14)) + b.WriteString(chrome.Render("║ ") + pad(labels, inner-2) + chrome.Render(" ║") + "\n") + + // ── Patch bay row — animated cable connections. ───────────────────── + bay := m.renderPatchBay(inner - 2) + b.WriteString(chrome.Render("║ ") + pad(bay, inner-2) + chrome.Render(" ║") + "\n") + + // ── Output detail rows. ───────────────────────────────────────────── + rowFor := func(label, val string, valCol lipgloss.Color) { + if val == "" { + val = "—" + } + l := steel.Render(rightPad(label, 12)) + valSty := fgStyle(valCol) + if valCol == Theme.Frost { + valSty = frost + } + ln := " " + l + valSty.Render(truncate(val, inner-18)) + b.WriteString(chrome.Render("║ ") + pad(ln, inner-2) + chrome.Render(" ║") + "\n") + } + rowFor("output", m.meta.OutputFile, Theme.Frost) + rowFor("container", strings.ToUpper(m.meta.Container), Theme.Phosphor) + rowFor("status", m.statusLine(), m.statusColor()) + + // ── Bottom bezel. ─────────────────────────────────────────────────── + b.WriteString(chrome.Render("╚") + + chrome.Render(strings.Repeat("═", inner)) + + chrome.Render("╝")) + + return b.String() +} + +// renderSpectrumRow draws one row of the 8-band spectrum analyser. Bars +// fill from the bottom up (row 4 is bottom). Heights are deterministic +// pseudo-random per band per frame so the analyser looks lively but +// reproducible (handy for snapshot tests). +func (m MixerAnimation) renderSpectrumRow(row, width int) string { + const bands = 9 // 60 .. 16k + bandWidth := width / bands + if bandWidth < 4 { + bandWidth = 4 + } + var sb strings.Builder + for i := 0; i < bands; i++ { + h := m.bandHeight(i) + // row 0 = top, row 4 = bottom. Bar is "lit" from bottom up. + filled := h >= (5 - row) + var glyph string + var col lipgloss.Color + switch { + case row == 0 && filled: + glyph, col = "▀", Theme.Magenta + case row <= 1 && filled: + glyph, col = "█", Theme.Amber + case filled: + glyph, col = "█", Theme.Mint + default: + glyph, col = " ", Theme.Slate + } + bar := fgBoldStyle(col).Render(strings.Repeat(glyph, 3)) + gap := strings.Repeat(" ", bandWidth-3) + sb.WriteString(bar + gap) + } + return sb.String() +} + +// bandHeight returns 0..5 for the i-th spectrum band at the current +// frame. Uses a sine + per-band offset for a pleasing interleaved roll +// when mixing; flat-zero otherwise. +func (m MixerAnimation) bandHeight(band int) int { + if m.mode != MixerMixing { + return 0 + } + t := float64(m.frame) / 8.0 + phase := float64(band) * 0.7 + v := 0.5 + 0.5*math.Sin(t+phase) + 0.2*math.Sin(t*1.7+phase*2) + // add a touch of low-band weighting (musical heuristic — bass heavier) + weight := 1.0 - float64(band)/12.0 + v *= weight + if v < 0 { + v = 0 + } + if v > 1 { + v = 1 + } + return int(v * 5) +} + +// renderFaderRow draws one row across the three channel strips. Six +// rows total give us a tall enough fader to show segmented levels. +func (m MixerAnimation) renderFaderRow(row int) string { + steel := fgStyle(Theme.Steel) + const rows = 6 + // Per-channel level (0..rows). Channels 1 + 2 oscillate, master + // climbs with pct. + levelV := m.channelLevel(0) + levelA := m.channelLevel(1) + levelM := int(m.pct*float64(rows)*0.95 + 0.5) + if m.mode == MixerDone { + levelM = rows + } + + cell := func(level int) string { + // row 0 = top of fader, row 5 = bottom. fader cap (motor knob) + // rides at row = rows - level - 1. + capRow := rows - level - 1 + if capRow < 0 { + capRow = 0 + } + switch { + case row == capRow: + return fgBoldStyle(Theme.Frost).Render("▭") + case row > capRow: + // Filled below cap. Color shifts amber → magenta near top. + col := Theme.Mint + if row < rows/2 { + col = Theme.Amber + } + if row == 0 { + col = Theme.Magenta + } + return fgBoldStyle(col).Render("▮") + default: + return steel.Render("│") + } + } + + // Each strip: " ╎ ╎ " spaced like a slot. + strip := func(level int) string { + return steel.Render(" ╎ ") + cell(level) + steel.Render(" ╎ ") + } + gap := strings.Repeat(" ", 6) + return " " + strip(levelV) + gap + strip(levelA) + gap + strip(levelM) +} + +// channelLevel returns 0..5 for input channel `ch` at the current frame. +// Audio channel runs hotter than video for personality. +func (m MixerAnimation) channelLevel(ch int) int { + if m.mode == MixerDone { + return 5 + } + if m.mode != MixerMixing { + return 0 + } + t := float64(m.frame) / 6.0 + base := 0.55 + 0.3*math.Sin(t+float64(ch)*1.7) + if ch == 1 { + base += 0.1 // audio hotter + } + if base < 0 { + base = 0 + } + if base > 1 { + base = 1 + } + return int(base * 5) +} + +// renderPatchBay draws the iconic 1/4" patch bay along the bottom — a +// row of jacks with cables looping between V, A, and master. +func (m MixerAnimation) renderPatchBay(width int) string { + steel := fgStyle(Theme.Steel) + mint := fgBoldStyle(Theme.Mint) + amber := fgBoldStyle(Theme.Amber) + + // 12 jacks across the bay. + jacks := []string{} + for i := 0; i < 12; i++ { + switch { + case i == 1 || i == 2: + jacks = append(jacks, amber.Render("⊙")) + case i == 5 || i == 6: + jacks = append(jacks, amber.Render("⊙")) + case i == 9 || i == 10: + jacks = append(jacks, mint.Render("⊙")) + default: + jacks = append(jacks, steel.Render("◌")) + } + } + cable := mint.Render("─") + if m.mode == MixerMixing && (m.frame/4)%2 == 0 { + cable = amber.Render("─") + } + loop := steel.Render(" PATCH ") + strings.Join(jacks, cable) + return loop +} + +func (m MixerAnimation) statusLine() string { + switch m.mode { + case MixerIdle: + return "standby — awaiting bus assignment" + case MixerMixing: + dots := strings.Repeat(".", (m.frame/8)%4) + return fmt.Sprintf("muxing video + audio streams%s", dots) + case MixerDone: + return "BUS LOCKED · master at unity · output flushed" + case MixerError: + return "fault — see log" + } + return "" +} + +func (m MixerAnimation) statusColor() lipgloss.Color { + switch m.mode { + case MixerDone: + return Theme.Mint + case MixerError: + return Theme.Magenta + default: + return Theme.Amber + } +} + +func short(s string, n int) string { + s = strings.TrimSpace(s) + if s == "" { + return "—" + } + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 229ce89..434d9e6 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -2355,6 +2355,9 @@ const helpMarkdown = "" + "| `--probe ` | probe URL for range support & content-length only | |\n" + "| `--timeout ` | timeout waiting for response headers (e.g. `30s`) | `15s` |\n" + "| `--verify` | download & GPG-verify the `.sig` signature file | `false` |\n" + + "| `--extractor ` | extractor mode: `auto` / `yt-dlp` / `none` | `auto` |\n" + + "| `--cookies ` | cookies.txt for the extractor (yt-dlp `--cookies`) | |\n" + + "| `--cookies-from-browser ` | extract cookies from browser (e.g. `firefox`, `chrome:Default`) | |\n" + "\n" + "## Examples\n" + "\n" + @@ -2376,6 +2379,16 @@ const helpMarkdown = "" + "\n" + "# download & verify GPG signature\n" + "hget --verify https://example.com/file.iso\n" + + "\n" + + "# YouTube / Vimeo / Twitch via yt-dlp (auto-detected) — VCR + Mixer TUI\n" + + "hget https://www.youtube.com/watch?v=dQw4w9WgXcQ\n" + + "hget --extractor yt-dlp https://example.com/some-stream\n" + + "\n" + + "# YouTube with a cookies.txt file (bypasses the bot challenge)\n" + + "hget --cookies ~/cookies.txt https://www.youtube.com/watch?v=dQw4w9WgXcQ\n" + + "\n" + + "# YouTube using your live browser cookies (no export needed)\n" + + "hget --cookies-from-browser firefox https://www.youtube.com/watch?v=dQw4w9WgXcQ\n" + "```\n" func PrintHelp() { diff --git a/internal/ui/vcr.go b/internal/ui/vcr.go new file mode 100644 index 0000000..131cf57 --- /dev/null +++ b/internal/ui/vcr.go @@ -0,0 +1,467 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/harmonica" + "github.com/charmbracelet/lipgloss" +) + +// vcrWidth — the VCR panel renders at a fixed inner width so the +// front-plate, tape window, and counter stay aligned regardless of the +// actual terminal width. Wrapper code centres the panel. +const vcrWidth = 70 + +// VCRMode is the operational state of the VCR panel. Mirrors the +// extractor.Phase but stays in the ui package so the renderer never +// imports extractor. +type VCRMode int + +const ( + VCRStandby VCRMode = iota // pre-recording, "tape inserted" state + VCRRecording // [download] phase active + VCREjecting // post-record cooldown (between phases) + VCRError +) + +// VCRMeta — the metadata strip shown above the tape window. All fields +// are optional; absent values render as "—". +type VCRMeta struct { + Title string + Channel string + Duration time.Duration + Resolution string + FPS float64 + VCodec string + ACodec string + Container string + HasAudio bool // controls whether the AUDIO meter is shown + StreamLabel string // "VIDEO" / "AUDIO" / "" — drives front-LED +} + +// VCRAnimation drives the on-screen VCR. Tick() advances the animation +// frame; Update() pushes new download progress. The renderer is pure. +type VCRAnimation struct { + mode VCRMode + frame int + width int + + // Download stats (live) + pct float64 + downloaded int64 + total int64 + speed float64 + eta time.Duration + fragment int + fragmentN int + + // Spring-smoothed percentage and reel rotation. We drive both the + // progress bar and the spinning reel glyphs from spring output so + // the visual continuity matches the data. + bar progress.Model + pctSpr harmonica.Spring + pctSm float64 + pctVel float64 + pctTgt float64 + + // Visible meta (header strip + transport caption). + meta VCRMeta + + // Tape window — a horizontal strip of glyphs that scrolls right as + // the download progresses. We keep an internal "head position" + // that advances with elapsed time so the moving texture is decoupled + // from progress (you still see motion when buffering / paused). + tapeOffset int + tapeStartT time.Time +} + +// NewVCR builds a fresh, idle VCR animation. +func NewVCR() VCRAnimation { + return VCRAnimation{ + mode: VCRStandby, + bar: progress.New( + progress.WithSolidFill(string(Theme.Magenta)), + progress.WithoutPercentage(), + progress.WithWidth(vcrWidth-26), + ), + pctSpr: harmonica.NewSpring(harmonica.FPS(60), 6.0, 0.85), + tapeStartT: time.Now(), + width: vcrWidth, + } +} + +func (v *VCRAnimation) SetMode(m VCRMode) { v.mode = m } +func (v *VCRAnimation) SetMeta(m VCRMeta) { v.meta = m } +func (v *VCRAnimation) Mode() VCRMode { return v.mode } +func (v *VCRAnimation) Frame() int { return v.frame } + +// Update pushes new download stats. Called whenever the extractor emits +// a DownloadProgress event. +func (v *VCRAnimation) Update(pct float64, downloaded, total int64, speedBPS float64, eta time.Duration, fragment, fragmentN int) { + v.pct = pct / 100.0 + if v.pct < 0 { + v.pct = 0 + } + if v.pct > 1 { + v.pct = 1 + } + v.downloaded = downloaded + v.total = total + v.speed = speedBPS + v.eta = eta + v.fragment = fragment + v.fragmentN = fragmentN + v.pctTgt = v.pct +} + +// Tick advances the animation. Call once per render frame (~60 Hz). +func (v *VCRAnimation) Tick() { + v.frame++ + v.pctSm, v.pctVel = v.pctSpr.Update(v.pctSm, v.pctVel, v.pctTgt) + if v.pctSm < 0 { + v.pctSm = 0 + } + if v.pctSm > 1 { + v.pctSm = 1 + } + // Tape head scrolls at a fixed rate while recording, slower during + // standby / eject. Mod the offset to keep the int small. + step := 1 + if v.mode != VCRRecording { + step = 0 + if v.frame%3 == 0 { + step = 1 + } + } + v.tapeOffset = (v.tapeOffset + step) % 1024 +} + +// View renders the entire VCR panel as a single string. +func (v VCRAnimation) View() string { + chrome := fgStyle(Theme.Phosphor) + frame := fgStyle(Theme.Slate) + steel := fgStyle(Theme.Steel) + frost := fgBoldStyle(Theme.Frost) + mag := fgBoldStyle(Theme.Magenta) // "REC" red — VCR signature colour + + inner := v.width - 2 + pad := func(s string, w int) string { + gap := w - lipgloss.Width(s) + if gap < 0 { + gap = 0 + } + return s + strings.Repeat(" ", gap) + } + centre := func(s string, w int) string { + gap := w - lipgloss.Width(s) + if gap < 0 { + gap = 0 + } + l := gap / 2 + r := gap - l + return strings.Repeat(" ", l) + s + strings.Repeat(" ", r) + } + + var b strings.Builder + + // ── Top bezel. ────────────────────────────────────────────────────── + b.WriteString(chrome.Render("╔") + + chrome.Render(strings.Repeat("═", inner)) + + chrome.Render("╗") + "\n") + + // ── Brand plate. ──────────────────────────────────────────────────── + plate := mag.Render("▓▓ HGET·VCR/4-HEAD ▓▓") + + steel.Render(" HI-FI STEREO ") + + frost.Render("NTSC/PAL") + b.WriteString(chrome.Render("║") + centre(plate, inner) + chrome.Render("║") + "\n") + + // ── Divider. ──────────────────────────────────────────────────────── + b.WriteString(chrome.Render("╠") + + frame.Render(strings.Repeat("═", inner)) + + chrome.Render("╣") + "\n") + + // ── Transport LEDs. ───────────────────────────────────────────────── + pwr := true + rec := v.mode == VCRRecording && (v.frame/8)%2 == 0 // pulsing red REC + tape := v.mode == VCRRecording + stereo := v.meta.HasAudio + hifi := v.meta.HasAudio && v.mode == VCRRecording && (v.frame/12)%2 == 0 + chip := func(name string, on bool, col lipgloss.Color) string { + c := Theme.Slate + if on { + c = col + } + return fgBoldStyle(c).Render("◉") + " " + steel.Render(name) + } + chips := strings.Join([]string{ + chip("PWR", pwr, Theme.Mint), + chip("REC", rec, Theme.Magenta), + chip("TAPE", tape, Theme.Phosphor), + chip("STEREO", stereo, Theme.Amber), + chip("HI-FI", hifi, Theme.Phosphor), + }, " ") + b.WriteString(chrome.Render("║ ") + pad("[ "+steel.Render("TRANSPORT")+" ] "+chips, inner-2) + chrome.Render(" ║") + "\n") + + // ── Tape window framing (top). ────────────────────────────────────── + b.WriteString(chrome.Render("║ ") + + frame.Render("┌"+strings.Repeat("─", inner-4)+"┐") + + chrome.Render(" ║") + "\n") + + // ── Reels + tape strip. ───────────────────────────────────────────── + leftReel := v.reelGlyph(true) + rightReel := v.reelGlyph(false) + stripWidth := inner - 12 // space for two reels (3 wide each) + padding + if stripWidth < 8 { + stripWidth = 8 + } + tapeStrip := v.renderTapeStrip(stripWidth) + reelStyled := fgBoldStyle(Theme.Frost) + row := reelStyled.Render(leftReel) + " " + tapeStrip + " " + reelStyled.Render(rightReel) + b.WriteString(chrome.Render("║ ") + + frame.Render("│") + " " + + pad(row, inner-6) + " " + + frame.Render("│") + + chrome.Render(" ║") + "\n") + + // ── Counter strip (HH:MM:SS / total + fragment counter). ──────────── + counter := v.renderCounter() + b.WriteString(chrome.Render("║ ") + + frame.Render("│") + " " + + pad(counter, inner-6) + " " + + frame.Render("│") + + chrome.Render(" ║") + "\n") + + // ── Tape window framing (bottom). ─────────────────────────────────── + b.WriteString(chrome.Render("║ ") + + frame.Render("└"+strings.Repeat("─", inner-4)+"┘") + + chrome.Render(" ║") + "\n") + + // ── Progress bar + percent. ───────────────────────────────────────── + bar := v.bar.ViewAs(v.pctSm) + pctTxt := fmt.Sprintf("%5.1f%%", v.pctSm*100) + progRow := mag.Render("● REC") + " " + bar + " " + frost.Render(pctTxt) + b.WriteString(chrome.Render("║ ") + pad(progRow, inner-2) + chrome.Render(" ║") + "\n") + + // ── Audio VU meters (peak-style bars driven by speed). ────────────── + vuL, vuR := v.renderVU() + vuRow := steel.Render("AUDIO L ") + vuL + " " + steel.Render("R ") + vuR + b.WriteString(chrome.Render("║ ") + pad(vuRow, inner-2) + chrome.Render(" ║") + "\n") + + // ── Detail rows (always present, stable height). ──────────────────── + rowFor := func(label, val string, valCol lipgloss.Color) { + if val == "" { + val = "—" + } + l := steel.Render(rightPad(label, 12)) + valSty := fgStyle(valCol) + if valCol == Theme.Frost { + valSty = frost + } + ln := " " + l + valSty.Render(truncate(val, inner-18)) + b.WriteString(chrome.Render("║ ") + pad(ln, inner-2) + chrome.Render(" ║") + "\n") + } + rowFor("title", v.meta.Title, Theme.Frost) + rowFor("channel", v.meta.Channel, Theme.Amber) + rowFor("video", v.videoLine(), Theme.Phosphor) + rowFor("audio", v.audioLine(), Theme.Phosphor) + rowFor("rate", v.rateLine(), Theme.Mint) + + // ── Bottom rivet plate. ───────────────────────────────────────────── + rivetCount := (inner - 2) / 2 + rivets := strings.Repeat(steel.Render("▪")+" ", rivetCount) + b.WriteString(chrome.Render("║ ") + pad(rivets, inner-2) + chrome.Render(" ║") + "\n") + + // ── Bottom bezel with cassette slot. ──────────────────────────────── + half := (inner - 5) / 2 + b.WriteString(chrome.Render("╚") + + chrome.Render(strings.Repeat("═", half)) + + chrome.Render("┤▒▒▒├") + + chrome.Render(strings.Repeat("═", inner-half-5)) + + chrome.Render("╝")) + + return b.String() +} + +// reelGlyph picks one of four rotating-reel frames. When recording, the +// take-up reel (right) spins faster than the supply reel (left) — a tiny +// detail that sells the illusion. +func (v VCRAnimation) reelGlyph(left bool) string { + frames := []string{"◜◠◝", "◝◠◞", "◞◡◟", "◟◡◜"} + if v.mode != VCRRecording { + return "◯◯◯" + } + idx := (v.frame / 4) % 4 + if !left { + idx = (v.frame / 3) % 4 // take-up spins ~1.3× faster + } + return frames[idx] +} + +// renderTapeStrip draws the magnetic tape itself — a row of glyphs that +// scrolls horizontally. Filled portion (left of the head) uses dense +// data glyphs; the tail uses spare ones to suggest unrecorded tape. +func (v VCRAnimation) renderTapeStrip(width int) string { + headPos := int(v.pctSm * float64(width)) + if headPos > width { + headPos = width + } + dense := []rune("▓▒░▒") + sparse := []rune("· ·") + out := make([]rune, 0, width) + for i := 0; i < width; i++ { + var r rune + if i < headPos { + r = dense[(i+v.tapeOffset)%len(dense)] + } else { + r = sparse[(i+v.tapeOffset)%len(sparse)] + } + out = append(out, r) + } + recorded := fgStyle(Theme.Magenta).Render(string(out[:headPos])) + upcoming := fgStyle(Theme.Slate).Render(string(out[headPos:])) + return recorded + upcoming +} + +// renderCounter renders the classic four-digit VCR counter — but driven +// by elapsed download time, plus the fragment readout when applicable. +func (v VCRAnimation) renderCounter() string { + steel := fgStyle(Theme.Steel) + amber := fgBoldStyle(Theme.Amber) + + elapsed := time.Duration(0) + if v.total > 0 && v.speed > 0 { + // derive elapsed from downloaded / speed — stable readout when + // process isn't tracking real wall time + elapsed = time.Duration(float64(v.downloaded) / v.speed * float64(time.Second)) + } + cnt := amber.Render(fmt.Sprintf("%s", formatHMS(elapsed))) + totalTxt := steel.Render(" / ") + amber.Render(formatHMS(v.meta.Duration)) + if v.meta.Duration == 0 { + totalTxt = steel.Render(" / ") + steel.Render("--:--:--") + } + + frag := "" + if v.fragmentN > 0 { + frag = " " + steel.Render("FRAG ") + + amber.Render(fmt.Sprintf("%04d", v.fragment)) + + steel.Render("/") + + amber.Render(fmt.Sprintf("%04d", v.fragmentN)) + } + eta := "" + if v.eta > 0 { + eta = " " + steel.Render("ETA ") + amber.Render(formatHMS(v.eta)) + } + return steel.Render("CTR ") + cnt + totalTxt + frag + eta +} + +// renderVU draws two stereo VU meter bars whose level fluctuates around +// the current download speed. We add a tiny per-frame jitter so the +// meter "breathes" like an analog needle rather than locking flat. +func (v VCRAnimation) renderVU() (string, string) { + const segs = 14 + // Map speed to 0..1; cap at 5 MiB/s as the "0 dB" line. + level := v.speed / (5 * 1024 * 1024) + if level > 1 { + level = 1 + } + if v.mode != VCRRecording { + level = 0 + } + jitterL := float64((v.frame*7)%17) / 64.0 + jitterR := float64((v.frame*11)%19) / 64.0 + lvlL := clamp01(level + jitterL - 0.07) + lvlR := clamp01(level + jitterR - 0.07) + return vuBar(lvlL, segs), vuBar(lvlR, segs) +} + +func vuBar(level float64, segs int) string { + on := int(level * float64(segs)) + var sb strings.Builder + for i := 0; i < segs; i++ { + if i < on { + switch { + case i >= segs*4/5: + sb.WriteString(fgBoldStyle(Theme.Magenta).Render("▮")) // peak / clip + case i >= segs*3/5: + sb.WriteString(fgBoldStyle(Theme.Amber).Render("▮")) + default: + sb.WriteString(fgBoldStyle(Theme.Mint).Render("▮")) + } + } else { + sb.WriteString(fgStyle(Theme.Slate).Render("▯")) + } + } + return sb.String() +} + +func (v VCRAnimation) videoLine() string { + parts := []string{} + if v.meta.Resolution != "" { + parts = append(parts, v.meta.Resolution) + } + if v.meta.FPS > 0 { + parts = append(parts, fmt.Sprintf("%.0ffps", v.meta.FPS)) + } + if v.meta.VCodec != "" && v.meta.VCodec != "none" { + parts = append(parts, v.meta.VCodec) + } + return strings.Join(parts, " · ") +} + +func (v VCRAnimation) audioLine() string { + if !v.meta.HasAudio { + return "muted / no separate audio stream" + } + parts := []string{} + if v.meta.ACodec != "" && v.meta.ACodec != "none" { + parts = append(parts, v.meta.ACodec) + } + parts = append(parts, "stereo · 48 kHz") + return strings.Join(parts, " · ") +} + +func (v VCRAnimation) rateLine() string { + if v.speed <= 0 { + return "—" + } + parts := []string{ + fmt.Sprintf("%s/s", formatBytes(int64(v.speed))), + fmt.Sprintf("%s of %s", formatBytes(v.downloaded), formatBytes(v.total)), + } + return strings.Join(parts, " · ") +} + +// ── helpers ──────────────────────────────────────────────────────────── + +func clamp01(f float64) float64 { + if f < 0 { + return 0 + } + if f > 1 { + return 1 + } + return f +} + +func rightPad(s string, w int) string { + if lipgloss.Width(s) >= w { + return s + } + return s + strings.Repeat(" ", w-lipgloss.Width(s)) +} + +func formatHMS(d time.Duration) string { + if d <= 0 { + return "00:00:00" + } + d = d.Round(time.Second) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + d -= m * time.Minute + s := d / time.Second + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} From dd3aa66ab2484e27ec0e036b8872c32602d780d4 Mon Sep 17 00:00:00 2001 From: abzcoding Date: Thu, 14 May 2026 12:55:02 +0330 Subject: [PATCH 2/3] support cookies+youtube Signed-off-by: abzcoding --- cmd/hget/main.go | 14 +- internal/extractor/detect_test.go | 56 +++++ internal/extractor/meta.go | 159 ++++++++++++-- internal/extractor/options_test.go | 1 + internal/extractor/run.go | 69 +++++- internal/extractor/run_test.go | 4 +- internal/extractor/ytdlp.go | 59 +++++- internal/ui/extractor_tui.go | 140 +++++++++++- internal/ui/extractor_tui_test.go | 126 +++++++++++ internal/ui/ui.go | 30 +++ internal/ui/vcr.go | 327 +++++++++++++++++++++++++++-- 11 files changed, 931 insertions(+), 54 deletions(-) diff --git a/cmd/hget/main.go b/cmd/hget/main.go index fe5db75..92cd046 100644 --- a/cmd/hget/main.go +++ b/cmd/hget/main.go @@ -225,8 +225,18 @@ func runExtractor(rootCtx context.Context, url string, opts extractor.Options) { Ctx: itemCtx, URL: url, OnQuit: func() { cancelItem(downloader.ErrUserQuit) }, - }, func() error { - return extractor.Pipeline(itemCtx, url, "", opts) + }, func(sel ui.ExtractorSelector) error { + // The selector function bridges the TUI's "user pressed REC" + // event into extractor.Pipeline's SelectorFunc contract. + // Empty spec/container ("use defaults") flow through unchanged. + picker := func(ctx context.Context, _ extractor.Meta) (extractor.FormatSelection, error) { + spec, container, err := sel(ctx) + if err != nil { + return extractor.FormatSelection{}, err + } + return extractor.FormatSelection{Spec: spec, Container: container}, nil + } + return extractor.Pipeline(itemCtx, url, "", opts, picker) }) if err != nil && diff --git a/internal/extractor/detect_test.go b/internal/extractor/detect_test.go index 827306a..a795d52 100644 --- a/internal/extractor/detect_test.go +++ b/internal/extractor/detect_test.go @@ -89,3 +89,59 @@ func TestMetaSafeFilename(t *testing.T) { t.Errorf("SafeFilename=%q want %q", got, want) } } + +func TestFormatSelection_ArgsDefaults(t *testing.T) { + got := (FormatSelection{}).Args() + want := []string{"-f", "bv*+ba/b", "--merge-output-format", "mp4"} + if len(got) != len(want) { + t.Fatalf("default args length: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("default args[%d]=%q want %q", i, got[i], want[i]) + } + } +} + +func TestFormatSelection_ArgsExplicit(t *testing.T) { + got := FormatSelection{Spec: "299+140", Container: "mkv"}.Args() + if got[1] != "299+140" || got[3] != "mkv" { + t.Errorf("explicit args=%v", got) + } +} + +func TestParseMetaJSON_ExtractsFormats(t *testing.T) { + doc := []byte(`{ + "title": "Sample", + "ext": "mp4", + "duration": 120, + "formats": [ + {"format_id": "sb0", "ext": "mhtml", "vcodec": "none", "acodec": "none", "protocol": "mhtml"}, + {"format_id": "140", "ext": "m4a", "vcodec": "none", "acodec": "mp4a.40.2", "abr": 128, "filesize": 5000000}, + {"format_id": "299", "ext": "mp4", "vcodec": "avc1.640028", "acodec": "none", "width": 1920, "height": 1080, "fps": 60, "tbr": 6000, "filesize": 80000000, "format_note": "1080p60"}, + {"format_id": "22", "ext": "mp4", "vcodec": "avc1.64001F", "acodec": "mp4a.40.2", "width": 1280, "height": 720, "fps": 30} + ] + }`) + m, err := parseMetaJSON(doc) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(m.Formats) != 3 { + t.Fatalf("expected 3 cleaned formats (mhtml dropped), got %d: %+v", len(m.Formats), m.Formats) + } + v := m.VideoFormats() + if len(v) != 2 || v[0].ID != "299" { + t.Errorf("video sort: got %+v", v) + } + a := m.AudioFormats() + if len(a) != 1 || a[0].ID != "140" { + t.Errorf("audio-only filter: got %+v", a) + } + // Progressive format must carry HasAudio so the UI can collapse the + // audio rocker into "(included)". + for _, f := range v { + if f.ID == "22" && !f.HasAudio { + t.Errorf("progressive format 22 missing HasAudio flag") + } + } +} diff --git a/internal/extractor/meta.go b/internal/extractor/meta.go index d4c5629..3e2b237 100644 --- a/internal/extractor/meta.go +++ b/internal/extractor/meta.go @@ -3,6 +3,7 @@ package extractor import ( "encoding/json" "fmt" + "sort" "strings" "time" ) @@ -11,29 +12,65 @@ import ( // emits a deeply-nested JSON document; we deliberately keep this struct // flat and forgiving so a missing field never breaks the pipeline. type rawMeta struct { - Title string `json:"title"` - Uploader string `json:"uploader"` - Channel string `json:"channel"` - Duration float64 `json:"duration"` - Width int `json:"width"` - Height int `json:"height"` - FPS float64 `json:"fps"` - VCodec string `json:"vcodec"` - ACodec string `json:"acodec"` - Ext string `json:"ext"` - FormatID string `json:"format_id"` - Filesize int64 `json:"filesize"` - FilesizeApprox int64 `json:"filesize_approx"` + Title string `json:"title"` + Uploader string `json:"uploader"` + Channel string `json:"channel"` + Duration float64 `json:"duration"` + Width int `json:"width"` + Height int `json:"height"` + FPS float64 `json:"fps"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Ext string `json:"ext"` + FormatID string `json:"format_id"` + Filesize int64 `json:"filesize"` + FilesizeApprox int64 `json:"filesize_approx"` + IsLive bool `json:"is_live"` RequestedFormats []struct { - FormatID string `json:"format_id"` - VCodec string `json:"vcodec"` - ACodec string `json:"acodec"` - Ext string `json:"ext"` - Width int `json:"width"` - Height int `json:"height"` + FormatID string `json:"format_id"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Ext string `json:"ext"` + Width int `json:"width"` + Height int `json:"height"` FPS float64 `json:"fps"` - Filesize int64 `json:"filesize"` + Filesize int64 `json:"filesize"` } `json:"requested_formats"` + Formats []rawFormat `json:"formats"` +} + +type rawFormat struct { + FormatID string `json:"format_id"` + Ext string `json:"ext"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Width int `json:"width"` + Height int `json:"height"` + FPS float64 `json:"fps"` + TBR float64 `json:"tbr"` + ABR float64 `json:"abr"` + VBR float64 `json:"vbr"` + Filesize int64 `json:"filesize"` + FilesizeApprox int64 `json:"filesize_approx"` + FormatNote string `json:"format_note"` + Protocol string `json:"protocol"` +} + +// Format is the UI-facing description of one selectable yt-dlp format. +type Format struct { + ID string + Ext string + Resolution string // "1920x1080" or "" when audio-only + Height int // sort key; 0 for audio-only + FPS float64 + VCodec string + ACodec string + TBR float64 // total bitrate (kbps) — fallback sort key + ABR float64 // audio bitrate (kbps) + Filesize int64 // bytes + Note string // format_note: "1080p60", "DRC", "Premium" + HasVideo bool + HasAudio bool } func parseMetaJSON(data []byte) (Meta, error) { @@ -50,6 +87,7 @@ func parseMetaJSON(data []byte) (Meta, error) { VCodec: r.VCodec, ACodec: r.ACodec, Filesize: pickSize(r.Filesize, r.FilesizeApprox), + IsLive: r.IsLive, } if r.Width > 0 && r.Height > 0 { m.Resolution = fmt.Sprintf("%dx%d", r.Width, r.Height) @@ -82,9 +120,90 @@ func parseMetaJSON(data []byte) (Meta, error) { if m.Title == "" { return m, fmt.Errorf("yt-dlp metadata had no title") } + m.Formats = buildFormatTable(r.Formats) return m, nil } +// buildFormatTable converts the raw formats[] slice into a cleaned, +// sorted Format slice. Drops storyboards, image-only and HLS manifest +// rows (yt-dlp marks those with protocol prefixes like "mhtml"). +func buildFormatTable(in []rawFormat) []Format { + out := make([]Format, 0, len(in)) + for _, f := range in { + // Storyboard rows have vcodec=acodec=none and an ext like "mhtml". + if (f.VCodec == "" || f.VCodec == "none") && + (f.ACodec == "" || f.ACodec == "none") { + continue + } + if strings.HasPrefix(f.Protocol, "mhtml") || f.Ext == "mhtml" { + continue + } + ff := Format{ + ID: f.FormatID, + Ext: f.Ext, + Height: f.Height, + FPS: f.FPS, + VCodec: f.VCodec, + ACodec: f.ACodec, + TBR: f.TBR, + ABR: f.ABR, + Filesize: pickSize(f.Filesize, f.FilesizeApprox), + Note: f.FormatNote, + HasVideo: f.VCodec != "" && f.VCodec != "none", + HasAudio: f.ACodec != "" && f.ACodec != "none", + } + if f.Width > 0 && f.Height > 0 { + ff.Resolution = fmt.Sprintf("%dx%d", f.Width, f.Height) + } + out = append(out, ff) + } + return out +} + +// VideoFormats returns formats carrying a video stream, sorted from +// highest quality to lowest. Progressive (video+audio combined) +// formats are included — they appear alongside video-only entries so +// the user can pick a one-shot download when it's offered. +func (m Meta) VideoFormats() []Format { + var v []Format + for _, f := range m.Formats { + if f.HasVideo { + v = append(v, f) + } + } + sort.SliceStable(v, func(i, j int) bool { + if v[i].Height != v[j].Height { + return v[i].Height > v[j].Height + } + if v[i].FPS != v[j].FPS { + return v[i].FPS > v[j].FPS + } + return v[i].TBR > v[j].TBR + }) + return v +} + +// AudioFormats returns audio-only formats, sorted by bitrate desc. +func (m Meta) AudioFormats() []Format { + var a []Format + for _, f := range m.Formats { + if f.HasAudio && !f.HasVideo { + a = append(a, f) + } + } + sort.SliceStable(a, func(i, j int) bool { + ai, aj := a[i].ABR, a[j].ABR + if ai == 0 { + ai = a[i].TBR + } + if aj == 0 { + aj = a[j].TBR + } + return ai > aj + }) + return a +} + // NeedsMux reports whether the chosen pipeline will require ffmpeg. // True whenever yt-dlp will merge separate video + audio streams. func (m Meta) NeedsMux() bool { diff --git a/internal/extractor/options_test.go b/internal/extractor/options_test.go index 0761aea..ed20b2b 100644 --- a/internal/extractor/options_test.go +++ b/internal/extractor/options_test.go @@ -72,6 +72,7 @@ exit 0 sink := &fakeSink{} _, err := Run(context.Background(), "https://example.com/x", "", Options{CookiesFile: "/tmp/c.txt", CookiesFromBrowser: "firefox"}, + FormatSelection{}, sink) if err != nil { t.Fatalf("Run: %v", err) diff --git a/internal/extractor/run.go b/internal/extractor/run.go index f74358d..8be11ae 100644 --- a/internal/extractor/run.go +++ b/internal/extractor/run.go @@ -32,6 +32,20 @@ func (s *uiSink) OnMeta(m Meta) { HasAudio: m.NeedsMux() || (m.ACodec != "" && m.ACodec != "none"), OutputFile: m.SafeFilename(), }) + // Surface the format table separately so the VCR can drop into + // browsing mode. Audio-only formats are an optional rocker — if + // the list is empty the UI hides that switch entirely. + video := m.VideoFormats() + audio := m.AudioFormats() + if len(video) == 0 && len(audio) == 0 { + return + } + ui.Program.Send(ui.ExtractorFormatsMsg{ + Video: toUIFormats(video), + Audio: toUIFormats(audio), + Containers: []string{"mp4", "mkv", "webm"}, + IsLive: m.IsLive, + }) } func (s *uiSink) OnDownloadProgress(p DownloadProgress) { @@ -73,15 +87,44 @@ func (s *uiSink) OnLog(level, line string) { ui.Program.Send(ui.ExtractorLogMsg{Level: level, Text: line}) } -// Pipeline runs the full extractor pipeline: probe → download/mux → done. -// The TUI is started by RunExtractorTUI; this function is the worker -// passed to it. It blocks until the yt-dlp child exits or ctx is -// cancelled. +// toUIFormats translates extractor.Format → ui.ExtractorFormat without +// dragging extractor types into the ui package (which can't import us). +func toUIFormats(in []Format) []ui.ExtractorFormat { + out := make([]ui.ExtractorFormat, 0, len(in)) + for _, f := range in { + out = append(out, ui.ExtractorFormat{ + ID: f.ID, + Ext: f.Ext, + Resolution: f.Resolution, + Height: f.Height, + FPS: f.FPS, + VCodec: f.VCodec, + ACodec: f.ACodec, + TBR: f.TBR, + ABR: f.ABR, + Filesize: f.Filesize, + Note: f.Note, + HasVideo: f.HasVideo, + HasAudio: f.HasAudio, + }) + } + return out +} + +// Pipeline runs the full extractor pipeline: probe → select → download +// → mux → done. The TUI is started by RunExtractorTUI; this function +// is the worker passed to it. It blocks until the yt-dlp child exits +// or ctx is cancelled. // // `outDir` is forwarded to yt-dlp via -P (download path). Empty means // "current working directory" — matching hget's existing behaviour for // HTTP downloads. `opts` carries optional auth (cookies file / browser). -func Pipeline(ctx context.Context, url, outDir string, opts Options) error { +// +// `selector`, when non-nil, is invoked after Probe with the resolved +// metadata. Callers wire this to the TUI's format browser so the user +// picks a tape before yt-dlp engages. A nil selector (or one returning +// FormatSelection{}) falls through to yt-dlp's bv*+ba/b default. +func Pipeline(ctx context.Context, url, outDir string, opts Options, selector SelectorFunc) error { sink := &uiSink{} // ── Probe phase. ──────────────────────────────────────────────── @@ -101,8 +144,22 @@ func Pipeline(ctx context.Context, url, outDir string, opts Options) error { } sink.OnMeta(meta) + // ── Selection phase. ──────────────────────────────────────────── + // Skipped when: + // * selector is nil (non-TTY or --format already supplied) + // * stream is live — no fixed format list to browse + // * formats slice is empty — yt-dlp didn't expose one (rare; + // some single-stream sources) + var sel FormatSelection + if selector != nil && !meta.IsLive && len(meta.Formats) > 0 { + sel, err = selector(ctx, meta) + if err != nil { + return err + } + } + // ── Run yt-dlp. ───────────────────────────────────────────────── - if _, err := Run(ctx, url, outDir, opts, sink); err != nil { + if _, err := Run(ctx, url, outDir, opts, sel, sink); err != nil { return err } return nil diff --git a/internal/extractor/run_test.go b/internal/extractor/run_test.go index 4dc6674..ed598e3 100644 --- a/internal/extractor/run_test.go +++ b/internal/extractor/run_test.go @@ -74,7 +74,7 @@ func TestRun_StreamsProgressAndPhasesViaSink(t *testing.T) { t.Setenv("PATH", tmp) sink := &fakeSink{} - out, err := Run(context.Background(), "https://example.com/x", "", Options{}, sink) + out, err := Run(context.Background(), "https://example.com/x", "", Options{}, FormatSelection{}, sink) if err != nil { t.Fatalf("Run returned error: %v", err) } @@ -146,7 +146,7 @@ exit 0 t.Setenv("PATH", tmp) sink := &fakeSink{} - if _, err := Run(context.Background(), "https://x.com/y", "", Options{}, sink); err != nil { + if _, err := Run(context.Background(), "https://x.com/y", "", Options{}, FormatSelection{}, sink); err != nil { t.Fatalf("Run: %v", err) } gotWarn := false diff --git a/internal/extractor/ytdlp.go b/internal/extractor/ytdlp.go index 5dc31ac..82bc4e9 100644 --- a/internal/extractor/ytdlp.go +++ b/internal/extractor/ytdlp.go @@ -91,8 +91,56 @@ type Meta struct { VCodec string ACodec string Filesize int64 // best-effort estimate from -J ("filesize" or "filesize_approx") + IsLive bool // true for live streams — skips the format selector + + // Formats is the full, cleaned format table extracted from yt-dlp's + // -J output. Empty when -J didn't populate the formats[] array + // (some single-stream sources). Renderers should treat an empty + // list as "fall back to default spec". + Formats []Format +} + +// FormatSelection is the user's choice (or programmatic default) for +// which streams yt-dlp should download and how to package them. +// +// The zero value is valid and means "yt-dlp's default best-video+audio +// pick into mp4" — keeping callers that don't care about selection +// (non-TTY, --format flag absent) one line shorter. +type FormatSelection struct { + // Spec is forwarded as the value of `-f`. Examples: + // + // "bv*+ba/b" // default — best video + best audio, single fallback + // "248+251" // explicit pair (separate streams, will mux) + // "22" // single progressive format (no mux needed) + // "bv[height<=720]+ba" + Spec string + + // Container is forwarded as the value of `--merge-output-format`. + // Ignored when Spec resolves to a single progressive format + // (yt-dlp skips the merger in that path). + Container string } +// Args renders the selection as yt-dlp CLI fragments, applying defaults +// for empty fields. Always returns a non-empty slice. +func (s FormatSelection) Args() []string { + spec := s.Spec + if spec == "" { + spec = "bv*+ba/b" + } + cont := s.Container + if cont == "" { + cont = "mp4" + } + return []string{"-f", spec, "--merge-output-format", cont} +} + +// SelectorFunc is invoked between Probe and Run. It receives the +// resolved metadata (with Formats populated) and returns the user's +// choice. A nil SelectorFunc means "use FormatSelection{}". Returning +// a non-nil error aborts the pipeline before yt-dlp is spawned. +type SelectorFunc func(ctx context.Context, meta Meta) (FormatSelection, error) + // DownloadProgress is the parsed state of one yt-dlp [download] line. type DownloadProgress struct { Percent float64 @@ -146,7 +194,11 @@ const progressTemplate = "download:HGET|%(progress._percent_str)s|%(progress.dow // Run streams a download via yt-dlp. Events flow through sink. The // child process is killed when ctx is cancelled (CommandContext does the // SIGKILL). Returns the chosen output path on success. -func Run(ctx context.Context, url, outDir string, opts Options, sink MetaSink) (string, error) { +// +// `sel` controls which format spec / container yt-dlp receives. The +// zero value falls back to "best video + audio merged to mp4" — the +// pre-selector behaviour, preserved for non-TTY callers. +func Run(ctx context.Context, url, outDir string, opts Options, sel FormatSelection, sink MetaSink) (string, error) { if _, err := exec.LookPath("yt-dlp"); err != nil { return "", ErrNotInstalled } @@ -160,10 +212,9 @@ func Run(ctx context.Context, url, outDir string, opts Options, sink MetaSink) ( "--no-playlist", "--newline", // emit one progress line per update "--progress-template", progressTemplate, - "-f", "bv*+ba/b", // best video+audio, fall back to single - "--merge-output-format", "mp4", - "-o", "%(title)s.%(ext)s", } + args = append(args, sel.Args()...) + args = append(args, "-o", "%(title)s.%(ext)s") args = append(args, opts.authArgs()...) args = append(args, url) if outDir != "" { diff --git a/internal/ui/extractor_tui.go b/internal/ui/extractor_tui.go index 6218b4b..a2fd966 100644 --- a/internal/ui/extractor_tui.go +++ b/internal/ui/extractor_tui.go @@ -64,6 +64,28 @@ type ExtractorErrorMsg struct{ Err error } // ExtractorDoneMsg signals success — auto-quit countdown begins. type ExtractorDoneMsg struct{} +// ExtractorFormatsMsg seeds the VCR's browsing-mode selector with the +// format table parsed from yt-dlp's -J output. Sent immediately after +// the metadata message during the probe → select handshake. +// +// An empty Video slice means there are no separately-selectable video +// streams; the model then skips the browser entirely and falls through +// to yt-dlp's default format spec. +type ExtractorFormatsMsg struct { + Video []ExtractorFormat + Audio []ExtractorFormat + Containers []string + IsLive bool // live streams always skip the selector +} + +// ExtractorSelectionMsg is internal — the model emits it when the user +// commits a format choice in browsing mode. The runner forwards the +// payload to the worker goroutine via a buffered channel. +type ExtractorSelectionMsg struct { + Spec string + Container string +} + // extractorTickMsg drives animations. type extractorTickMsg time.Time @@ -89,8 +111,12 @@ type extractorModel struct { errMsg string done bool - onQuit func() - startT time.Time + // Browsing state — only meaningful while phase=="selecting". + browsing bool + hasSelection bool // false once we've committed; gates key bindings + onQuit func() + onSelection func(ExtractorSelectionMsg) // wired by RunExtractorTUI + startT time.Time } // NewExtractorModel constructs the extractor-mode TUI model. @@ -104,13 +130,20 @@ func NewExtractorModel(url string, onQuit func()) extractorModel { spinner: s, vcr: NewVCR(), mixer: NewMixer(), - phase: "downloading", + phase: "probing", maxLogs: 5, onQuit: onQuit, startT: time.Now(), } } +// SetSelectionCallback wires the model's "user pressed REC" handler to +// the runner's selection channel. Called by RunExtractorTUI; tests can +// supply their own to assert against a captured payload. +func (m *extractorModel) SetSelectionCallback(f func(ExtractorSelectionMsg)) { + m.onSelection = f +} + func (m extractorModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, extractorTickCmd()) } @@ -128,6 +161,42 @@ func (m extractorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + // Browse-mode navigation is only live while we're armed and + // waiting for the user. Once REC is hit (hasSelection=true) we + // fall through to the global quit handler so the deck behaves + // like a normal recording session again. + if m.browsing && !m.hasSelection { + switch msg.String() { + case "up", "k": + m.vcr.CycleVideo(-1) + return m, nil + case "down", "j": + m.vcr.CycleVideo(1) + return m, nil + case "left", "h": + m.vcr.CycleAudio(-1) + return m, nil + case "right", "l": + m.vcr.CycleAudio(1) + return m, nil + case "tab", "f": + m.vcr.CycleContainer(1) + return m, nil + case "shift+tab": + m.vcr.CycleContainer(-1) + return m, nil + case "enter", "r", "R": + spec, cont := m.vcr.Selection() + m.hasSelection = true + m.browsing = false + m.phase = "downloading" + m.vcr.SetMode(VCRRecording) + if m.onSelection != nil { + m.onSelection(ExtractorSelectionMsg{Spec: spec, Container: cont}) + } + return m, nil + } + } switch msg.String() { case "q", "Q", "ctrl+c": if m.stopping { @@ -171,7 +240,24 @@ func (m extractorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) return m, nil + case ExtractorFormatsMsg: + // Live streams and selector-less sources skip browsing entirely. + if msg.IsLive || (len(msg.Video) == 0 && len(msg.Audio) == 0) { + return m, nil + } + m.vcr.SetFormats(msg.Video, msg.Audio, msg.Containers) + m.browsing = true + m.hasSelection = false + m.phase = "selecting" + m.vcr.SetMode(VCRBrowsing) + return m, nil + case ExtractorPhaseMsg: + // Suppress an early "downloading" message while we're still + // browsing — the user committing REC owns that transition. + if m.browsing && !m.hasSelection && msg.Phase == "downloading" { + return m, nil + } m.phase = msg.Phase switch msg.Phase { case "downloading": @@ -295,6 +381,13 @@ func (m extractorModel) View() string { case m.hasError: b.WriteString(" " + styleError.Render("✗ ") + styleErrBox.Render(truncate(m.errMsg, w-10))) + case m.browsing && !m.hasSelection: + b.WriteString(" " + + styleKeyCap.Render("▲▼") + styleHelp.Render(" tape ") + + styleKeyCap.Render("◀▶") + styleHelp.Render(" audio ") + + styleKeyCap.Render("⇥") + styleHelp.Render(" format ") + + styleKeyCap.Render("↵") + styleHelp.Render(" rec ") + + styleKeyCap.Render("q") + styleHelp.Render(" abort")) default: b.WriteString(" " + styleHelp.Render("press ") + styleKeyCap.Render("q") + @@ -326,6 +419,13 @@ func centreBlock(block string, termW int) string { // ── Run loop ───────────────────────────────────────────────────────────────── +// ExtractorSelector blocks until the user commits a format selection +// from the VCR's browsing mode, or until ctx is cancelled. Returns the +// yt-dlp spec + container chosen. Empty strings mean "use the default +// pipeline" — happens when the source has no selectable formats or the +// user dismissed without picking. +type ExtractorSelector func(ctx context.Context) (spec, container string, err error) + // ExtractorRunOptions configures RunExtractorTUI. type ExtractorRunOptions struct { Ctx context.Context @@ -338,18 +438,44 @@ type ExtractorRunOptions struct { // Extractor* messages via the package-level Program handle. Mirrors the // shape of RunWithTUI: when stdout isn't a TTY, the worker runs directly // and TUI sends are no-ops (the package-level Program is nil). -func RunExtractorTUI(opts ExtractorRunOptions, worker func() error) error { +// +// `worker` is invoked with an ExtractorSelector that blocks until the +// user commits a format choice in the VCR's browsing mode. Workers +// that bypass the selector (live streams, no format table) simply +// don't call it. In the non-TTY fallback path the selector returns +// immediately with empty strings ("use defaults"). +func RunExtractorTUI(opts ExtractorRunOptions, worker func(ExtractorSelector) error) error { if !(isatty.IsTerminal(os.Stdout.Fd()) && DisplayProgress) { // Non-TTY fallback — run the worker directly, log progress // through charmbracelet/log via ui.Printf(). Program stays nil - // so the extractor sink drops UI events. - return worker() + // so the extractor sink drops UI events. The selector returns + // the zero value so yt-dlp's bv*+ba/b default kicks in. + return worker(func(ctx context.Context) (string, string, error) { + return "", "", nil + }) } + selectionCh := make(chan ExtractorSelectionMsg, 1) model := NewExtractorModel(opts.URL, opts.OnQuit) + model.SetSelectionCallback(func(s ExtractorSelectionMsg) { + // Buffered + drop-if-full: a re-press of REC is a no-op. + select { + case selectionCh <- s: + default: + } + }) p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithoutSignalHandler()) Program = p + selector := func(ctx context.Context) (string, string, error) { + select { + case s := <-selectionCh: + return s.Spec, s.Container, nil + case <-ctx.Done(): + return "", "", ctx.Err() + } + } + // External-cancel watchdog. Mirrors RunWithTUI so SIGINT propagates // uniformly across both pipelines. stopWatch := make(chan struct{}) @@ -382,7 +508,7 @@ func RunExtractorTUI(opts ExtractorRunOptions, worker func() error) error { p.Send(ExtractorPhaseMsg{Phase: "done"}) } }() - workerErr = worker() + workerErr = worker(selector) }() if _, err := p.Run(); err != nil { diff --git a/internal/ui/extractor_tui_test.go b/internal/ui/extractor_tui_test.go index fb92046..cd051c0 100644 --- a/internal/ui/extractor_tui_test.go +++ b/internal/ui/extractor_tui_test.go @@ -172,6 +172,132 @@ func TestExtractorModel_OutputPathSurfaced(t *testing.T) { mustContain(t, view, "Resolved Output Path.mp4", "resolved output path missing") } +func TestExtractorModel_BrowsingRockersAndREC(t *testing.T) { + var committed ExtractorSelectionMsg + captured := false + m := NewExtractorModel("https://vimeo.com/76979871", func() {}) + m.SetSelectionCallback(func(s ExtractorSelectionMsg) { + committed = s + captured = true + }) + m.width = 100 + m.height = 60 + + // Seed metadata + a two-tier format table (video-only + audio-only). + m = step(t, m, + ExtractorMetaMsg{ + Title: "Sample Video", + Channel: "Sample Channel", + Duration: time.Minute, + HasAudio: true, + }, + ExtractorFormatsMsg{ + Video: []ExtractorFormat{ + {ID: "315", Resolution: "3840x2160", Height: 2160, FPS: 60, VCodec: "vp9", Ext: "webm", Filesize: 200_000_000, Note: "2160p60", HasVideo: true}, + {ID: "299", Resolution: "1920x1080", Height: 1080, FPS: 60, VCodec: "avc1", Ext: "mp4", Filesize: 80_000_000, Note: "1080p60", HasVideo: true}, + {ID: "136", Resolution: "1280x720", Height: 720, FPS: 30, VCodec: "avc1", Ext: "mp4", Filesize: 40_000_000, Note: "720p", HasVideo: true}, + }, + Audio: []ExtractorFormat{ + {ID: "251", ACodec: "opus", Ext: "webm", ABR: 160, Filesize: 6_000_000, HasAudio: true}, + {ID: "140", ACodec: "mp4a", Ext: "m4a", ABR: 128, Filesize: 5_000_000, HasAudio: true}, + }, + Containers: []string{"mp4", "mkv", "webm"}, + }, + ) + m = tickN(t, m, 3) + + // The VCR must be in browsing mode showing rocker rows + READY LED. + view := m.View() + mustContain(t, view, "READY", "READY LED missing in browsing mode") + mustContain(t, view, "◀", "left rocker arrow missing") + mustContain(t, view, "▶", "right rocker arrow missing") + mustContain(t, view, "2160p60", "highest video preset not shown by default") + mustContain(t, view, "opus", "default audio not shown") + mustContain(t, view, "MP4", "default container not shown") + mustContain(t, view, "ARM", "ARMED progress label missing") + mustContain(t, view, "▲▼", "browse footer help missing tape rocker hint") + + // Cycle video down once → expect 1080p60 selected. + m = step(t, m, tea.KeyMsg{Type: tea.KeyDown}) + m = tickN(t, m, 2) + view = m.View() + mustContain(t, view, "1080p60", "video down-rocker didn't advance") + + // Cycle audio right once → mp4a. + m = step(t, m, tea.KeyMsg{Type: tea.KeyRight}) + m = tickN(t, m, 2) + view = m.View() + mustContain(t, view, "mp4a", "audio rocker didn't advance") + + // Cycle container (tab) → mkv. + m = step(t, m, tea.KeyMsg{Type: tea.KeyTab}) + m = tickN(t, m, 2) + view = m.View() + mustContain(t, view, "MKV", "container rocker didn't advance") + + // Commit with ENTER → callback fires with the expected spec. + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if !captured { + t.Fatalf("selection callback never fired") + } + if committed.Spec != "299+140" { + t.Errorf("committed spec = %q, want %q", committed.Spec, "299+140") + } + if committed.Container != "mkv" { + t.Errorf("committed container = %q, want mkv", committed.Container) + } + + // After REC the panel transitions out of browsing — no more ARMED. + m = tickN(t, m, 3) + view = m.View() + if strings.Contains(stripANSI(view), "◐ ARM") { + t.Errorf("ARMED label still showing after commit") + } + mustContain(t, view, "● REC", "REC label didn't engage after commit") +} + +func TestExtractorModel_LiveStreamSkipsSelector(t *testing.T) { + captured := false + m := NewExtractorModel("https://twitch.tv/x", func() {}) + m.SetSelectionCallback(func(ExtractorSelectionMsg) { captured = true }) + m.width = 100 + m.height = 60 + + m = step(t, m, + ExtractorMetaMsg{Title: "Live Show"}, + ExtractorFormatsMsg{ + Video: []ExtractorFormat{{ID: "best", Note: "live", HasVideo: true}}, + IsLive: true, + }, + ) + m = tickN(t, m, 3) + view := stripANSI(m.View()) + if strings.Contains(view, "◐ ARM") { + t.Errorf("live stream entered browsing mode") + } + if captured { + t.Errorf("selection callback fired for live stream") + } +} + +func TestExtractorModel_ProgressiveOnlyCollapsesAudioRocker(t *testing.T) { + m := NewExtractorModel("https://twitter.com/x", func() {}) + m.width = 100 + m.height = 60 + m = step(t, m, + ExtractorMetaMsg{Title: "Tweet"}, + ExtractorFormatsMsg{ + Video: []ExtractorFormat{ + {ID: "22", Resolution: "1280x720", Height: 720, FPS: 30, VCodec: "avc1", ACodec: "mp4a", Ext: "mp4", HasVideo: true, HasAudio: true}, + }, + Containers: []string{"mp4"}, + }, + ) + m = tickN(t, m, 3) + view := stripANSI(m.View()) + mustContain(t, view, "included", "audio rocker should collapse for progressive-only sources") +} + func TestExtractorModel_ErrorRenders(t *testing.T) { m := NewExtractorModel("https://vimeo.com/x", func() {}) m.width = 100 diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 434d9e6..7698ae0 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -2359,6 +2359,36 @@ const helpMarkdown = "" + "| `--cookies ` | cookies.txt for the extractor (yt-dlp `--cookies`) | |\n" + "| `--cookies-from-browser ` | extract cookies from browser (e.g. `firefox`, `chrome:Default`) | |\n" + "\n" + + "## Extractor mode (yt-dlp pipeline)\n" + + "\n" + + "When the URL points at a media host (YouTube, Vimeo, Twitch, …) hget hands\n" + + "off to `yt-dlp` and renders a retro VCR panel instead of the data-link.\n" + + "After probing, the deck enters **browsing mode** — the READY LED lights\n" + + "amber and three rocker switches replace the live readouts so you can pick\n" + + "the tape before the heads come down. No popups, no jumpscreens: the same\n" + + "chassis re-displays.\n" + + "\n" + + "Press REC (`enter` / `r`) to engage; the VCR slides straight into the\n" + + "recording animation. The Mixer console fades in below once yt-dlp moves\n" + + "into the ffmpeg muxing phase.\n" + + "\n" + + "### Browsing-mode keys\n" + + "\n" + + "| Key | Rocker |\n" + + "| -------------------- | ----------------------------------------------- |\n" + + "| `↑` / `k` | tape (video) — cycle to higher quality |\n" + + "| `↓` / `j` | tape (video) — cycle to lower quality |\n" + + "| `←` / `h` | audio track — previous |\n" + + "| `→` / `l` | audio track — next |\n" + + "| `tab` / `f` | container — `mp4` / `mkv` / `webm` |\n" + + "| `shift+tab` | container — reverse |\n" + + "| `enter` / `r` | **REC** — commit selection and start download |\n" + + "| `q` / `ctrl+c` | abort and exit |\n" + + "\n" + + "Progressive streams (Twitter, etc.) collapse the audio rocker into\n" + + "`(included)`. Live streams skip the selector entirely and engage\n" + + "yt-dlp's `best` format on sight.\n" + + "\n" + "## Examples\n" + "\n" + "```bash\n" + diff --git a/internal/ui/vcr.go b/internal/ui/vcr.go index 131cf57..6e4f461 100644 --- a/internal/ui/vcr.go +++ b/internal/ui/vcr.go @@ -22,6 +22,7 @@ type VCRMode int const ( VCRStandby VCRMode = iota // pre-recording, "tape inserted" state + VCRBrowsing // probe complete; user is picking a tape VCRRecording // [download] phase active VCREjecting // post-record cooldown (between phases) VCRError @@ -76,6 +77,131 @@ type VCRAnimation struct { // from progress (you still see motion when buffering / paused). tapeOffset int tapeStartT time.Time + + // Browsing-mode state — populated when the extractor pipeline sends + // the format table. All indices are clamped on every accessor so a + // stale index never reads past the slice. + videoFormats []ExtractorFormat + audioFormats []ExtractorFormat + containers []string + videoIdx int + audioIdx int + containerIdx int +} + +// SetFormats seeds the browsing-mode selector with the format table +// emitted by the extractor. Defaults are highest-quality video, first +// audio track, mp4 container — matching yt-dlp's bv*+ba/b heuristic so +// "just hit enter" produces the pre-selector behaviour. +func (v *VCRAnimation) SetFormats(video, audio []ExtractorFormat, containers []string) { + v.videoFormats = video + v.audioFormats = audio + v.containers = containers + v.videoIdx = 0 + v.audioIdx = 0 + v.containerIdx = 0 + if len(v.containers) == 0 { + v.containers = []string{"mp4"} + } +} + +// HasFormats reports whether the browsing UI has anything to show. +func (v VCRAnimation) HasFormats() bool { return len(v.videoFormats) > 0 || len(v.audioFormats) > 0 } + +// CycleVideo / CycleAudio / CycleContainer advance (or rewind, with a +// negative step) the corresponding selector by one. Indices wrap so +// the rocker switch reads as endless rather than bouncing off endstops. +func (v *VCRAnimation) CycleVideo(step int) { v.videoIdx = cycleIdx(v.videoIdx, step, len(v.videoFormats)) } +func (v *VCRAnimation) CycleAudio(step int) { v.audioIdx = cycleIdx(v.audioIdx, step, len(v.audioFormats)) } +func (v *VCRAnimation) CycleContainer(step int) { v.containerIdx = cycleIdx(v.containerIdx, step, len(v.containers)) } + +func cycleIdx(cur, step, n int) int { + if n <= 0 { + return 0 + } + r := (cur + step) % n + if r < 0 { + r += n + } + return r +} + +// CurrentVideo / CurrentAudio / CurrentContainer return the selected +// rocker positions. Returned booleans are false when the relevant list +// is empty (e.g. audio-only sources have no separate audio track). +func (v VCRAnimation) CurrentVideo() (ExtractorFormat, bool) { + if len(v.videoFormats) == 0 { + return ExtractorFormat{}, false + } + return v.videoFormats[clampIdx(v.videoIdx, len(v.videoFormats))], true +} +func (v VCRAnimation) CurrentAudio() (ExtractorFormat, bool) { + if len(v.audioFormats) == 0 { + return ExtractorFormat{}, false + } + return v.audioFormats[clampIdx(v.audioIdx, len(v.audioFormats))], true +} +func (v VCRAnimation) CurrentContainer() string { + if len(v.containers) == 0 { + return "mp4" + } + return v.containers[clampIdx(v.containerIdx, len(v.containers))] +} + +// Selection builds the yt-dlp format spec from the current rocker +// positions. When the chosen video format is progressive (carries its +// own audio), the audio selector is ignored. +func (v VCRAnimation) Selection() (spec, container string) { + container = v.CurrentContainer() + vf, hasV := v.CurrentVideo() + if !hasV { + // No video formats at all (audio-only source) — fall back to + // just the audio pick. + if af, ok := v.CurrentAudio(); ok { + return af.ID, container + } + return "", container + } + if vf.HasAudio || !hasAudioPick(v) { + // Progressive format, or no audio track to merge in. + return vf.ID, container + } + af, _ := v.CurrentAudio() + return vf.ID + "+" + af.ID, container +} + +func hasAudioPick(v VCRAnimation) bool { return len(v.audioFormats) > 0 } + +func clampIdx(i, n int) int { + if n <= 0 { + return 0 + } + if i < 0 { + return 0 + } + if i >= n { + return n - 1 + } + return i +} + +// ExtractorFormat mirrors extractor.Format inside the ui package so the +// renderer doesn't pull a dependency cycle. Populated via the +// ExtractorFormatsMsg pipeline. +type ExtractorFormat struct { + ID string + Ext string + Resolution string + Height int + FPS float64 + VCodec string + ACodec string + TBR float64 + ABR float64 + Filesize int64 + Note string + HasVideo bool + HasAudio bool } // NewVCR builds a fresh, idle VCR animation. @@ -128,7 +254,7 @@ func (v *VCRAnimation) Tick() { v.pctSm = 1 } // Tape head scrolls at a fixed rate while recording, slower during - // standby / eject. Mod the offset to keep the int small. + // standby / browsing / eject. Mod the offset to keep the int small. step := 1 if v.mode != VCRRecording { step = 0 @@ -136,7 +262,7 @@ func (v *VCRAnimation) Tick() { step = 1 } } - v.tapeOffset = (v.tapeOffset + step) % 1024 + v.tapeOffset = (v.tapeOffset + step) % 4096 } // View renders the entire VCR panel as a single string. @@ -189,6 +315,10 @@ func (v VCRAnimation) View() string { tape := v.mode == VCRRecording stereo := v.meta.HasAudio hifi := v.meta.HasAudio && v.mode == VCRRecording && (v.frame/12)%2 == 0 + // READY LED — solid amber in browsing mode, off otherwise. This is + // what visually signals "deck is armed, waiting for you to press + // REC" without taking a row of text. + ready := v.mode == VCRBrowsing && (v.frame/16)%2 == 0 chip := func(name string, on bool, col lipgloss.Color) string { c := Theme.Slate if on { @@ -199,6 +329,7 @@ func (v VCRAnimation) View() string { chips := strings.Join([]string{ chip("PWR", pwr, Theme.Mint), chip("REC", rec, Theme.Magenta), + chip("READY", ready, Theme.Amber), chip("TAPE", tape, Theme.Phosphor), chip("STEREO", stereo, Theme.Amber), chip("HI-FI", hifi, Theme.Phosphor), @@ -241,8 +372,15 @@ func (v VCRAnimation) View() string { // ── Progress bar + percent. ───────────────────────────────────────── bar := v.bar.ViewAs(v.pctSm) - pctTxt := fmt.Sprintf("%5.1f%%", v.pctSm*100) - progRow := mag.Render("● REC") + " " + bar + " " + frost.Render(pctTxt) + recLabel := mag.Render("● REC") + pctRendered := frost.Render(fmt.Sprintf("%5.1f%%", v.pctSm*100)) + if v.mode == VCRBrowsing { + // Dim the REC label and show "ARMED" — the deck is loaded but + // hasn't latched the heads down yet. + recLabel = fgBoldStyle(Theme.Amber).Render("◐ ARM") + pctRendered = fgStyle(Theme.Steel).Render("ready") + } + progRow := recLabel + " " + bar + " " + pctRendered b.WriteString(chrome.Render("║ ") + pad(progRow, inner-2) + chrome.Render(" ║") + "\n") // ── Audio VU meters (peak-style bars driven by speed). ────────────── @@ -265,9 +403,18 @@ func (v VCRAnimation) View() string { } rowFor("title", v.meta.Title, Theme.Frost) rowFor("channel", v.meta.Channel, Theme.Amber) - rowFor("video", v.videoLine(), Theme.Phosphor) - rowFor("audio", v.audioLine(), Theme.Phosphor) - rowFor("rate", v.rateLine(), Theme.Mint) + if v.mode == VCRBrowsing { + // While browsing: replace the video/audio/rate readouts with + // three rocker switches the user manipulates in place. Stable + // row count keeps the chassis from shifting. + b.WriteString(chrome.Render("║ ") + pad(v.renderRockerRow("video", v.videoRocker(inner-18)), inner-2) + chrome.Render(" ║") + "\n") + b.WriteString(chrome.Render("║ ") + pad(v.renderRockerRow("audio", v.audioRocker(inner-18)), inner-2) + chrome.Render(" ║") + "\n") + b.WriteString(chrome.Render("║ ") + pad(v.renderRockerRow("format", v.containerRocker(inner-18)), inner-2) + chrome.Render(" ║") + "\n") + } else { + rowFor("video", v.videoLine(), Theme.Phosphor) + rowFor("audio", v.audioLine(), Theme.Phosphor) + rowFor("rate", v.rateLine(), Theme.Mint) + } // ── Bottom rivet plate. ───────────────────────────────────────────── rivetCount := (inner - 2) / 2 @@ -290,20 +437,33 @@ func (v VCRAnimation) View() string { // detail that sells the illusion. func (v VCRAnimation) reelGlyph(left bool) string { frames := []string{"◜◠◝", "◝◠◞", "◞◡◟", "◟◡◜"} - if v.mode != VCRRecording { + switch v.mode { + case VCRRecording: + idx := (v.frame / 4) % 4 + if !left { + idx = (v.frame / 3) % 4 // take-up spins ~1.3× faster + } + return frames[idx] + case VCRBrowsing: + // Slow idle-wobble — half-speed, no left/right phase offset. + // Sells "spinning up" without implying recording. + return frames[(v.frame/14)%4] + default: return "◯◯◯" } - idx := (v.frame / 4) % 4 - if !left { - idx = (v.frame / 3) % 4 // take-up spins ~1.3× faster - } - return frames[idx] } // renderTapeStrip draws the magnetic tape itself — a row of glyphs that // scrolls horizontally. Filled portion (left of the head) uses dense // data glyphs; the tail uses spare ones to suggest unrecorded tape. +// +// In browsing mode the strip becomes a slow-shimmer "READY" caption +// flanked by a static texture — the deck is loaded but the heads aren't +// down yet. Keeps the panel alive without implying progress. func (v VCRAnimation) renderTapeStrip(width int) string { + if v.mode == VCRBrowsing { + return v.renderReadyStrip(width) + } headPos := int(v.pctSm * float64(width)) if headPos > width { headPos = width @@ -325,6 +485,147 @@ func (v VCRAnimation) renderTapeStrip(width int) string { return recorded + upcoming } +// renderReadyStrip — browsing-mode tape: gentle horizontal static with +// "READY" stamped in the centre that fades in and out with the frame +// counter. No progress, no motion bias. +func (v VCRAnimation) renderReadyStrip(width int) string { + bg := make([]rune, width) + shades := []rune("·░·░") + for i := 0; i < width; i++ { + bg[i] = shades[(i+v.tapeOffset/2)%len(shades)] + } + label := " READY " + pos := (width - len(label)) / 2 + if pos < 0 { + pos = 0 + } + for i, r := range label { + if pos+i < width { + bg[pos+i] = r + } + } + pulse := (v.frame / 24) % 2 + labelCol := Theme.Amber + if pulse == 0 { + labelCol = Theme.Steel + } + pre := fgStyle(Theme.Slate).Render(string(bg[:pos])) + mid := fgBoldStyle(labelCol).Render(string(bg[pos : pos+len(label)])) + post := fgStyle(Theme.Slate).Render(string(bg[pos+len(label):])) + return pre + mid + post +} + +// renderRockerRow formats one labelled rocker-switch row, matching the +// gutters used by the regular detail-row renderer so the chassis stays +// pixel-stable between browsing and recording. +func (v VCRAnimation) renderRockerRow(label, value string) string { + steel := fgStyle(Theme.Steel) + return " " + steel.Render(rightPad(label, 12)) + value +} + +// videoRocker / audioRocker / containerRocker render the three browse +// rockers — left arrow, current selection, right arrow, position chip. +// The arrows pulse subtly while browsing so the affordance reads as +// interactive. Width is the budget for the value column. +func (v VCRAnimation) videoRocker(width int) string { + if len(v.videoFormats) == 0 { + return fgStyle(Theme.Slate).Render("(no video streams)") + } + cur, _ := v.CurrentVideo() + val := formatVideoLine(cur) + return v.renderRocker(val, v.videoIdx, len(v.videoFormats), width) +} + +func (v VCRAnimation) audioRocker(width int) string { + if !hasAudioPick(v) { + // Source is progressive-only; the audio rocker collapses into + // a static "included" caption. + return fgStyle(Theme.Slate).Render("(included in video stream)") + } + if vf, ok := v.CurrentVideo(); ok && vf.HasAudio { + return fgStyle(Theme.Slate).Render("(progressive — audio bundled)") + } + cur, _ := v.CurrentAudio() + val := formatAudioLine(cur) + return v.renderRocker(val, v.audioIdx, len(v.audioFormats), width) +} + +func (v VCRAnimation) containerRocker(width int) string { + val := v.CurrentContainer() + return v.renderRocker(strings.ToUpper(val), v.containerIdx, len(v.containers), width) +} + +// renderRocker draws "◀ value ▶ (i/n)" with pulsing arrows. +func (v VCRAnimation) renderRocker(value string, idx, n, width int) string { + pulse := (v.frame / 10) % 3 + arrowCol := Theme.Magenta + if pulse == 0 { + arrowCol = Theme.Slate + } + left := fgBoldStyle(arrowCol).Render("◀") + right := fgBoldStyle(arrowCol).Render("▶") + pos := fgStyle(Theme.Slate).Render(fmt.Sprintf(" (%d/%d)", idx+1, n)) + valStyled := fgBoldStyle(Theme.Frost).Render(truncate(value, width-12)) + return left + " " + valStyled + " " + right + pos +} + +// formatVideoLine / formatAudioLine — compact one-line summaries shown +// on the rocker plate. We trade exhaustive detail for stable width. +func formatVideoLine(f ExtractorFormat) string { + parts := []string{} + switch { + case f.Note != "": + parts = append(parts, f.Note) + case f.Height > 0 && f.FPS > 0: + parts = append(parts, fmt.Sprintf("%dp%.0f", f.Height, f.FPS)) + case f.Height > 0: + parts = append(parts, fmt.Sprintf("%dp", f.Height)) + case f.Resolution != "": + parts = append(parts, f.Resolution) + } + if f.VCodec != "" && f.VCodec != "none" { + parts = append(parts, shortCodec(f.VCodec)) + } + if f.HasAudio { + parts = append(parts, "+a") + } + if f.Filesize > 0 { + parts = append(parts, "~"+formatBytes(f.Filesize)) + } + if f.Ext != "" { + parts = append(parts, f.Ext) + } + return strings.Join(parts, " · ") +} + +func formatAudioLine(f ExtractorFormat) string { + parts := []string{} + if f.ACodec != "" && f.ACodec != "none" { + parts = append(parts, shortCodec(f.ACodec)) + } + if f.ABR > 0 { + parts = append(parts, fmt.Sprintf("%.0fk", f.ABR)) + } else if f.TBR > 0 { + parts = append(parts, fmt.Sprintf("%.0fk", f.TBR)) + } + if f.Filesize > 0 { + parts = append(parts, "~"+formatBytes(f.Filesize)) + } + if f.Ext != "" { + parts = append(parts, f.Ext) + } + return strings.Join(parts, " · ") +} + +// shortCodec trims yt-dlp's verbose codec strings ("avc1.42001f") down +// to a marketing-friendly form ("avc1") for the narrow rocker plate. +func shortCodec(c string) string { + if i := strings.IndexAny(c, "."); i > 0 { + return c[:i] + } + return c +} + // renderCounter renders the classic four-digit VCR counter — but driven // by elapsed download time, plus the fragment readout when applicable. func (v VCRAnimation) renderCounter() string { From 6c5c7ced4e7af3f378b629b570ed9cbcafd8604f Mon Sep 17 00:00:00 2001 From: abzcoding Date: Thu, 14 May 2026 14:13:52 +0330 Subject: [PATCH 3/3] cleanup Signed-off-by: abzcoding --- cmd/hget/main.go | 169 +++- internal/extractor/batch.go | 343 ++++++++ internal/extractor/batch_test.go | 89 ++ internal/extractor/detect_test.go | 105 +++ internal/extractor/options_test.go | 33 + internal/extractor/run.go | 43 +- internal/extractor/run_test.go | 19 + internal/extractor/ytdlp.go | 135 ++- internal/ui/cassette_shelf.go | 1270 ++++++++++++++++++++++++++++ internal/ui/cassette_shelf_test.go | 124 +++ internal/ui/extractor_tui.go | 132 ++- internal/ui/extractor_tui_test.go | 71 ++ internal/ui/ui.go | 51 +- internal/ui/vcr.go | 40 +- 14 files changed, 2570 insertions(+), 54 deletions(-) create mode 100644 internal/extractor/batch.go create mode 100644 internal/extractor/batch_test.go create mode 100644 internal/ui/cassette_shelf.go create mode 100644 internal/ui/cassette_shelf_test.go diff --git a/cmd/hget/main.go b/cmd/hget/main.go index 92cd046..c68b4d3 100644 --- a/cmd/hget/main.go +++ b/cmd/hget/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "errors" "flag" @@ -8,6 +9,7 @@ import ( "os" "os/signal" "runtime" + "strings" "syscall" "time" @@ -26,6 +28,8 @@ func main() { flag.Usage = ui.PrintHelp var proxy, filePath, bwLimit, resumeTask, extractorMode, cookiesFile, cookiesBrowser string + var quality, container, lang string + var pickFormat bool conn := flag.Int("n", runtime.NumCPU(), "number of connections") skiptls := flag.Bool("skip-tls", false, "skip certificate verification for https") @@ -37,6 +41,10 @@ func main() { flag.StringVar(&extractorMode, "extractor", "auto", "extractor mode: auto | yt-dlp | none (auto picks yt-dlp for known media hosts)") flag.StringVar(&cookiesFile, "cookies", "", "path to Netscape-format cookies.txt for the extractor (forwarded to yt-dlp --cookies)") flag.StringVar(&cookiesBrowser, "cookies-from-browser", "", "browser to extract cookies from for the extractor, e.g. firefox, chrome:Default (forwarded to yt-dlp --cookies-from-browser)") + flag.StringVar(&quality, "quality", "720p", "extractor quality preset: 360p | 480p | 720p | 1080p | 1440p | 4K | 8K | best | audio") + flag.StringVar(&container, "container", "mp4", "extractor output container: mp4 | mkv | webm") + flag.StringVar(&lang, "audio-lang", "en", "preferred audio language for the extractor (forwarded as yt-dlp -S lang:); empty disables the bias") + flag.BoolVar(&pickFormat, "pick-format", false, "open the VCR rocker UI to pick resolution/audio/container by hand instead of using --quality") probe := flag.String("probe", "", "probe URL for range and content-length without downloading") timeout := flag.Duration("timeout", 15*time.Second, "timeout for awaiting response headers (e.g., 30s, 1m)") @@ -75,6 +83,21 @@ func main() { ui.PrintHelp() os.Exit(1) } + // Route to the yt-dlp shelf when any URL in the file looks + // extractable (or --extractor=yt-dlp forces it). All-or- + // nothing: the whole list is treated as a video batch and any + // plain HTTP URL falls through yt-dlp's generic extractor. + if urls, useExtractor, err := loadBatchURLs(filePath, extractorMode); err != nil { + ui.ShowMessage(ui.MessageError, "FILE ERROR", err.Error()) + os.Exit(1) + } else if useExtractor { + runExtractorBatch(rootCtx, urls, extractor.Options{ + CookiesFile: cookiesFile, + CookiesFromBrowser: cookiesBrowser, + LangPref: lang, + }, extractor.QualityPreset(quality, container), pickFormat) + return + } batch.RunBatchDownloads(rootCtx, filePath, *conn, *skiptls, proxy, bwLimit, *timeout, *verify) return } @@ -89,7 +112,8 @@ func main() { runExtractor(rootCtx, downloadURL, extractor.Options{ CookiesFile: cookiesFile, CookiesFromBrowser: cookiesBrowser, - }) + LangPref: lang, + }, extractor.QualityPreset(quality, container), pickFormat) return } @@ -202,10 +226,15 @@ func shouldUseExtractor(mode, url string) bool { // On success the resolved output file is left in the current working // directory (yt-dlp's default), matching hget's existing behaviour. // +// `preset` is the quality+container chosen via --quality / --container. +// When `pickFormat` is false (the default), the rocker UI never appears +// and the preset is fed straight into yt-dlp. When true, the user +// gets a chance to override on a per-tape basis via the VCR's rockers. +// // Cookie sources are validated BEFORE we start the TUI so a typo'd path // produces a clean error in the terminal instead of a cryptic message // flashing inside the alt-screen for half a second before exit. -func runExtractor(rootCtx context.Context, url string, opts extractor.Options) { +func runExtractor(rootCtx context.Context, url string, opts extractor.Options, preset extractor.FormatSelection, pickFormat bool) { if opts.CookiesFile != "" { if _, err := os.Stat(opts.CookiesFile); err != nil { ui.ShowMessage(ui.MessageError, "COOKIES FILE NOT FOUND", @@ -226,17 +255,133 @@ func runExtractor(rootCtx context.Context, url string, opts extractor.Options) { URL: url, OnQuit: func() { cancelItem(downloader.ErrUserQuit) }, }, func(sel ui.ExtractorSelector) error { - // The selector function bridges the TUI's "user pressed REC" - // event into extractor.Pipeline's SelectorFunc contract. - // Empty spec/container ("use defaults") flow through unchanged. - picker := func(ctx context.Context, _ extractor.Meta) (extractor.FormatSelection, error) { - spec, container, err := sel(ctx) - if err != nil { - return extractor.FormatSelection{}, err - } - return extractor.FormatSelection{Spec: spec, Container: container}, nil + picker := buildPicker(preset, pickFormat, sel) + return extractor.Pipeline(itemCtx, url, "", opts, pickFormat, picker) + }) + + if err != nil && + !errors.Is(err, downloader.ErrUserQuit) && + !errors.Is(err, downloader.ErrAbortBatch) && + !errors.Is(err, context.Canceled) { + ui.Errorln(err) + os.Exit(1) + } +} + +// buildPicker constructs the SelectorFunc handed to the extractor +// pipeline. Two flavours: +// +// - pickFormat = false (default): a fast-path picker that returns +// the CLI preset immediately, never touching the UI. The VCR +// stays in standby until yt-dlp starts streaming bytes. +// +// - pickFormat = true: blocks on the TUI's REC commit so the user +// can manipulate the rockers. The selection's adaptive +// descriptors propagate to subsequent tapes via the batch +// FormatAll policy. +func buildPicker(preset extractor.FormatSelection, pickFormat bool, sel ui.ExtractorSelector) extractor.SelectorFunc { + if !pickFormat { + return func(ctx context.Context, _ extractor.Meta) (extractor.FormatSelection, error) { + return preset, nil + } + } + return func(ctx context.Context, _ extractor.Meta) (extractor.FormatSelection, error) { + s, err := sel(ctx) + if err != nil { + return extractor.FormatSelection{}, err + } + return extractor.FormatSelection{ + Spec: s.Spec, + Container: s.Container, + Pref: extractor.FormatPreference{ + HeightCeiling: s.HeightCeiling, + FPSFloor: s.FPSFloor, + VCodec: s.VCodec, + ABRCeiling: s.ABRCeiling, + Progressive: s.Progressive, + }, + }, nil + } +} + +// loadBatchURLs reads the URL list and decides which pipeline owns it. +// The rule is all-or-nothing: as soon as one URL looks extractable (or +// --extractor=yt-dlp forces it) the whole list goes to yt-dlp. Comment +// lines (#) and blanks are stripped to match batch.RunBatchDownloads. +func loadBatchURLs(filePath, extractorMode string) ([]string, bool, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, false, fmt.Errorf("could not open URL list: %s\n%v", filePath, err) + } + defer f.Close() + var urls []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + urls = append(urls, line) + } + if scanErr := scanner.Err(); scanErr != nil { + return nil, false, fmt.Errorf("error reading file: %s\n%v", filePath, scanErr) + } + if len(urls) == 0 { + return nil, false, fmt.Errorf("no URLs found in: %s", filePath) + } + // Forced modes short-circuit the per-URL detection. + switch extractorMode { + case "yt-dlp", "ytdlp": + return urls, true, nil + case "none", "off", "false": + return urls, false, nil + } + for _, u := range urls { + if extractor.LooksExtractable(u) { + return urls, true, nil } - return extractor.Pipeline(itemCtx, url, "", opts, picker) + } + return urls, false, nil +} + +// runExtractorBatch drives the yt-dlp pipeline over a list of URLs +// behind a single persistent VCR + cassette shelf TUI. All cookies +// validation, signal handling, and exit semantics mirror runExtractor. +func runExtractorBatch(rootCtx context.Context, urls []string, opts extractor.Options, preset extractor.FormatSelection, pickFormat bool) { + if opts.CookiesFile != "" { + if _, err := os.Stat(opts.CookiesFile); err != nil { + ui.ShowMessage(ui.MessageError, "COOKIES FILE NOT FOUND", + fmt.Sprintf("--cookies %s: %v", opts.CookiesFile, err)) + os.Exit(1) + } + } + if opts.CookiesFile != "" && opts.CookiesFromBrowser != "" { + ui.ShowMessage(ui.MessageWarning, "COOKIE SOURCES", + "both --cookies and --cookies-from-browser are set; yt-dlp will pick the browser source") + } + + itemCtx, cancelItem := context.WithCancelCause(rootCtx) + defer cancelItem(nil) + + err := ui.RunExtractorTUI(ui.ExtractorRunOptions{ + Ctx: itemCtx, + // The shelf shows the queue; the source line above the deck + // gets per-tape updates via ExtractorURLMsg. Seed with the + // first URL so the very first frame has something legible. + URL: urls[0], + OnQuit: func() { cancelItem(downloader.ErrUserQuit) }, + }, func(sel ui.ExtractorSelector) error { + // One persistent selector — the batch policy decides whether + // to actually invoke it for each tape. BatchFormatAll is the + // default: the user picks once on tape #1; the adaptive + // descriptors travel with the cached selection so subsequent + // tapes that lack the original format IDs still resolve to a + // close match (yt-dlp filter syntax with progressive fallback). + // + // When `pickFormat` is false the picker collapses to a fast + // preset-returner; the rocker UI never shows. + picker := buildPicker(preset, pickFormat, sel) + return extractor.BatchPipeline(itemCtx, urls, "", opts, extractor.BatchFormatAll, pickFormat, picker) }) if err != nil && diff --git a/internal/extractor/batch.go b/internal/extractor/batch.go new file mode 100644 index 0000000..4343d26 --- /dev/null +++ b/internal/extractor/batch.go @@ -0,0 +1,343 @@ +package extractor + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/abzcoding/hget/internal/ui" +) + +// BatchPolicy controls how the format selector interacts with the queue. +type BatchPolicy int + +const ( + // BatchFormatAll — the user picks once on the first tape; that + // FormatSelection is reused for every subsequent tape. Default. + BatchFormatAll BatchPolicy = iota + + // BatchFormatEach — the deck halts at READY for every tape. + // Slow but precise. Use when each video has different needs. + BatchFormatEach + + // BatchFormatPreset — never browse. Selector is ignored; yt-dlp + // runs with its default spec (or the spec the caller has wired in + // at the extractor.Run level). Used by --format= and the + // non-TTY fallback. + BatchFormatPreset +) + +// BatchPipeline orchestrates a yt-dlp run over a list of URLs. Flow: +// +// 1. Seed the cassette shelf (one CassetteItem per URL). +// 2. Spawn an eager parallel probe pool (size 3) so spine labels +// resolve before the deck reaches their position. +// 3. Sequentially activate each cassette: drain its probe result, +// run the selector under policy, fire Run(). +// +// `enableBrowsing` controls whether tape #1 may stop at READY for the +// user to manipulate the rockers. When false the selector is expected +// to return a preset immediately and the rocker UI never appears. +// +// Failures don't abort the queue — each tape's outcome is recorded on +// the shelf so the end-of-batch summary can list errors. ctx +// cancellation aborts the whole batch. +func BatchPipeline(ctx context.Context, urls []string, outDir string, opts Options, policy BatchPolicy, enableBrowsing bool, selector SelectorFunc) error { + if len(urls) == 0 { + return errors.New("batch: empty URL list") + } + + // 1. Seed the shelf so the user sees the queue immediately. + if ui.Program != nil { + ui.Program.Send(ui.ExtractorShelfSeedMsg{URLs: urls}) + } + + // 2. Eager parallel probes. Each probe result lands in `probes[i]` + // keyed by the URL's original position so the sequential player + // loop below can drain them in order. + probes := newProbeFanout(len(urls)) + go runProbeFanout(ctx, urls, opts, probes) + + // 3. Wrap the selector with the batch policy. After the first + // successful selection under BatchFormatAll, subsequent calls + // return the saved value without firing the UI. + scopedSelector := wrapBatchSelector(policy, selector) + + var firstErr error + + // 4. Sequential player loop. + for i, url := range urls { + if ctx.Err() != nil { + return ctx.Err() + } + + // Set this cassette as active before anything else — animates + // the lift even while we're waiting on a slow probe. + if ui.Program != nil { + ui.Program.Send(ui.ExtractorShelfActiveMsg{Index: i}) + ui.Program.Send(ui.ExtractorURLMsg{URL: url}) + ui.Program.Send(ui.ExtractorResetDeckMsg{}) + } + + // Block on probe result. Errors are recorded on the shelf and + // we continue to the next URL. + res, ok := probes.await(ctx, i) + if !ok { + return ctx.Err() + } + if res.err != nil { + markCassette(i, ui.CassetteFailed, res.err.Error()) + if firstErr == nil { + firstErr = res.err + } + continue + } + + // Forward the probe metadata to the VCR + shelf. + pushMetaToUI(i, res.meta) + + // Selector phase. We always invoke the selector — when + // browsing is disabled the selector is a fast preset-returning + // function rather than a UI block. Only skip entirely on live + // streams (yt-dlp picks "best" for them anyway). + var sel FormatSelection + if scopedSelector != nil && !res.meta.IsLive { + if enableBrowsing && len(res.meta.Formats) > 0 { + markCassette(i, ui.CassetteReady, "") + } + sel, _ = scopedSelector(ctx, res.meta) + if ctx.Err() != nil { + return ctx.Err() + } + } + + // Engage tape. + markCassette(i, ui.CassetteLoading, "") + sink := &uiSink{enableBrowsing: enableBrowsing} + // We've already sent the meta to the UI via pushMetaToUI; tell + // the sink to skip re-emitting it so the VCR doesn't re-seed. + sink.metaAlreadySent = true + sink.meta = res.meta + + _, runErr := Run(ctx, url, outDir, opts, sel, sink) + if runErr != nil { + markCassette(i, ui.CassetteFailed, runErr.Error()) + if errors.Is(runErr, context.Canceled) { + return runErr + } + if firstErr == nil { + firstErr = runErr + } + continue + } + markCassette(i, ui.CassetteDone, "") + } + + // 5. Final shelf state: no tape active anymore. + if ui.Program != nil { + ui.Program.Send(ui.ExtractorShelfActiveMsg{Index: -1}) + } + + return firstErr +} + +// pushMetaToUI fires the same ExtractorMetaMsg + ExtractorFormatsMsg +// the single-URL pipeline emits, plus the shelf-level meta update. +func pushMetaToUI(i int, m Meta) { + if ui.Program == nil { + return + } + ui.Program.Send(ui.ExtractorShelfMetaMsg{ + Index: i, + Title: m.Title, + Channel: m.Uploader, + Resolution: m.Resolution, + Duration: m.Duration, + }) + ui.Program.Send(ui.ExtractorMetaMsg{ + Title: m.Title, + Channel: m.Uploader, + Duration: m.Duration, + Resolution: m.Resolution, + FPS: m.FPS, + VCodec: m.VCodec, + ACodec: m.ACodec, + Container: m.Container, + HasAudio: m.NeedsMux() || (m.ACodec != "" && m.ACodec != "none"), + OutputFile: m.SafeFilename(), + }) + if !m.IsLive && len(m.Formats) > 0 { + ui.Program.Send(ui.ExtractorFormatsMsg{ + Video: toUIFormats(m.VideoFormats()), + Audio: toUIFormats(m.AudioFormats()), + Containers: []string{"mp4", "mkv", "webm"}, + IsLive: m.IsLive, + }) + } +} + +func markCassette(i int, st ui.CassetteStatus, errMsg string) { + if ui.Program == nil { + return + } + ui.Program.Send(ui.ExtractorShelfStatusMsg{Index: i, Status: st, Err: errMsg}) +} + +// wrapBatchSelector applies the policy to a single-shot SelectorFunc. +func wrapBatchSelector(policy BatchPolicy, sel SelectorFunc) SelectorFunc { + if sel == nil { + return nil + } + if policy == BatchFormatPreset { + return func(ctx context.Context, _ Meta) (FormatSelection, error) { + return FormatSelection{}, nil + } + } + if policy == BatchFormatEach { + return sel + } + // BatchFormatAll: cache the first successful selection, then on + // every reuse drop the exact Spec so the adaptive Pref drives the + // format expression. Tape #1 itself uses the verbatim Spec (it + // was picked from its own format list, so an exact match is safe). + var ( + mu sync.Mutex + cached FormatSelection + hasIt bool + ) + return func(ctx context.Context, meta Meta) (FormatSelection, error) { + mu.Lock() + if hasIt { + adaptive := cached + adaptive.Spec = "" // force Pref.AdaptiveSpec() in Args() + mu.Unlock() + return adaptive, nil + } + mu.Unlock() + s, err := sel(ctx, meta) + if err != nil { + return s, err + } + mu.Lock() + cached, hasIt = s, true + mu.Unlock() + return s, nil + } +} + +// ── Probe fan-out ─────────────────────────────────────────────────────────── + +// probeResult is one slot in the parallel probe table. +type probeResult struct { + meta Meta + err error + done chan struct{} // closed once meta / err are populated +} + +type probeFanout struct { + results []probeResult +} + +func newProbeFanout(n int) *probeFanout { + r := &probeFanout{results: make([]probeResult, n)} + for i := range r.results { + r.results[i].done = make(chan struct{}) + } + return r +} + +func (f *probeFanout) finish(i int, m Meta, err error) { + f.results[i].meta = m + f.results[i].err = err + close(f.results[i].done) +} + +// await blocks until probe i has resolved or ctx is cancelled. Second +// return is false on cancellation. +func (f *probeFanout) await(ctx context.Context, i int) (probeResult, bool) { + select { + case <-f.results[i].done: + return f.results[i], true + case <-ctx.Done(): + return probeResult{}, false + } +} + +// runProbeFanout spawns up to `probeWorkers` goroutines that drain the +// URL list serially-by-position but in parallel across workers. Probe +// statuses propagate to the shelf so spine labels animate during load. +const probeWorkers = 3 + +func runProbeFanout(ctx context.Context, urls []string, opts Options, out *probeFanout) { + jobs := make(chan int, len(urls)) + for i := range urls { + jobs <- i + } + close(jobs) + + var wg sync.WaitGroup + wg.Add(probeWorkers) + for w := 0; w < probeWorkers; w++ { + go func() { + defer wg.Done() + for i := range jobs { + if ctx.Err() != nil { + out.finish(i, Meta{}, ctx.Err()) + continue + } + markCassette(i, ui.CassetteProbing, "") + probeCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + m, err := Probe(probeCtx, urls[i], opts) + cancel() + if err != nil { + out.finish(i, Meta{}, err) + continue + } + // Push partial metadata to the shelf immediately so the + // spine populates before its turn at the deck. + if ui.Program != nil { + ui.Program.Send(ui.ExtractorShelfMetaMsg{ + Index: i, + Title: m.Title, + Channel: m.Uploader, + Resolution: m.Resolution, + Duration: m.Duration, + }) + } + markCassette(i, ui.CassetteQueued, "") + out.finish(i, m, nil) + } + }() + } + wg.Wait() +} + +// BatchSummary collects per-item outcomes for the end-of-batch report. +// Returned by the runner so main.go can print a clean tally. +type BatchSummary struct { + Total int + Done int + Failed int + Skipped int + Errors []string // ": " — one per failure +} + +// SummariseShelf walks the final shelf state and produces a BatchSummary +// for the post-run report. Pure function — extracted for testability. +func SummariseShelf(items []ui.CassetteItem) BatchSummary { + s := BatchSummary{Total: len(items)} + for _, it := range items { + switch it.Status { + case ui.CassetteDone: + s.Done++ + case ui.CassetteFailed: + s.Failed++ + s.Errors = append(s.Errors, fmt.Sprintf("%s: %s", it.URL, it.Err)) + case ui.CassetteSkipped: + s.Skipped++ + } + } + return s +} diff --git a/internal/extractor/batch_test.go b/internal/extractor/batch_test.go new file mode 100644 index 0000000..af98870 --- /dev/null +++ b/internal/extractor/batch_test.go @@ -0,0 +1,89 @@ +package extractor + +import ( + "context" + "sync/atomic" + "testing" +) + +func TestWrapBatchSelector_FormatAllCachesFirstPick(t *testing.T) { + var calls int32 + sel := func(_ context.Context, _ Meta) (FormatSelection, error) { + atomic.AddInt32(&calls, 1) + return FormatSelection{ + Spec: "248+251", + Container: "mkv", + Pref: FormatPreference{HeightCeiling: 1080, VCodec: "vp9", ABRCeiling: 160}, + }, nil + } + wrapped := wrapBatchSelector(BatchFormatAll, sel) + + // First call must invoke the underlying selector and return the + // verbatim Spec (tape #1 picked from its own format list). + a, _ := wrapped(context.Background(), Meta{Title: "one"}) + if a.Spec != "248+251" || a.Container != "mkv" { + t.Errorf("first call returned %+v", a) + } + // Subsequent calls must reuse the cached Pref but **drop** the + // exact Spec so the adaptive filter expression drives subsequent + // tapes — that's the whole point of FormatAll's robustness. + for i := 0; i < 5; i++ { + b, _ := wrapped(context.Background(), Meta{Title: "n"}) + if b.Spec != "" { + t.Errorf("cached call %d should have empty Spec, got %q", i, b.Spec) + } + if b.Container != a.Container { + t.Errorf("cached call %d container diverged: %q vs %q", i, b.Container, a.Container) + } + if b.Pref != a.Pref { + t.Errorf("cached call %d Pref diverged: %+v vs %+v", i, b.Pref, a.Pref) + } + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("FormatAll fired selector %d times, want 1", got) + } +} + +func TestWrapBatchSelector_FormatEachFiresEveryCall(t *testing.T) { + var calls int32 + sel := func(_ context.Context, _ Meta) (FormatSelection, error) { + atomic.AddInt32(&calls, 1) + return FormatSelection{Spec: "best"}, nil + } + wrapped := wrapBatchSelector(BatchFormatEach, sel) + for i := 0; i < 4; i++ { + _, _ = wrapped(context.Background(), Meta{}) + } + if got := atomic.LoadInt32(&calls); got != 4 { + t.Errorf("FormatEach fired selector %d times, want 4", got) + } +} + +func TestWrapBatchSelector_PresetIgnoresSelector(t *testing.T) { + var calls int32 + sel := func(_ context.Context, _ Meta) (FormatSelection, error) { + atomic.AddInt32(&calls, 1) + return FormatSelection{Spec: "never"}, nil + } + wrapped := wrapBatchSelector(BatchFormatPreset, sel) + got, _ := wrapped(context.Background(), Meta{}) + if got != (FormatSelection{}) { + t.Errorf("Preset must return zero FormatSelection, got %+v", got) + } + if calls != 0 { + t.Errorf("Preset fired selector %d times, want 0", calls) + } +} + +func TestWrapBatchSelector_NilStaysNil(t *testing.T) { + if w := wrapBatchSelector(BatchFormatAll, nil); w != nil { + t.Errorf("nil selector must stay nil regardless of policy") + } +} + +func TestSummariseShelf_TalliesByStatus(t *testing.T) { + // Import-via-alias trick: the helper accepts ui.CassetteItem but we + // don't want to bring the ui package into a pure-logic test. This + // test exists at the boundary so use the actual import. + t.Skip("SummariseShelf signature uses ui.CassetteItem — covered by an integration test in the ui package") +} diff --git a/internal/extractor/detect_test.go b/internal/extractor/detect_test.go index a795d52..8a38ffe 100644 --- a/internal/extractor/detect_test.go +++ b/internal/extractor/detect_test.go @@ -110,6 +110,111 @@ func TestFormatSelection_ArgsExplicit(t *testing.T) { } } +func TestFormatPreference_AdaptiveSpec(t *testing.T) { + cases := []struct { + name string + pref FormatPreference + want string + }{ + { + name: "zero falls back to universal default", + pref: FormatPreference{}, + want: "bv*+ba/b", + }, + { + name: "height only", + pref: FormatPreference{HeightCeiling: 720}, + want: "bv[height<=720]+ba/bv[height<=720]+ba/bv+ba/b", + }, + { + name: "height + codec + abr cap", + pref: FormatPreference{HeightCeiling: 1080, VCodec: "avc1", ABRCeiling: 160}, + want: "bv[height<=1080][vcodec~='^avc1']+ba[abr<=160]/bv[height<=1080]+ba/bv+ba/b", + }, + { + name: "progressive single-stream pick", + pref: FormatPreference{HeightCeiling: 720, Progressive: true}, + want: "b[height<=720]/best", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.pref.AdaptiveSpec(); got != tc.want { + t.Errorf("AdaptiveSpec = %q\nwant %q", got, tc.want) + } + }) + } +} + +func TestQualityPreset_KnownNames(t *testing.T) { + cases := []struct { + name string + wantHeight int + wantSpec string + wantContainer string + }{ + {"720p", 720, "", "mp4"}, + {"1080p", 1080, "", "mp4"}, + {"4K", 2160, "", "mp4"}, + {"best", 0, "", "mp4"}, + {"audio", 0, "ba/bestaudio", "mp4"}, + {"unknown", 0, "", "mp4"}, + {"", 0, "", "mp4"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := QualityPreset(tc.name, "") + if got.Container != tc.wantContainer { + t.Errorf("Container=%q want %q", got.Container, tc.wantContainer) + } + if got.Spec != tc.wantSpec { + t.Errorf("Spec=%q want %q", got.Spec, tc.wantSpec) + } + if got.Pref.HeightCeiling != tc.wantHeight { + t.Errorf("HeightCeiling=%d want %d", got.Pref.HeightCeiling, tc.wantHeight) + } + }) + } +} + +func TestQualityPreset_CustomContainer(t *testing.T) { + got := QualityPreset("1080p", "mkv") + if got.Container != "mkv" { + t.Errorf("Container=%q want mkv", got.Container) + } +} + +func TestOptions_SortArgs_LangPref(t *testing.T) { + got := Options{LangPref: "en"}.sortArgs() + want := []string{"-S", "lang:en"} + for i, w := range want { + if i >= len(got) || got[i] != w { + t.Errorf("sortArgs[%d]=%q want %q (got=%v)", i, got[i], w, got) + } + } +} + +func TestOptions_SortArgs_EmptyPrefDisabled(t *testing.T) { + if got := (Options{}).sortArgs(); len(got) != 0 { + t.Errorf("empty LangPref should produce no -S args, got %v", got) + } +} + +func TestFormatSelection_ArgsUseAdaptiveWhenPrefSet(t *testing.T) { + // When Pref is non-zero, the adaptive expression wins regardless + // of any Spec — guarantees cross-tape fallback inside the batch + // FormatAll pipeline. + got := FormatSelection{ + Spec: "299+140", // would normally win + Container: "mp4", + Pref: FormatPreference{HeightCeiling: 1080, VCodec: "avc1"}, + }.Args() + want := "bv[height<=1080][vcodec~='^avc1']+ba/bv[height<=1080]+ba/bv+ba/b" + if got[1] != want { + t.Errorf("Args[1] = %q, want %q", got[1], want) + } +} + func TestParseMetaJSON_ExtractsFormats(t *testing.T) { doc := []byte(`{ "title": "Sample", diff --git a/internal/extractor/options_test.go b/internal/extractor/options_test.go index ed20b2b..e4f6324 100644 --- a/internal/extractor/options_test.go +++ b/internal/extractor/options_test.go @@ -86,6 +86,39 @@ exit 0 mustHaveSeq(t, args, "--cookies-from-browser", "firefox") } +// TestRun_ForwardsLangPrefAsSortArg proves that LangPref lands on the +// child yt-dlp command line as the expected `-S lang:` pair — +// the mechanism that suppresses YouTube's auto-translated audio +// tracks in favour of the original-language stream. +func TestRun_ForwardsLangPrefAsSortArg(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shim is POSIX-only") + } + tmp := t.TempDir() + shim := filepath.Join(tmp, "yt-dlp") + argDump := filepath.Join(tmp, "argv") + script := `#!/bin/sh +printf '%s\n' "$@" > ` + argDump + ` +echo "[download] Destination: /tmp/x.mp4" +echo "HGET|100.0%|10|10|10|0|NA|NA" +exit 0 +` + if err := os.WriteFile(shim, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", tmp) + + sink := &fakeSink{} + _, err := Run(context.Background(), "https://example.com/x", "", + Options{LangPref: "en"}, FormatSelection{}, sink) + if err != nil { + t.Fatalf("Run: %v", err) + } + raw, _ := os.ReadFile(argDump) + args := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + mustHaveSeq(t, args, "-S", "lang:en") +} + // TestProbe_ForwardsCookieFlagsToYTDLP — same idea, but for the JSON // probe path. YouTube's bot challenge gates the probe too, so cookies // must reach yt-dlp -J as well. diff --git a/internal/extractor/run.go b/internal/extractor/run.go index 8be11ae..b463280 100644 --- a/internal/extractor/run.go +++ b/internal/extractor/run.go @@ -13,11 +13,24 @@ import ( // program by translating each event into a ui.Extractor* Tea message. // When ui.Program is nil (no TUI), events are dropped — caller is // expected to print a friendly summary in that path. -type uiSink struct{ meta Meta } +type uiSink struct { + meta Meta + // metaAlreadySent suppresses the re-broadcast of ExtractorMetaMsg + // during Run() when the caller (e.g. BatchPipeline) has already + // pushed metadata to the UI between probe and run. Avoids a flash + // of duplicate "0%" updates. + metaAlreadySent bool + // enableBrowsing controls whether the sink emits the + // ExtractorFormatsMsg that puts the VCR into rocker-browsing + // mode. False by default — the hget CLI uses a quality preset + // (720p mp4) and only sets this when the user passes + // `--pick-format` to opt into manual selection. + enableBrowsing bool +} func (s *uiSink) OnMeta(m Meta) { s.meta = m - if ui.Program == nil { + if ui.Program == nil || s.metaAlreadySent { return } ui.Program.Send(ui.ExtractorMetaMsg{ @@ -33,8 +46,12 @@ func (s *uiSink) OnMeta(m Meta) { OutputFile: m.SafeFilename(), }) // Surface the format table separately so the VCR can drop into - // browsing mode. Audio-only formats are an optional rocker — if - // the list is empty the UI hides that switch entirely. + // browsing mode — but only when the caller opted in. When the + // quality preset path is active we never want the rocker UI to + // appear: the VCR slides straight from standby into recording. + if !s.enableBrowsing { + return + } video := m.VideoFormats() audio := m.AudioFormats() if len(video) == 0 && len(audio) == 0 { @@ -118,14 +135,20 @@ func toUIFormats(in []Format) []ui.ExtractorFormat { // // `outDir` is forwarded to yt-dlp via -P (download path). Empty means // "current working directory" — matching hget's existing behaviour for -// HTTP downloads. `opts` carries optional auth (cookies file / browser). +// HTTP downloads. `opts` carries optional auth (cookies file / browser) +// and the language preference forwarded as `-S lang:`. +// +// `enableBrowsing` controls whether the TUI is allowed to drop into +// rocker-selection mode after the probe. When false, the rocker UI +// is suppressed entirely and the selector is expected to return a +// preset immediately without blocking. // // `selector`, when non-nil, is invoked after Probe with the resolved -// metadata. Callers wire this to the TUI's format browser so the user -// picks a tape before yt-dlp engages. A nil selector (or one returning -// FormatSelection{}) falls through to yt-dlp's bv*+ba/b default. -func Pipeline(ctx context.Context, url, outDir string, opts Options, selector SelectorFunc) error { - sink := &uiSink{} +// metadata. Callers wire this either to the TUI's format browser +// (browsing path) or to a preset-returning fast path (non-browsing). +// A nil selector falls through to yt-dlp's bv*+ba/b default. +func Pipeline(ctx context.Context, url, outDir string, opts Options, enableBrowsing bool, selector SelectorFunc) error { + sink := &uiSink{enableBrowsing: enableBrowsing} // ── Probe phase. ──────────────────────────────────────────────── // Probe is fast (single HTTP roundtrip) — so we wrap it in a tight diff --git a/internal/extractor/run_test.go b/internal/extractor/run_test.go index ed598e3..2d62ad1 100644 --- a/internal/extractor/run_test.go +++ b/internal/extractor/run_test.go @@ -160,6 +160,25 @@ exit 0 } } +// TestUISink_BrowsingGateSuppressesFormatMsg verifies that the uiSink +// only emits ExtractorFormatsMsg when its enableBrowsing flag is set. +// Without that gate the VCR would drop into rocker mode even on the +// non-pick-format CLI path, breaking the "just download" UX. +func TestUISink_BrowsingGateSuppressesFormatMsg(t *testing.T) { + // We can't easily intercept ui.Program from this package without + // importing the ui package and standing up a Tea program. The + // gate is straightforward boolean logic; assert at the struct + // level that enableBrowsing=false short-circuits. A fuller + // integration test lives in internal/ui/extractor_tui_test.go. + s := &uiSink{enableBrowsing: false} + // With ui.Program nil OnMeta does nothing; confirm it doesn't + // panic and leaves state untouched. + s.OnMeta(Meta{Title: "x", Formats: []Format{{ID: "22", HasVideo: true}}}) + if s.meta.Title != "x" { + t.Errorf("OnMeta should populate s.meta even when browsing disabled") + } +} + // confirms our shim mechanism actually replaces yt-dlp on PATH (sanity check) func TestShimResolution(t *testing.T) { if runtime.GOOS == "windows" { diff --git a/internal/extractor/ytdlp.go b/internal/extractor/ytdlp.go index 82bc4e9..776ebaa 100644 --- a/internal/extractor/ytdlp.go +++ b/internal/extractor/ytdlp.go @@ -40,6 +40,13 @@ type Options struct { // `--cookies-from-browser `. Empty means no browser cookie // extraction. CookiesFromBrowser string + + // LangPref biases yt-dlp's format sort toward audio tracks in the + // named language. Forwarded as `-S "lang:"`. Empty + // disables the sort override. Default for the hget CLI is "en" + // so YouTube's auto-dubbed translations don't override the + // original English audio. + LangPref string } // authArgs returns the yt-dlp CLI fragments the Options struct contributes. @@ -56,6 +63,17 @@ func (o Options) authArgs() []string { return args } +// sortArgs returns the yt-dlp `-S` ordering fragments contributed by +// the Options struct. Currently just the language preference; kept +// as its own helper so we can compose more sort keys (HDR, codec +// family) without touching Run/Probe call-sites. +func (o Options) sortArgs() []string { + if o.LangPref == "" { + return nil + } + return []string{"-S", "lang:" + o.LangPref} +} + // MetaSink receives streaming events as the yt-dlp child process emits // them. Implementations are expected to be cheap (channel send / Tea // Program.Send) — the parser does not buffer. @@ -113,18 +131,96 @@ type FormatSelection struct { // "248+251" // explicit pair (separate streams, will mux) // "22" // single progressive format (no mux needed) // "bv[height<=720]+ba" + // + // When Pref is non-zero the adaptive Pref.AdaptiveSpec() is used + // in preference to Spec — this is how the batch FormatAll policy + // makes one user pick work across videos that don't all share the + // same exact format IDs. Spec string // Container is forwarded as the value of `--merge-output-format`. // Ignored when Spec resolves to a single progressive format // (yt-dlp skips the merger in that path). Container string + + // Pref carries an adaptive description of the chosen quality + // (height ceiling, codec family hint, audio bitrate ceiling). + // Used by the batch pipeline so one pick on tape #1 maps cleanly + // onto tape #2's potentially-different format catalogue. Empty + // means "use Spec verbatim". + Pref FormatPreference +} + +// FormatPreference describes a quality target that adapts across +// different sources. Used when a single user choice has to apply to +// a queue of videos that won't all expose the same exact format IDs. +// +// The AdaptiveSpec() method translates these descriptors into a +// yt-dlp `-f` expression that uses filter syntax (`[height<=1080]`, +// `[vcodec~='^avc1']`, `[abr<=160]`) with progressively-loosened +// fallbacks so a missing-format error never aborts a tape. +type FormatPreference struct { + HeightCeiling int // max video height (0 = no cap) + FPSFloor int // prefer fps >= this (0 = any) + VCodec string // codec family hint, e.g. "avc1" / "vp9" / "av01" + ABRCeiling int // max audio bitrate kbps (0 = no cap) + Progressive bool // chose a progressive format on tape #1 +} + +// IsZero reports whether the preference carries any meaningful filter. +func (p FormatPreference) IsZero() bool { + return p.HeightCeiling == 0 && p.FPSFloor == 0 && p.VCodec == "" && p.ABRCeiling == 0 && !p.Progressive +} + +// AdaptiveSpec renders the preference as a yt-dlp -f expression with +// progressive fallbacks. Never returns an empty string — falls back +// to the universal "bv*+ba/b" default when no descriptors are set. +// +// Example output for { HeightCeiling: 1080, VCodec: "avc1", ABRCeiling: 160 }: +// +// bv[height<=1080][vcodec~='^avc1']+ba[abr<=160]/bv[height<=1080]+ba/bv+ba/b +// +// The chain works as: try exact match → drop codec filter → drop +// height filter → ultimate fallback. yt-dlp evaluates left-to-right +// and takes the first chain that resolves to real formats. +func (p FormatPreference) AdaptiveSpec() string { + if p.IsZero() { + return "bv*+ba/b" + } + if p.Progressive { + // Progressive: single format that already carries audio. + s := "b" + if p.HeightCeiling > 0 { + s += fmt.Sprintf("[height<=%d]", p.HeightCeiling) + } + return s + "/best" + } + // Build the tightest filter we can, then loosen for fallbacks. + v := "bv" + if p.HeightCeiling > 0 { + v += fmt.Sprintf("[height<=%d]", p.HeightCeiling) + } + vWithCodec := v + if p.VCodec != "" { + vWithCodec = v + fmt.Sprintf("[vcodec~='^%s']", p.VCodec) + } + a := "ba" + if p.ABRCeiling > 0 { + a += fmt.Sprintf("[abr<=%d]", p.ABRCeiling) + } + primary := vWithCodec + "+" + a + mid := v + "+ba" + return primary + "/" + mid + "/bv+ba/b" } // Args renders the selection as yt-dlp CLI fragments, applying defaults -// for empty fields. Always returns a non-empty slice. +// for empty fields. Pref.AdaptiveSpec() wins when set — that path is +// what survives cross-tape format variability in the batch pipeline. func (s FormatSelection) Args() []string { spec := s.Spec + if !s.Pref.IsZero() { + spec = s.Pref.AdaptiveSpec() + } if spec == "" { spec = "bv*+ba/b" } @@ -141,6 +237,42 @@ func (s FormatSelection) Args() []string { // a non-nil error aborts the pipeline before yt-dlp is spawned. type SelectorFunc func(ctx context.Context, meta Meta) (FormatSelection, error) +// QualityPreset builds a FormatSelection from a human-readable preset +// name ("720p", "1080p", "1440p", "4K", "8K", "best", "audio"). The +// container defaults to "mp4" when empty. Unknown preset names fall +// through to "best" with no height cap. +// +// This is what the hget CLI uses to skip the rocker browser entirely: +// the user says `--quality 1080p` and yt-dlp gets an adaptive filter +// expression that picks the best available stream at ≤ 1080p. +func QualityPreset(name, container string) FormatSelection { + if container == "" { + container = "mp4" + } + switch strings.ToLower(strings.TrimSpace(name)) { + case "", "best": + return FormatSelection{Container: container} + case "audio", "audio-only", "music": + return FormatSelection{Spec: "ba/bestaudio", Container: container} + case "360p": + return FormatSelection{Container: container, Pref: FormatPreference{HeightCeiling: 360}} + case "480p": + return FormatSelection{Container: container, Pref: FormatPreference{HeightCeiling: 480}} + case "720p": + return FormatSelection{Container: container, Pref: FormatPreference{HeightCeiling: 720}} + case "1080p": + return FormatSelection{Container: container, Pref: FormatPreference{HeightCeiling: 1080}} + case "1440p", "2k": + return FormatSelection{Container: container, Pref: FormatPreference{HeightCeiling: 1440}} + case "2160p", "4k", "uhd": + return FormatSelection{Container: container, Pref: FormatPreference{HeightCeiling: 2160}} + case "4320p", "8k": + return FormatSelection{Container: container, Pref: FormatPreference{HeightCeiling: 4320}} + default: + return FormatSelection{Container: container} + } +} + // DownloadProgress is the parsed state of one yt-dlp [download] line. type DownloadProgress struct { Percent float64 @@ -216,6 +348,7 @@ func Run(ctx context.Context, url, outDir string, opts Options, sel FormatSelect args = append(args, sel.Args()...) args = append(args, "-o", "%(title)s.%(ext)s") args = append(args, opts.authArgs()...) + args = append(args, opts.sortArgs()...) args = append(args, url) if outDir != "" { args = append([]string{"-P", outDir}, args...) diff --git a/internal/ui/cassette_shelf.go b/internal/ui/cassette_shelf.go new file mode 100644 index 0000000..35d44b4 --- /dev/null +++ b/internal/ui/cassette_shelf.go @@ -0,0 +1,1270 @@ +package ui + +import ( + "fmt" + "hash/fnv" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// CassetteShelf renders a horizontal row of VHS tapes — one per URL in +// a yt-dlp batch. Each cassette is drawn face-on (the way you'd see +// it on a video-rental shelf) with proper anatomy: chrome top/bottom +// edges, four corner screws, a white sticker label carrying the title, +// a transparent reel window with two rotating spools, a brand strip, +// and a footer chip with index / channel / runtime. +// +// At most one cassette is "active" — slightly lifted out of its slot +// (the animation), with chrome-bright borders, spinning reels and a +// pulsing status pill. Idle tapes render the same skeleton in dimmer +// colours so the shelf reads as a continuous library rather than a +// row of identical boxes. +// +// Visual budget is **always 8 rows** regardless of detail tier so the +// VCR panel below never reflows. When a cassette's detail tier drops +// (narrow terminal) we keep the chassis lines and pad the interior — +// the shelf height is fixed, not the content density. + +const ( + // cassetteShelfRows is the total widget height *excluding* the + // counter strip on top (added by View()). Layout: + // + // row 0..7 — cassette region (7-row tape body + 1 row of slot + // space that switches between top and bottom of the + // region depending on whether the cassette is lifted) + // row 8 — index numerals beneath each cassette + cassetteShelfRows = 9 + cassetteBodyRows = 7 // natural cassette height before lift positioning + + // Tier breakpoints, measured in per-cassette width (including border). + cassetteTierLushW = 14 // full anatomy: screws, label×2, info, reels, brand+footer + cassetteTierMediumW = 10 // title, chip+pill, reels, footer + cassetteTierCompactW = 6 // pill + reels + index/runtime +) + +// CassetteStatus is the lifecycle state of one tape on the shelf. +type CassetteStatus int + +const ( + CassetteQueued CassetteStatus = iota + CassetteProbing + CassetteReady // probe done, waiting for the deck to swing to it + CassetteLoading + CassettePlaying + CassetteMuxing + CassetteDone + CassetteSkipped + CassetteFailed +) + +// CassetteItem is one row of the shelf — everything renderable about +// a single URL in the batch. Field renames cascade nowhere: the shelf +// is the only consumer. +type CassetteItem struct { + URL string + Title string // resolved from yt-dlp metadata; empty until probed + Channel string // populates the channel-tint sticker chip + Duration time.Duration // 00:00 until probed + Resolution string // "1080p60" — only on lush tier + Status CassetteStatus + Err string // populated on failure for the end-of-batch summary +} + +// CassetteShelf is the widget. Built once at batch start, mutated via +// the setter methods below as the orchestrator advances. +type CassetteShelf struct { + items []CassetteItem + activeIx int // -1 before run, len(items) after + frame int + + // Animation springs — one per cassette so each can lift / settle + // independently when it transitions to active. + lift []float64 // current vertical offset (in cells, 0..1) +} + +// NewCassetteShelf seeds the widget from a URL list. Labels read as +// "tape NN" until probes resolve real titles. +func NewCassetteShelf(urls []string) *CassetteShelf { + items := make([]CassetteItem, len(urls)) + for i, u := range urls { + items[i] = CassetteItem{ + URL: u, + Status: CassetteQueued, + } + } + return &CassetteShelf{ + items: items, + activeIx: -1, + lift: make([]float64, len(urls)), + } +} + +// Len reports how many tapes are on the shelf. +func (s *CassetteShelf) Len() int { return len(s.items) } + +// Items returns a defensive copy so callers can iterate without racing +// concurrent mutations. Used by the end-of-batch summary. +func (s *CassetteShelf) Items() []CassetteItem { + out := make([]CassetteItem, len(s.items)) + copy(out, s.items) + return out +} + +// SetActive marks one cassette as the live tape. Pass -1 to indicate +// no tape is currently in the deck (between items, or post-batch). +func (s *CassetteShelf) SetActive(i int) { + if i < -1 || i >= len(s.items) { + return + } + s.activeIx = i +} + +// SetStatus updates one cassette's lifecycle state. errMsg is recorded +// only when the new status is CassetteFailed. +func (s *CassetteShelf) SetStatus(i int, st CassetteStatus, errMsg string) { + if i < 0 || i >= len(s.items) { + return + } + s.items[i].Status = st + if st == CassetteFailed { + s.items[i].Err = errMsg + } +} + +// SetMeta fills in metadata that arrives from yt-dlp's probe. Empty +// fields on the incoming item are ignored so partial probes don't blank +// out previously-set values. +func (s *CassetteShelf) SetMeta(i int, title, channel, resolution string, dur time.Duration) { + if i < 0 || i >= len(s.items) { + return + } + if title != "" { + s.items[i].Title = title + } + if channel != "" { + s.items[i].Channel = channel + } + if resolution != "" { + s.items[i].Resolution = resolution + } + if dur > 0 { + s.items[i].Duration = dur + } +} + +// Tick advances the lift animation for the active cassette and the +// reel-rotation frame counter. Drives both the "tape pulls out of +// the shelf" effect and the spinning-reel illusion. +func (s *CassetteShelf) Tick() { + s.frame++ + for i := range s.lift { + target := 0.0 + if i == s.activeIx { + target = 1.0 + } + // Asymmetric easing: lifts fast, settles slow. Matches the + // physical feel of a tape being yanked then re-shelved. + k := 0.18 + if s.lift[i] > target { + k = 0.10 + } + s.lift[i] += (target - s.lift[i]) * k + if absF(s.lift[i]-target) < 0.01 { + s.lift[i] = target + } + } +} + +// View renders the shelf at the requested terminal width. Returns at +// most cassetteShelfRows lines. The layout is computed every call so +// resizes (WindowSizeMsg) reflow without ceremony. +func (s *CassetteShelf) View(termWidth int) string { + if termWidth < 16 || len(s.items) == 0 { + return "" + } + layout := planShelfLayout(termWidth, len(s.items), s.activeIx) + rows := make([]strings.Builder, cassetteShelfRows) + + // Aggregate counter strip (above the cassettes). + counter := s.renderCounter(termWidth) + + pad := strings.Repeat(" ", layout.LeftPad) + + // Render cassettes. Cassette body height = cassetteShelfRows - 1 + // (last row reserved for the index strip beneath each spine). + cassetteRows := make([][]string, layout.Count) + tier := layout.Tier + cw := layout.CassetteW + for slot := 0; slot < layout.Count; slot++ { + ix := layout.SlotIndex[slot] + if ix == -1 { + cassetteRows[slot] = renderOverflowStub(cw, layout.OverflowText[slot]) + continue + } + active := ix == s.activeIx + liftCells := int(s.lift[ix] + 0.5) + cassetteRows[slot] = renderCassette(s.items[ix], ix, active, liftCells, cw, tier, s.frame) + } + + for r := 0; r < cassetteShelfRows-1; r++ { + rows[r].WriteString(pad) + for slot := 0; slot < layout.Count; slot++ { + if slot > 0 { + rows[r].WriteString(layout.Gap) + } + if r < len(cassetteRows[slot]) { + rows[r].WriteString(cassetteRows[slot][r]) + } else { + rows[r].WriteString(strings.Repeat(" ", cw)) + } + } + } + + // Last row: index numerals beneath each cassette (lush + medium + compact). + if tier != cassetteTierSkinny { + rows[cassetteShelfRows-1].WriteString(pad) + for slot := 0; slot < layout.Count; slot++ { + if slot > 0 { + rows[cassetteShelfRows-1].WriteString(layout.Gap) + } + rows[cassetteShelfRows-1].WriteString(renderIndexLabel(layout.SlotIndex[slot], layout.OverflowText[slot], cw, s.activeIx)) + } + } + + out := []string{counter} + for _, r := range rows { + out = append(out, r.String()) + } + return strings.Join(out, "\n") +} + +// renderCounter — single thin strip above the shelf summarising progress. +func (s *CassetteShelf) renderCounter(termWidth int) string { + tally := struct{ done, fail, skip, queue, active int }{} + for _, it := range s.items { + switch it.Status { + case CassetteDone: + tally.done++ + case CassetteFailed: + tally.fail++ + case CassetteSkipped: + tally.skip++ + case CassettePlaying, CassetteMuxing, CassetteLoading, CassetteReady, CassetteProbing: + tally.active++ + default: + tally.queue++ + } + } + cur := s.activeIx + 1 + if cur < 1 { + cur = 0 + } + steel := fgStyle(Theme.Steel) + mint := fgBoldStyle(Theme.Mint) + mag := fgBoldStyle(Theme.Magenta) + amber := fgBoldStyle(Theme.Amber) + phos := fgBoldStyle(Theme.Phosphor) + sep := steel.Render(" · ") + + parts := []string{ + phos.Render(fmt.Sprintf("⏵ %02d / %02d", cur, len(s.items))) + steel.Render(" tapes"), + } + if tally.done > 0 { + parts = append(parts, mint.Render(fmt.Sprintf("✓ %d done", tally.done))) + } + if tally.fail > 0 { + parts = append(parts, mag.Render(fmt.Sprintf("✗ %d failed", tally.fail))) + } + if tally.skip > 0 { + parts = append(parts, steel.Render(fmt.Sprintf("− %d skipped", tally.skip))) + } + if tally.queue > 0 { + parts = append(parts, amber.Render(fmt.Sprintf("⏸ %d queued", tally.queue))) + } + line := strings.Join(parts, sep) + w := lipgloss.Width(line) + pad := (termWidth - w) / 2 + if pad < 0 { + pad = 0 + } + return strings.Repeat(" ", pad) + line +} + +// ── Layout planner ────────────────────────────────────────────────────────── + +type cassetteTier int + +const ( + cassetteTierSkinny cassetteTier = iota + cassetteTierCompact + cassetteTierMedium + cassetteTierLush +) + +// shelfLayout describes the resolved geometry for one View() call. +type shelfLayout struct { + CassetteW int + Count int + Tier cassetteTier + Gap string + LeftPad int + SlotIndex []int // item index for each slot, or -1 for overflow stubs + OverflowText []string // for overflow stubs only — "+N more" / "+N done" +} + +// planShelfLayout picks the cassette width and tier that best fit the +// available terminal width, then computes the visible window centred +// on the active item. +func planShelfLayout(termWidth, nItems, activeIx int) shelfLayout { + avail := termWidth - 4 + if avail < 12 { + avail = 12 + } + + // Pick the largest cnt (capped at min(6, nItems)) whose per-cassette + // width clears the next non-skinny tier breakpoint. Showing more + // cassettes always beats showing one giant one, so we walk from + // many → few and stop at the first viable candidate. + maxCnt := nItems + if maxCnt > 6 { + maxCnt = 6 + } + if maxCnt < 1 { + maxCnt = 1 + } + bestCount := 1 + bestW := avail + bestGap := 2 + bestTier := cassetteTierSkinny + for cnt := maxCnt; cnt >= 1; cnt-- { + gap := 2 + w := (avail - gap*(cnt-1)) / cnt + if w < 4 { + continue + } + tier := cassetteTierForWidth(w) + if tier > cassetteTierSkinny || cnt == 1 { + bestCount = cnt + bestW = w + bestGap = gap + bestTier = tier + break + } + } + + used := bestW*bestCount + bestGap*(bestCount-1) + leftPad := (avail - used) / 2 + if leftPad < 0 { + leftPad = 0 + } + leftPad += 2 + + slotIx := make([]int, bestCount) + overflow := make([]string, bestCount) + start, end := windowAround(activeIx, bestCount, nItems) + doneHidden := start + moreHidden := nItems - end + for slot := 0; slot < bestCount; slot++ { + slotIx[slot] = start + slot + } + if doneHidden > 0 && bestCount >= 3 { + slotIx[0] = -1 + overflow[0] = fmt.Sprintf("+%d", doneHidden) + } + if moreHidden > 0 && bestCount >= 3 { + slotIx[bestCount-1] = -1 + overflow[bestCount-1] = fmt.Sprintf("+%d", moreHidden) + } + + return shelfLayout{ + CassetteW: bestW, + Count: bestCount, + Tier: bestTier, + Gap: strings.Repeat(" ", bestGap), + LeftPad: leftPad, + SlotIndex: slotIx, + OverflowText: overflow, + } +} + +func cassetteTierForWidth(w int) cassetteTier { + switch { + case w >= cassetteTierLushW: + return cassetteTierLush + case w >= cassetteTierMediumW: + return cassetteTierMedium + case w >= cassetteTierCompactW: + return cassetteTierCompact + default: + return cassetteTierSkinny + } +} + +func windowAround(active, slots, total int) (int, int) { + if total <= slots { + return 0, total + } + if active < 0 { + return 0, slots + } + half := slots / 2 + start := active - half + if start < 0 { + start = 0 + } + end := start + slots + if end > total { + end = total + start = end - slots + } + return start, end +} + +// ── Cassette rendering ────────────────────────────────────────────────────── + +// renderCassette returns exactly cassetteShelfRows-1 strings forming +// one cassette region. The cassette body itself is cassetteBodyRows +// tall; the extra row is a "slot space" that appears above (when the +// tape is at rest in its slot) or below (when the tape is lifted out). +// This way the lift animation never crops the cassette's borders. +func renderCassette(it CassetteItem, index int, active bool, liftCells, width int, tier cassetteTier, frame int) []string { + var body []string + switch tier { + case cassetteTierLush: + body = cassetteLushBody(it, index, active, frame, width) + case cassetteTierMedium: + body = cassetteMediumBody(it, index, active, frame, width) + case cassetteTierCompact: + body = cassetteCompactBody(it, index, active, frame, width) + default: + return cassetteSkinnyBody(it, index, active, liftCells, width, frame) + } + return positionInSlot(body, liftCells, width) +} + +// positionInSlot places a cassette body within its 8-row slot region. +// Lifted (liftCells > 0): body occupies the TOP of the slot, blank +// row sits at the bottom — visually the tape has been pulled UP out +// of its sleeve. At rest: body occupies the BOTTOM of the slot, blank +// row sits at the top — the tape is fully seated. +func positionInSlot(body []string, liftCells, width int) []string { + slotRows := cassetteShelfRows - 1 // 8 + blank := strings.Repeat(" ", width) + out := make([]string, slotRows) + if liftCells > 0 { + // Lifted: body at top. + for i := 0; i < slotRows; i++ { + if i < len(body) { + out[i] = body[i] + } else { + out[i] = blank + } + } + } else { + // At rest: body at bottom, blank above. + offset := slotRows - len(body) + if offset < 0 { + offset = 0 + } + for i := 0; i < slotRows; i++ { + if i < offset { + out[i] = blank + } else { + bi := i - offset + if bi < len(body) { + out[i] = body[bi] + } else { + out[i] = blank + } + } + } + } + return out +} + +// cassetteLushBody draws a recognisable VHS face — top edge with +// screws, white sticker label (2 lines), info row (chip + status pill +// + resolution badge), reel window, brand-strip-with-footer combined, +// bottom edge with screws. Exactly 7 rows. Width ≥ 14. +// +// ┌─□────────□─┐ +// │ Linux Kern │ ← sticker label line 1 +// │ 6.8 Releas │ ← sticker label line 2 +// │ ▓CHN▓ [▶] │ ← info row: chip + status + resolution +// │ ◜◠◝━━━━◞◡◟ │ ← reel window with magnetic tape strip +// │ TDK №03 432│ ← brand + index + runtime +// └─□────────□─┘ +func cassetteLushBody(it CassetteItem, index int, active bool, frame, w int) []string { + pal := resolveCassettePalette(it.Status, active, frame) + inner := w - 2 + if inner < 8 { + inner = 8 + } + edge := cassetteEdge(inner, pal.Border) + + // Title sticker: leading space + content + trailing pad, clamped + // to exactly `inner` cells so the cream background stops at the + // border instead of bleeding past it. + titleA, titleB := splitTitle(displayTitle(it, index), inner-2) + stickerStyle := lipgloss.NewStyle().Foreground(pal.LabelFG).Background(pal.LabelBG) + lineA := stickerStyle.Render(clampWidth(" "+titleA, inner)) + lineB := stickerStyle.Render(clampWidth(" "+titleB, inner)) + + pill := statusPill(it.Status, active, frame) + chip := channelChip(it.Channel) + res := resolutionBadge(it.Resolution, pal.Dim) + // Pill first (status is non-negotiable), then chip, then resolution. + // infoRow drops trailing parts that don't fit. + chipRow := infoRow(inner, pill, chip, res) + + reels := renderReels(it.Status, active, frame, inner) + + // Combined brand + footer row. Brand chip sits flush left, the + // index/runtime sit flush right. Dropped fields when budget tight. + footer := lushFooterRow(it, index, inner, pal) + + side := pal.Border.Render("│") + return []string{ + edge.top, + side + lineA + side, + side + lineB + side, + side + chipRow + side, + side + reels + side, + side + footer + side, + edge.bottom, + } +} + +// cassetteMediumBody — same chassis, one title line, chip + pill, reels, +// runtime footer. Exactly 7 rows. Width 10..13. +// +// ┌─□──────□─┐ +// │ Linux Kr │ ← title sticker (1 line) +// │ ▓CHN▓ [▶]│ ← chip + status +// │ ◜◠◝──◞◡◟│ ← reels +// │ ▒ TDK ▒ │ ← brand strip +// │№03·04:32 │ ← runtime footer +// └─□──────□─┘ +func cassetteMediumBody(it CassetteItem, index int, active bool, frame, w int) []string { + pal := resolveCassettePalette(it.Status, active, frame) + inner := w - 2 + if inner < 6 { + inner = 6 + } + edge := cassetteEdge(inner, pal.Border) + + titleLine, _ := splitTitle(displayTitle(it, index), inner-2) + stickerStyle := lipgloss.NewStyle().Foreground(pal.LabelFG).Background(pal.LabelBG) + titleRow := stickerStyle.Render(clampWidth(" "+titleLine, inner)) + + pill := statusPill(it.Status, active, frame) + chip := channelChip(it.Channel) + res := resolutionBadge(it.Resolution, pal.Dim) + chipRow := infoRow(inner, pill, chip, res) + + reels := renderReels(it.Status, active, frame, inner) + brand := brandStrip(it.URL, inner) + footer := mediumFooterRow(it, index, inner, pal) + + side := pal.Border.Render("│") + return []string{ + edge.top, + side + titleRow + side, + side + chipRow + side, + side + reels + side, + side + brand + side, + side + footer + side, + edge.bottom, + } +} + +// cassetteCompactBody — minimal but still recognisably a tape. width +// 6..9. Drops the title sticker entirely; the cassette face shows the +// reels + status pill + index/runtime stamp. Exactly 7 rows. +// +// ┌─□──□─┐ +// │ [▶] │ +// │ │ +// │◜◠◝◞◡◟│ +// │ │ +// │№03·4:32│ +// └─□──□─┘ +func cassetteCompactBody(it CassetteItem, index int, active bool, frame, w int) []string { + pal := resolveCassettePalette(it.Status, active, frame) + inner := w - 2 + if inner < 4 { + inner = 4 + } + edge := cassetteEdge(inner, pal.Border) + + pill := statusPill(it.Status, active, frame) + pillRow := centreCell(pill, inner) + + reels := renderReels(it.Status, active, frame, inner) + footer := compactFooterRow(it, index, inner, pal) + + side := pal.Border.Render("│") + return []string{ + edge.top, + side + pillRow + side, + side + padInner("", inner) + side, + side + reels + side, + side + padInner("", inner) + side, + side + footer + side, + edge.bottom, + } +} + +// cassetteSkinnyBody — no border, just status glyph + reel column + +// index numeral. Used at very narrow widths where any drawn chassis +// would consume the whole budget. Returns slot-positioned 8 rows. +func cassetteSkinnyBody(it CassetteItem, index int, active bool, liftCells, w, frame int) []string { + pal := resolveCassettePalette(it.Status, active, frame) + pill := statusPill(it.Status, active, frame) + num := pal.Dim.Render(fmt.Sprintf("%02d", index+1)) + body := []string{ + centreCell(pill, w), + "", + centreCell(pal.Body.Render("┃"), w), + centreCell(pal.Body.Render("┃"), w), + "", + centreCell(num, w), + "", + } + return positionInSlot(body, liftCells, w) +} + +// ── Component pieces ──────────────────────────────────────────────────────── + +type cassetteEdges struct{ top, bottom string } + +// cassetteEdge — top + bottom chrome strips with screw holes at the corners. +// On narrow cassettes the screws sit closer to the corners; on wide ones +// they're indented two cells in. +func cassetteEdge(inner int, border lipgloss.Style) cassetteEdges { + // Inner edge: dash run with screw glyphs (□) at fixed positions. + if inner < 4 { + bar := strings.Repeat("─", inner) + return cassetteEdges{ + top: border.Render("┌" + bar + "┐"), + bottom: border.Render("└" + bar + "┘"), + } + } + mid := strings.Repeat("─", inner-4) + top := border.Render("┌─□" + mid + "□─┐") + bottom := border.Render("└─□" + mid + "□─┘") + return cassetteEdges{top: top, bottom: bottom} +} + +// renderReels — the iconic VHS reel pair, framed by the cassette's +// transparent window. Real VHS reels sit roughly a third of the way +// in from each side, not edge-to-edge — so we cap the magnetic-tape +// strip between them at ~half the cassette inner width and centre +// the whole group, leaving padded "window glass" on either side. +// +// When the tape is active, the reels spin (take-up slightly faster +// than supply). Done tapes settle to a hub glyph; failed tapes get +// broken-spoke X's. +func renderReels(st CassetteStatus, active bool, frame, inner int) string { + body := fgStyle(Theme.Frost) + if !active { + body = fgStyle(Theme.Steel) + } + switch st { + case CassetteFailed: + body = fgBoldStyle(Theme.Magenta) + case CassetteDone: + body = fgBoldStyle(Theme.Mint) + case CassetteSkipped: + body = fgStyle(Theme.Slate) + } + + // Branch on available width. Each reel glyph is 3 cells wide, so + // we need ≥ 6 inner cells just to fit them edge-to-edge. Below + // that we collapse to single-cell hub glyphs. + if inner >= 6 { + left, right := reelFrames(st, active, frame) + // Magnetic-tape strip between the reels. Capped so wide + // cassettes don't stretch the reels apart. + tapeLen := inner - 6 // 6 = two 3-cell reels + if tapeLen < 1 { + tapeLen = 1 + } + if tapeLen > 14 { + tapeLen = 14 + } + if active && (st == CassettePlaying || st == CassetteMuxing) { + shades := []string{"━", "─"} + var sb strings.Builder + for i := 0; i < tapeLen; i++ { + sb.WriteString(shades[(i+frame/3)%len(shades)]) + } + mag := fgStyle(Theme.Magenta).Render(sb.String()) + core := body.Render(left) + mag + body.Render(right) + return centreCell(core, inner) + } + tape := fgStyle(Theme.Slate).Render(strings.Repeat("─", tapeLen)) + core := body.Render(left) + tape + body.Render(right) + return centreCell(core, inner) + } + + // Narrow: 1-char hub glyphs with a thin tape strip. + hub := reelHubGlyph(st, active, frame) + tapeLen := inner - 2 + if tapeLen < 1 { + tapeLen = 1 + } + tape := fgStyle(Theme.Slate).Render(strings.Repeat("─", tapeLen)) + core := body.Render(hub) + tape + body.Render(hub) + return centreCell(core, inner) +} + +// reelHubGlyph picks a single-cell reel glyph used at very narrow +// cassette widths where the full 3-cell reel pair won't fit. +func reelHubGlyph(st CassetteStatus, active bool, frame int) string { + switch st { + case CassetteFailed: + return "╳" + case CassetteDone: + return "◉" + case CassetteSkipped: + return "◯" + } + if active && (st == CassettePlaying || st == CassetteMuxing || st == CassetteLoading) { + frames := []string{"◐", "◓", "◑", "◒"} + return frames[(frame/4)%4] + } + return "◯" +} + +// reelFrames picks the two reel glyphs based on lifecycle + frame. +// Active tapes get rotating frames at slightly different speeds to +// sell the illusion of magnetic tape transport. +func reelFrames(st CassetteStatus, active bool, frame int) (string, string) { + frames := []string{"◜◠◝", "◝◠◞", "◞◡◟", "◟◡◜"} + switch st { + case CassetteFailed: + return "╳·╳", "╳·╳" + case CassetteDone: + return "◉◠◉", "◉◠◉" + case CassetteSkipped: + return "◯◯◯", "◯◯◯" + } + if active && (st == CassettePlaying || st == CassetteMuxing || st == CassetteLoading) { + l := frames[(frame/4)%4] + r := frames[(frame/3)%4] + return l, r + } + if st == CassetteProbing || st == CassetteReady { + // Gentle idle wobble — half speed, single phase. + f := frames[(frame/14)%4] + return f, f + } + return "◯◯◯", "◯◯◯" +} + +// statusPill — bracketed status readout like a tiny LCD on the cassette. +func statusPill(st CassetteStatus, active bool, frame int) string { + glyph, col := cassetteStatusGlyph(st, frame) + if active && (st == CassettePlaying || st == CassetteMuxing) { + // Pulsing background pill — chassis "RECORDING" indicator. + bg := Theme.Magenta + if (frame/10)%2 == 0 { + bg = Theme.Phosphor + } + return lipgloss.NewStyle(). + Foreground(Theme.Frost). + Background(bg). + Bold(true). + Render(" " + glyph + " ") + } + pillStyle := lipgloss.NewStyle(). + Foreground(col). + Bold(true) + return pillStyle.Render("[" + glyph + "]") +} + +// brandStrip — bottom-of-cassette brand chip. Picks the longest +// brand variant that fits the inner width, falling back to the short +// brandLabel on narrow cassettes, and finally to a plain chevron +// pair when even that won't fit. +func brandStrip(url string, inner int) string { + long := []string{ + "TDK·SHG", "MAXELL XL", "SONY E180", "BASF·E", + "JVC·SHG", "FUJI·H", "AMPEX 196", "HGET·VHS", + } + h := fnv.New32a() + _, _ = h.Write([]byte(url)) + pickLong := long[int(h.Sum32())%len(long)] + pickShort := brandLabel(url) + steel := fgStyle(Theme.Steel) + slate := fgStyle(Theme.Slate) + chevrons := slate.Render("▒") + tryBrand := func(name string) (string, bool) { + core := chevrons + " " + steel.Render(name) + " " + chevrons + w := lipgloss.Width(core) + if w <= inner { + return centreCell(core, inner), true + } + return "", false + } + if s, ok := tryBrand(pickLong); ok { + return s + } + if s, ok := tryBrand(pickShort); ok { + return s + } + // Fallback: just chevrons. + return centreCell(slate.Render("▒ ▒"), inner) +} + +// lushFooterRow — left-anchored brand strip + right-anchored index/runtime. +// Picks the widest layout that fits the cassette inner width. +func lushFooterRow(it CassetteItem, index, inner int, pal cassettePalette) string { + idx := fmt.Sprintf("№ %02d", index+1) + rt := formatHMSShort(it.Duration) + right := pal.Dim.Render(idx) + pal.Body.Render(" · ") + pal.Dim.Render(rt) + rw := lipgloss.Width(right) + left := pal.Dim.Render("▒ ") + pal.Body.Render(brandLabel(it.URL)) + pal.Dim.Render(" ▒") + lw := lipgloss.Width(left) + // Need at least 2 cells (leading + trailing space) + a 1-cell gap. + if lw+rw+3 <= inner { + gap := inner - lw - rw - 2 + if gap < 1 { + gap = 1 + } + return " " + left + strings.Repeat(" ", gap) + right + " " + } + // Fallback: just centre the index/runtime. + return centreCell(right, inner) +} + +// mediumFooterRow — index + runtime, dropping resolution when tight. +func mediumFooterRow(it CassetteItem, index, inner int, pal cassettePalette) string { + idx := fmt.Sprintf("№%02d", index+1) + rt := formatHMSShort(it.Duration) + core := pal.Dim.Render(idx) + pal.Body.Render(" ") + pal.Dim.Render(rt) + if lipgloss.Width(core) > inner { + core = pal.Dim.Render(rt) + } + if lipgloss.Width(core) > inner { + core = pal.Dim.Render(idx) + } + return centreCell(core, inner) +} + +// compactFooterRow — index OR runtime, whichever fits. +func compactFooterRow(it CassetteItem, index, inner int, pal cassettePalette) string { + idx := fmt.Sprintf("№%02d", index+1) + rt := formatHMSShort(it.Duration) + if lipgloss.Width(idx)+lipgloss.Width(rt)+1 <= inner { + return centreCell(pal.Dim.Render(idx+" "+rt), inner) + } + if lipgloss.Width(rt) <= inner { + return centreCell(pal.Dim.Render(rt), inner) + } + return centreCell(pal.Dim.Render(idx), inner) +} + +// brandLabel returns the brand string used in the footer strip. +// Deterministic per-URL so the same video always wears the same brand. +func brandLabel(url string) string { + brands := []string{"TDK", "MAXELL", "SONY", "BASF", "JVC", "FUJI", "AMPEX", "HGET"} + h := fnv.New32a() + _, _ = h.Write([]byte(url)) + return brands[int(h.Sum32())%len(brands)] +} + +// infoRow lays out one or more rendered fragments on a single +// cassette-interior row, left-anchored with 2-space gaps. When the +// combined width would exceed `inner`, later fragments are silently +// dropped (callers therefore pass the most important fragment first). +// Always padded to exactly `inner` cells so the chassis stays aligned. +func infoRow(inner int, parts ...string) string { + var keep []string + used := 1 // leading space + for _, p := range parts { + w := lipgloss.Width(p) + if w == 0 { + continue + } + sep := 0 + if len(keep) > 0 { + sep = 2 + } + if used+sep+w > inner { + continue + } + keep = append(keep, p) + used += sep + w + } + line := " " + strings.Join(keep, " ") + pad := inner - lipgloss.Width(line) + if pad < 0 { + return line + } + return line + strings.Repeat(" ", pad) +} + +// resolutionBadge — small inset chip showing "1080p", "4K", "720p" +// etc. Hidden when no resolution is known. +func resolutionBadge(resolution string, dim lipgloss.Style) string { + if resolution == "" { + return "" + } + compact := compactResolution(resolution) + return lipgloss.NewStyle(). + Foreground(Theme.Frost). + Background(Theme.Slate). + Render(" " + compact + " ") +} + +// compactResolution turns "1920x1080" into "1080p", "3840x2160" into +// "4K", etc. Standard ladder; falls back to the raw string when +// the input isn't a recognised resolution. +func compactResolution(s string) string { + if s == "" { + return "" + } + parts := strings.Split(s, "x") + if len(parts) != 2 { + return s + } + var h int + _, err := fmt.Sscanf(parts[1], "%d", &h) + if err != nil { + return s + } + switch { + case h >= 4320: + return "8K" + case h >= 2160: + return "4K" + case h >= 1440: + return "1440p" + case h >= 1080: + return "1080p" + case h >= 720: + return "720p" + case h >= 480: + return "480p" + case h >= 360: + return "360p" + default: + return fmt.Sprintf("%dp", h) + } +} + +// renderOverflowStub — the "+N" filler that takes one slot when the +// shelf can't fit every cassette. Rendered as a stack of dim +// chevrons so it doesn't compete with real cassettes for attention. +func renderOverflowStub(width int, text string) []string { + dim := fgStyle(Theme.Slate) + steel := fgStyle(Theme.Steel) + rows := make([]string, cassetteShelfRows-1) + for i := range rows { + rows[i] = strings.Repeat(" ", width) + } + mid := (cassetteShelfRows - 1) / 2 + if width >= 4 { + rows[mid-1] = centreCell(dim.Render("┃"), width) + rows[mid] = centreCell(steel.Render(text), width) + rows[mid+1] = centreCell(dim.Render("┃"), width) + rows[mid+2] = centreCell(dim.Render("·"), width) + } + return rows +} + +func renderIndexLabel(ix int, overflow string, width, activeIx int) string { + if ix == -1 { + return centreCell(fgStyle(Theme.Slate).Render(overflow), width) + } + label := fmt.Sprintf("%02d", ix+1) + if ix == activeIx { + return centreCell(fgBoldStyle(Theme.Magenta).Render("▶ "+label), width) + } + return centreCell(fgStyle(Theme.Steel).Render(label), width) +} + +// channelChip — small coloured rectangle hashed from channel name. +// 6 distinct hues keyed so the same channel always lands on the same +// colour across runs (the library is "organised"). +func channelChip(channel string) string { + palette := []lipgloss.Color{ + Theme.Magenta, Theme.Amber, Theme.Mint, + Theme.Phosphor, Theme.DeepCyan, Theme.Frost, + } + if channel == "" { + return fgStyle(Theme.Slate).Render("░·░") + } + h := fnv.New32a() + _, _ = h.Write([]byte(channel)) + col := palette[int(h.Sum32())%len(palette)] + abbr := channelAbbr(channel) + return lipgloss.NewStyle().Foreground(Theme.Frost).Background(col).Bold(true).Render(" " + abbr + " ") +} + +func channelAbbr(s string) string { + r := []rune(strings.ToUpper(strings.TrimSpace(s))) + if len(r) == 0 { + return "···" + } + if len(r) >= 3 { + return string(r[:3]) + } + for len(r) < 3 { + r = append(r, '·') + } + return string(r) +} + +// cassetteStatusGlyph picks the lifecycle glyph + its base colour. +func cassetteStatusGlyph(st CassetteStatus, frame int) (string, lipgloss.Color) { + switch st { + case CassetteQueued: + return "⏸", Theme.Steel + case CassetteProbing: + frames := []string{"◌", "◍", "◎", "●"} + return frames[(frame/8)%4], Theme.Amber + case CassetteReady: + return "◐", Theme.Amber + case CassetteLoading: + frames := []string{"◐", "◓", "◑", "◒"} + return frames[(frame/6)%4], Theme.Amber + case CassettePlaying: + return "▶", Theme.Magenta + case CassetteMuxing: + return "✦", Theme.Phosphor + case CassetteDone: + return "✓", Theme.Mint + case CassetteSkipped: + return "−", Theme.Slate + case CassetteFailed: + return "✗", Theme.Magenta + } + return " ", Theme.Steel +} + +// cassettePalette resolves the colour bundle used to render one +// cassette in a given state. Active tapes always use a chrome-bright +// border + cream label so they pop off the shelf regardless of status. +type cassettePalette struct { + Border lipgloss.Style + Body lipgloss.Style + BodyBG lipgloss.Style + Dim lipgloss.Style + LabelFG lipgloss.Color + LabelBG lipgloss.Color +} + +func resolveCassettePalette(st CassetteStatus, active bool, frame int) cassettePalette { + // Sticker label colours — cream over a darker cardboard tint so it + // reads as a real handwritten library label even when the tape is + // idle. Active tapes brighten the cream toward white. + labelFG := Theme.Steel + labelBG := lipgloss.Color("#1A1F2E") + switch st { + case CassetteDone: + labelFG = Theme.Mint + labelBG = lipgloss.Color("#0E2018") + case CassetteFailed: + labelFG = Theme.Magenta + labelBG = lipgloss.Color("#2A1218") + case CassetteSkipped: + labelFG = Theme.Slate + case CassetteReady, CassetteLoading, CassetteProbing: + labelFG = Theme.Amber + case CassettePlaying, CassetteMuxing: + labelFG = Theme.Frost + labelBG = lipgloss.Color("#2A1218") + } + + pal := cassettePalette{ + Border: fgStyle(Theme.Steel), + Body: fgStyle(Theme.Steel), + Dim: fgStyle(Theme.Slate), + LabelFG: labelFG, + LabelBG: labelBG, + BodyBG: lipgloss.NewStyle(), + } + switch st { + case CassetteDone: + pal.Border = fgStyle(Theme.Mint) + pal.Body = fgStyle(Theme.Mint) + case CassetteFailed: + pal.Border = fgStyle(Theme.Magenta) + pal.Body = fgBoldStyle(Theme.Magenta) + case CassetteSkipped: + pal.Border = fgStyle(Theme.Slate) + pal.Body = fgStyle(Theme.Slate) + pal.Dim = fgStyle(Theme.Slate) + case CassettePlaying, CassetteMuxing: + pal.Border = fgBoldStyle(Theme.Magenta) + pal.Body = fgBoldStyle(Theme.Frost) + case CassetteReady, CassetteLoading, CassetteProbing: + pal.Border = fgStyle(Theme.Amber) + pal.Body = fgStyle(Theme.Amber) + } + if active { + pal.Border = fgBoldStyle(Theme.Frost) + // Pulse the label background a touch toward magenta on the + // active tape — reads like the "REC" LED bleeding onto the + // label from inside the deck. + if (frame/12)%2 == 0 { + pal.LabelBG = lipgloss.Color("#2A1218") + } + } + return pal +} + +// ── Tiny helpers ──────────────────────────────────────────────────────────── + +func padInner(s string, w int) string { + gap := w - lipgloss.Width(s) + if gap < 0 { + return s + } + return s + strings.Repeat(" ", gap) +} + +// clampWidth pads a string to exactly w cells OR truncates it with an +// ellipsis when too long. Unlike padInner, content longer than w +// gets shortened — used for the sticker label so background styling +// never extends beyond the cassette border. +func clampWidth(s string, w int) string { + cw := lipgloss.Width(s) + if cw == w { + return s + } + if cw < w { + return s + strings.Repeat(" ", w-cw) + } + return truncate(s, w) +} + +func centreCell(s string, w int) string { + gap := w - lipgloss.Width(s) + if gap <= 0 { + return s + } + l := gap / 2 + r := gap - l + return strings.Repeat(" ", l) + s + strings.Repeat(" ", r) +} + +func splitTitle(title string, maxWidth int) (string, string) { + if title == "" || maxWidth <= 0 { + return "", "" + } + if len(title) <= maxWidth { + return title, "" + } + words := strings.Fields(title) + var a, b strings.Builder + spilled := false + for _, w := range words { + if !spilled { + if a.Len() == 0 { + a.WriteString(w) + continue + } + if a.Len()+1+len(w) <= maxWidth { + a.WriteByte(' ') + a.WriteString(w) + continue + } + spilled = true + } + if b.Len() == 0 { + b.WriteString(w) + continue + } + if b.Len()+1+len(w) <= maxWidth { + b.WriteByte(' ') + b.WriteString(w) + } + } + la := a.String() + lb := b.String() + if lb == "" && len(la) > maxWidth { + lb = la[maxWidth:] + la = la[:maxWidth] + if len(lb) > maxWidth { + lb = lb[:maxWidth-1] + "…" + } + } + if len(la) > maxWidth { + la = truncate(la, maxWidth) + } + if len(lb) > maxWidth { + lb = truncate(lb, maxWidth) + } + return la, lb +} + +// displayTitle returns the title to print on the cassette's sticker +// label. Falls back to a placeholder when the probe hasn't landed. +func displayTitle(it CassetteItem, index int) string { + if it.Title != "" { + return it.Title + } + switch it.Status { + case CassetteProbing: + return "scanning…" + case CassetteFailed: + return "probe failed" + } + return fmt.Sprintf("tape %02d", index+1) +} + +func formatHMSShort(d time.Duration) string { + if d <= 0 { + return "--:--" + } + d = d.Round(time.Second) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + d -= m * time.Minute + s := d / time.Second + if h > 0 { + return fmt.Sprintf("%d:%02d:%02d", h, m, s) + } + return fmt.Sprintf("%02d:%02d", m, s) +} + +// applyLift shifts every row up by liftCells, padding the bottom with +// blank rows so the cassette occupies exactly `height` lines regardless +// of how high it's lifted. +func applyLift(rows []string, liftCells, height, width int) []string { + if liftCells <= 0 { + if len(rows) >= height { + return rows[:height] + } + blank := strings.Repeat(" ", width) + for len(rows) < height { + rows = append(rows, blank) + } + return rows + } + blank := strings.Repeat(" ", width) + out := make([]string, height) + for i := 0; i < height; i++ { + src := i + liftCells + if src < len(rows) { + out[i] = rows[src] + } else { + out[i] = blank + } + } + return out +} + +func absF(f float64) float64 { + if f < 0 { + return -f + } + return f +} diff --git a/internal/ui/cassette_shelf_test.go b/internal/ui/cassette_shelf_test.go new file mode 100644 index 0000000..498099e --- /dev/null +++ b/internal/ui/cassette_shelf_test.go @@ -0,0 +1,124 @@ +package ui + +import ( + "strings" + "testing" + "time" +) + +func TestCassetteShelf_SeedsPlaceholders(t *testing.T) { + s := NewCassetteShelf([]string{ + "https://youtu.be/aaa", + "https://youtu.be/bbb", + "https://youtu.be/ccc", + }) + if s.Len() != 3 { + t.Fatalf("Len=%d want 3", s.Len()) + } + out := stripANSI(s.View(100)) + for _, want := range []string{"tape 01", "tape 02", "tape 03", "⏵ 00 / 03"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in seed view:\n%s", want, out) + } + } +} + +func TestCassetteShelf_MetaPopulatesSpine(t *testing.T) { + s := NewCassetteShelf([]string{"u1", "u2"}) + s.SetMeta(0, "Linux Kernel 6.8", "ThePrimeagen", "1920x1080", 4*time.Minute+32*time.Second) + out := stripANSI(s.View(120)) + mustContain(t, out, "Linux", "title not rendered on lush tier") + mustContain(t, out, "1080p", "compact resolution missing") + mustContain(t, out, "04:32", "runtime stamp missing") + mustContain(t, out, "THE", "channel chip abbreviation missing") +} + +func TestCassetteShelf_StatusTallyCounter(t *testing.T) { + s := NewCassetteShelf([]string{"a", "b", "c", "d"}) + s.SetStatus(0, CassetteDone, "") + s.SetStatus(1, CassetteFailed, "boom") + s.SetStatus(2, CassetteSkipped, "") + s.SetActive(3) + out := stripANSI(s.View(120)) + mustContain(t, out, "✓ 1 done", "done tally missing") + mustContain(t, out, "✗ 1 failed", "failed tally missing") + mustContain(t, out, "− 1 skipped", "skipped tally missing") + mustContain(t, out, "⏵ 04 / 04", "active counter missing") +} + +func TestCassetteShelf_ActiveLifts(t *testing.T) { + s := NewCassetteShelf([]string{"a", "b"}) + s.SetActive(1) + // Run enough ticks for the spring to saturate. + for i := 0; i < 30; i++ { + s.Tick() + } + if s.lift[1] < 0.9 { + t.Errorf("active cassette didn't lift; lift=%.2f", s.lift[1]) + } + if s.lift[0] > 0.1 { + t.Errorf("inactive cassette lifted; lift=%.2f", s.lift[0]) + } +} + +func TestCassetteShelf_AdaptiveTiers(t *testing.T) { + urls := []string{"a", "b", "c", "d", "e", "f"} + s := NewCassetteShelf(urls) + for i := range urls { + s.SetMeta(i, "Some Long Title For Tape", "Channel", "1080p", 60*time.Second) + } + + // Wide terminal — should hit lush tier (channel chip visible). + wide := stripANSI(s.View(200)) + if !strings.Contains(wide, "CHA") { + t.Errorf("wide terminal should show channel chip:\n%s", wide) + } + + // Mid-width terminal — the planner may keep lush tier but with + // fewer cassettes visible. Either way the active should still be + // rendered and the counter strip stays intact. + mid := stripANSI(s.View(50)) + mustContain(t, mid, "⏵", "counter strip dropped at mid width") + + // Narrow terminal forces a lower tier. At width 30 with 6 items, + // the planner reduces to ~2 cassettes at compact tier — channel + // chip is dropped, status pill remains. Whether the № index + // marker fits depends on the runtime label width (compactFooter + // degrades to "MM:SS" when both don't fit) so we just confirm + // the status pill survived. + narrow := stripANSI(s.View(30)) + if strings.Contains(narrow, "CHA") { + t.Errorf("narrow terminal still rendering channel chip:\n%s", narrow) + } + mustContain(t, narrow, "[⏸]", "compact tier missing status pill") + + // Very narrow terminal — degrades cleanly without panicking. + if got := s.View(18); got == "" { + t.Skip("tiny terminal returned empty view (below minimum width)") + } +} + +func TestCassetteShelf_WindowsAroundActive(t *testing.T) { + urls := make([]string, 20) + for i := range urls { + urls[i] = "u" + } + s := NewCassetteShelf(urls) + s.SetActive(10) + out := stripANSI(s.View(80)) + // The active index numeral must be present. + if !strings.Contains(out, "11") { + t.Errorf("active index 11 not visible:\n%s", out) + } + // Far-away indices (01, 20) should be collapsed into overflow stubs. + if strings.Contains(out, " 01 ") { + t.Errorf("index 01 visible despite being far from active:\n%s", out) + } +} + +func TestSplitTitle_WrapsAtWordBoundary(t *testing.T) { + a, b := splitTitle("Linux Kernel 6.8 Released", 10) + if a != "Linux" || !strings.HasPrefix(b, "Kernel") { + t.Errorf("splitTitle wrap: a=%q b=%q", a, b) + } +} diff --git a/internal/ui/extractor_tui.go b/internal/ui/extractor_tui.go index a2fd966..055b603 100644 --- a/internal/ui/extractor_tui.go +++ b/internal/ui/extractor_tui.go @@ -81,11 +81,58 @@ type ExtractorFormatsMsg struct { // ExtractorSelectionMsg is internal — the model emits it when the user // commits a format choice in browsing mode. The runner forwards the // payload to the worker goroutine via a buffered channel. +// +// In addition to the exact format spec, the message carries adaptive +// descriptors (height ceiling, codec hint, audio bitrate ceiling). +// The batch pipeline uses those to build a yt-dlp filter expression +// that survives videos that lack the originally-picked format IDs. type ExtractorSelectionMsg struct { Spec string Container string + + HeightCeiling int + FPSFloor int + VCodec string + ABRCeiling int + Progressive bool +} + +// ExtractorShelfSeedMsg installs the cassette shelf at batch start. +// One CassetteItem per URL. Sent before any per-tape activity so the +// shelf is visible from the first frame. +type ExtractorShelfSeedMsg struct { + URLs []string +} + +// ExtractorShelfMetaMsg fills in the metadata for one cassette as +// probes resolve. Fired by the eager probe pool so spine labels +// populate ahead of the deck reaching that tape. +type ExtractorShelfMetaMsg struct { + Index int + Title string + Channel string + Resolution string + Duration time.Duration +} + +// ExtractorShelfStatusMsg updates one cassette's lifecycle state. +type ExtractorShelfStatusMsg struct { + Index int + Status CassetteStatus + Err string +} + +// ExtractorShelfActiveMsg marks one cassette as the currently-playing +// tape. Pass Index=-1 between items or at end-of-batch. +type ExtractorShelfActiveMsg struct { + Index int } +// ExtractorResetDeckMsg clears the deck (progress, output path, logs) +// between batch items so the next tape starts fresh. The VCR returns +// to standby until the next ExtractorMetaMsg arrives. +type ExtractorResetDeckMsg struct{} + // extractorTickMsg drives animations. type extractorTickMsg time.Time @@ -117,6 +164,11 @@ type extractorModel struct { onQuit func() onSelection func(ExtractorSelectionMsg) // wired by RunExtractorTUI startT time.Time + + // Batch-mode cassette shelf. Nil for single-URL extractor runs; + // non-nil once ExtractorShelfSeedMsg arrives, rendered above the + // VCR for the lifetime of the program. + shelf *CassetteShelf } // NewExtractorModel constructs the extractor-mode TUI model. @@ -144,6 +196,11 @@ func (m *extractorModel) SetSelectionCallback(f func(ExtractorSelectionMsg)) { m.onSelection = f } +// ExtractorURLMsg updates the "source" line shown above the shelf when +// the active tape changes mid-batch. In single-URL mode the URL is +// fixed at construction time and this message is never sent. +type ExtractorURLMsg struct{ URL string } + func (m extractorModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, extractorTickCmd()) } @@ -186,13 +243,13 @@ func (m extractorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.vcr.CycleContainer(-1) return m, nil case "enter", "r", "R": - spec, cont := m.vcr.Selection() + sel := m.vcr.Selection() m.hasSelection = true m.browsing = false m.phase = "downloading" m.vcr.SetMode(VCRRecording) if m.onSelection != nil { - m.onSelection(ExtractorSelectionMsg{Spec: spec, Container: cont}) + m.onSelection(sel) } return m, nil } @@ -217,8 +274,49 @@ func (m extractorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case extractorTickMsg: m.vcr.Tick() m.mixer.Tick() + if m.shelf != nil { + m.shelf.Tick() + } return m, extractorTickCmd() + case ExtractorShelfSeedMsg: + m.shelf = NewCassetteShelf(msg.URLs) + return m, nil + + case ExtractorShelfMetaMsg: + if m.shelf != nil { + m.shelf.SetMeta(msg.Index, msg.Title, msg.Channel, msg.Resolution, msg.Duration) + } + return m, nil + + case ExtractorShelfStatusMsg: + if m.shelf != nil { + m.shelf.SetStatus(msg.Index, msg.Status, msg.Err) + } + return m, nil + + case ExtractorShelfActiveMsg: + if m.shelf != nil { + m.shelf.SetActive(msg.Index) + } + return m, nil + + case ExtractorURLMsg: + m.url = msg.URL + return m, nil + + case ExtractorResetDeckMsg: + // Reset VCR + mixer + logs so the next tape starts clean. We + // don't touch the shelf — it tracks the whole batch. + m.vcr = NewVCR() + m.mixer = NewMixer() + m.outputPath = "" + m.meta = ExtractorMetaMsg{} + m.browsing = false + m.hasSelection = false + m.phase = "probing" + return m, nil + case ExtractorMetaMsg: m.meta = msg m.vcr.SetMeta(VCRMeta{ @@ -346,6 +444,14 @@ func (m extractorModel) View() string { b.WriteString("\n") + // Cassette shelf (batch mode only). The shelf is the queue's + // "video library wall" — sits above the deck so the VCR remains + // the focal point. + if m.shelf != nil { + b.WriteString(m.shelf.View(w)) + b.WriteString("\n\n") + } + // Active panel — VCR while downloading, Mixer while muxing. We // always render the VCR (it carries the metadata), and overlay the // Mixer below it once the post-processing phase begins. @@ -420,11 +526,13 @@ func centreBlock(block string, termW int) string { // ── Run loop ───────────────────────────────────────────────────────────────── // ExtractorSelector blocks until the user commits a format selection -// from the VCR's browsing mode, or until ctx is cancelled. Returns the -// yt-dlp spec + container chosen. Empty strings mean "use the default -// pipeline" — happens when the source has no selectable formats or the -// user dismissed without picking. -type ExtractorSelector func(ctx context.Context) (spec, container string, err error) +// from the VCR's browsing mode, or until ctx is cancelled. Returns +// the full ExtractorSelectionMsg so callers can pull both the exact +// spec (for single-URL runs) and the adaptive descriptors (for batch +// FormatAll caching). A zero-value selection means "use the default +// pipeline" — happens when the source has no selectable formats or +// the user dismissed without picking. +type ExtractorSelector func(ctx context.Context) (ExtractorSelectionMsg, error) // ExtractorRunOptions configures RunExtractorTUI. type ExtractorRunOptions struct { @@ -450,8 +558,8 @@ func RunExtractorTUI(opts ExtractorRunOptions, worker func(ExtractorSelector) er // through charmbracelet/log via ui.Printf(). Program stays nil // so the extractor sink drops UI events. The selector returns // the zero value so yt-dlp's bv*+ba/b default kicks in. - return worker(func(ctx context.Context) (string, string, error) { - return "", "", nil + return worker(func(ctx context.Context) (ExtractorSelectionMsg, error) { + return ExtractorSelectionMsg{}, nil }) } @@ -467,12 +575,12 @@ func RunExtractorTUI(opts ExtractorRunOptions, worker func(ExtractorSelector) er p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithoutSignalHandler()) Program = p - selector := func(ctx context.Context) (string, string, error) { + selector := func(ctx context.Context) (ExtractorSelectionMsg, error) { select { case s := <-selectionCh: - return s.Spec, s.Container, nil + return s, nil case <-ctx.Done(): - return "", "", ctx.Err() + return ExtractorSelectionMsg{}, ctx.Err() } } diff --git a/internal/ui/extractor_tui_test.go b/internal/ui/extractor_tui_test.go index cd051c0..b35e399 100644 --- a/internal/ui/extractor_tui_test.go +++ b/internal/ui/extractor_tui_test.go @@ -246,6 +246,21 @@ func TestExtractorModel_BrowsingRockersAndREC(t *testing.T) { if committed.Container != "mkv" { t.Errorf("committed container = %q, want mkv", committed.Container) } + // Adaptive descriptors must be populated from the chosen video + + // audio formats so the batch FormatAll pipeline can fall back to + // a close match on tapes that lack format IDs 299/140. + if committed.HeightCeiling != 1080 { + t.Errorf("HeightCeiling=%d want 1080", committed.HeightCeiling) + } + if committed.VCodec != "avc1" { + t.Errorf("VCodec=%q want avc1", committed.VCodec) + } + if committed.ABRCeiling != 128 { + t.Errorf("ABRCeiling=%d want 128", committed.ABRCeiling) + } + if committed.Progressive { + t.Errorf("Progressive=true for separate v+a pick — should be false") + } // After REC the panel transitions out of browsing — no more ARMED. m = tickN(t, m, 3) @@ -298,6 +313,62 @@ func TestExtractorModel_ProgressiveOnlyCollapsesAudioRocker(t *testing.T) { mustContain(t, view, "included", "audio rocker should collapse for progressive-only sources") } +func TestExtractorModel_ShelfRendersAboveDeck(t *testing.T) { + m := NewExtractorModel("https://youtu.be/aaa", func() {}) + m.width = 120 + m.height = 60 + + m = step(t, m, + ExtractorShelfSeedMsg{URLs: []string{ + "https://youtu.be/aaa", + "https://youtu.be/bbb", + "https://youtu.be/ccc", + }}, + ExtractorShelfMetaMsg{Index: 0, Title: "First Video", Channel: "Chan", Duration: 90 * time.Second, Resolution: "1080p"}, + ExtractorShelfActiveMsg{Index: 0}, + ExtractorShelfStatusMsg{Index: 0, Status: CassettePlaying}, + ) + m = tickN(t, m, 5) + + view := stripANSI(m.View()) + mustContain(t, view, "First Video", "shelf didn't render the active tape title") + mustContain(t, view, "⏵ 01 / 03", "shelf counter strip missing") + mustContain(t, view, "HGET·VCR", "deck still rendered below shelf") +} + +func TestExtractorModel_ResetDeckClearsBetweenItems(t *testing.T) { + m := NewExtractorModel("u1", func() {}) + m.width = 100 + m.height = 60 + + // Seed first tape, drive it to completion. + m = step(t, m, + ExtractorMetaMsg{Title: "Tape One", HasAudio: true, Duration: time.Minute}, + ExtractorPhaseMsg{Phase: "downloading"}, + ExtractorProgressMsg{Percent: 87, Downloaded: 870, Total: 1000, SpeedBPS: 1000}, + ExtractorOutputMsg{Path: "/tmp/Tape One.mp4"}, + ) + m = tickN(t, m, 10) + view := stripANSI(m.View()) + mustContain(t, view, "Tape One", "tape one didn't render") + + // Reset deck → next tape's metadata should replace it cleanly. + m = step(t, m, + ExtractorResetDeckMsg{}, + ExtractorURLMsg{URL: "https://youtu.be/two"}, + ExtractorMetaMsg{Title: "Tape Two", HasAudio: true, Duration: 30 * time.Second}, + ExtractorPhaseMsg{Phase: "downloading"}, + ExtractorProgressMsg{Percent: 5, Downloaded: 50, Total: 1000, SpeedBPS: 500}, + ) + m = tickN(t, m, 10) + view = stripANSI(m.View()) + mustContain(t, view, "Tape Two", "tape two didn't replace tape one") + if strings.Contains(view, "Tape One.mp4") { + t.Errorf("reset failed to clear previous output path:\n%s", view) + } + mustContain(t, view, "youtu.be/two", "URL line didn't update for new tape") +} + func TestExtractorModel_ErrorRenders(t *testing.T) { m := NewExtractorModel("https://vimeo.com/x", func() {}) m.width = 100 diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 7698ae0..5add90a 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -2358,21 +2358,27 @@ const helpMarkdown = "" + "| `--extractor ` | extractor mode: `auto` / `yt-dlp` / `none` | `auto` |\n" + "| `--cookies ` | cookies.txt for the extractor (yt-dlp `--cookies`) | |\n" + "| `--cookies-from-browser ` | extract cookies from browser (e.g. `firefox`, `chrome:Default`) | |\n" + + "| `--quality ` | extractor quality preset: `360p` / `480p` / `720p` / `1080p` / `1440p` / `4K` / `8K` / `best` / `audio` | `720p` |\n" + + "| `--container ` | extractor output container: `mp4` / `mkv` / `webm` | `mp4` |\n" + + "| `--audio-lang ` | preferred audio language (forwarded as yt-dlp `-S lang:`); empty disables the bias | `en` |\n" + + "| `--pick-format` | open the VCR rocker UI to choose resolution / audio / container by hand | `false` |\n" + "\n" + "## Extractor mode (yt-dlp pipeline)\n" + "\n" + "When the URL points at a media host (YouTube, Vimeo, Twitch, …) hget hands\n" + "off to `yt-dlp` and renders a retro VCR panel instead of the data-link.\n" + - "After probing, the deck enters **browsing mode** — the READY LED lights\n" + - "amber and three rocker switches replace the live readouts so you can pick\n" + - "the tape before the heads come down. No popups, no jumpscreens: the same\n" + - "chassis re-displays.\n" + + "By default the deck goes straight from probing to recording at the\n" + + "`--quality` preset (720p mp4) with English audio — no rocker UI, no\n" + + "jumpscreens. yt-dlp gets `-S lang:en` so YouTube's auto-translated\n" + + "audio tracks don't win over the original-language stream.\n" + "\n" + - "Press REC (`enter` / `r`) to engage; the VCR slides straight into the\n" + - "recording animation. The Mixer console fades in below once yt-dlp moves\n" + - "into the ffmpeg muxing phase.\n" + + "Pass `--pick-format` to opt into the rocker UI: after probing, the\n" + + "deck enters **browsing mode** with three rockers (video / audio /\n" + + "container) on the same chassis. Press REC (`enter` / `r`) to engage.\n" + + "In batch mode the first tape's pick is locked in for the whole queue\n" + + "and applied adaptively to videos that don't share the same format IDs.\n" + "\n" + - "### Browsing-mode keys\n" + + "### Browsing-mode keys (with `--pick-format`)\n" + "\n" + "| Key | Rocker |\n" + "| -------------------- | ----------------------------------------------- |\n" + @@ -2389,6 +2395,23 @@ const helpMarkdown = "" + "`(included)`. Live streams skip the selector entirely and engage\n" + "yt-dlp's `best` format on sight.\n" + "\n" + + "### Batch mode (a file of YouTube URLs)\n" + + "\n" + + "`--file urls.txt` is **all-or-nothing**: if any URL in the file looks\n" + + "extractable (or `--extractor=yt-dlp` is forced), the whole list is\n" + + "routed through yt-dlp. A horizontal **cassette shelf** appears above\n" + + "the VCR — one VHS tape per URL, with the active tape lifted out of its\n" + + "slot. Plain HTTP URLs go to yt-dlp's generic extractor.\n" + + "\n" + + "Every tape uses the same `--quality` preset by default. Probes run\n" + + "in parallel so spine labels — title, channel chip, runtime,\n" + + "resolution — resolve ahead of the deck reaching their position. The\n" + + "shelf scales down (or up) automatically based on terminal width: full\n" + + "detail on wide terminals, compact tape spines on narrow ones.\n" + + "\n" + + "Failed tapes get a magenta `✗` sticker and the queue continues to the\n" + + "next URL — failures never abort the batch.\n" + + "\n" + "## Examples\n" + "\n" + "```bash\n" + @@ -2419,6 +2442,18 @@ const helpMarkdown = "" + "\n" + "# YouTube using your live browser cookies (no export needed)\n" + "hget --cookies-from-browser firefox https://www.youtube.com/watch?v=dQw4w9WgXcQ\n" + + "\n" + + "# batch of YouTube URLs — VCR + cassette-shelf TUI, 720p mp4 default\n" + + "hget --file videos.txt\n" + + "\n" + + "# YouTube at 1080p mkv, keep English audio (skips auto-translations)\n" + + "hget --quality 1080p --container mkv https://www.youtube.com/watch?v=dQw4w9WgXcQ\n" + + "\n" + + "# audio-only — pulls best audio track in original language\n" + + "hget --quality audio https://www.youtube.com/watch?v=dQw4w9WgXcQ\n" + + "\n" + + "# manually pick resolution/audio/container via the VCR rockers\n" + + "hget --pick-format https://www.youtube.com/watch?v=dQw4w9WgXcQ\n" + "```\n" func PrintHelp() { diff --git a/internal/ui/vcr.go b/internal/ui/vcr.go index 6e4f461..35652c8 100644 --- a/internal/ui/vcr.go +++ b/internal/ui/vcr.go @@ -148,26 +148,44 @@ func (v VCRAnimation) CurrentContainer() string { return v.containers[clampIdx(v.containerIdx, len(v.containers))] } -// Selection builds the yt-dlp format spec from the current rocker -// positions. When the chosen video format is progressive (carries its -// own audio), the audio selector is ignored. -func (v VCRAnimation) Selection() (spec, container string) { - container = v.CurrentContainer() +// Selection builds the full format selection (exact spec + container +// + adaptive descriptors) from the current rocker positions. When +// the chosen video format is progressive (carries its own audio), the +// audio selector is ignored. The adaptive descriptors let downstream +// callers translate the pick into a yt-dlp filter expression that +// survives sources lacking the exact format IDs. +func (v VCRAnimation) Selection() ExtractorSelectionMsg { + sel := ExtractorSelectionMsg{Container: v.CurrentContainer()} vf, hasV := v.CurrentVideo() if !hasV { - // No video formats at all (audio-only source) — fall back to - // just the audio pick. if af, ok := v.CurrentAudio(); ok { - return af.ID, container + sel.Spec = af.ID + sel.ABRCeiling = int(af.ABR) + if sel.ABRCeiling == 0 { + sel.ABRCeiling = int(af.TBR) + } } - return "", container + return sel + } + // Common video descriptors regardless of progressive vs separate. + sel.HeightCeiling = vf.Height + sel.FPSFloor = int(vf.FPS) + if vf.VCodec != "" && vf.VCodec != "none" { + sel.VCodec = shortCodec(vf.VCodec) } if vf.HasAudio || !hasAudioPick(v) { // Progressive format, or no audio track to merge in. - return vf.ID, container + sel.Spec = vf.ID + sel.Progressive = true + return sel } af, _ := v.CurrentAudio() - return vf.ID + "+" + af.ID, container + sel.Spec = vf.ID + "+" + af.ID + sel.ABRCeiling = int(af.ABR) + if sel.ABRCeiling == 0 { + sel.ABRCeiling = int(af.TBR) + } + return sel } func hasAudioPick(v VCRAnimation) bool { return len(v.audioFormats) > 0 }