Initial commit: omagrab v0.1.0
A yt-dlp TUI for Omarchy. Dual-mode (audio/video) paste-and-queue downloader built with Go + Bubble Tea. Live progress, config view, clipboard launch, and an install.sh that pulls in yt-dlp/ffmpeg/wl-clipboard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
1febf47dcb
17 changed files with 1446 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/omagrab
|
||||
/dist/
|
||||
*.bak
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gavin Nugent
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
152
README.md
Normal file
152
README.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# omagrab
|
||||
|
||||
A fast, keyboard-driven **yt-dlp TUI** for [Omarchy](https://omarchy.org) — paste a
|
||||
URL, pick Audio or Video, and watch it download with live progress. Built with Go +
|
||||
[Bubble Tea](https://github.com/charmbracelet/bubbletea).
|
||||
|
||||
> **Omarchy only.** This is built specifically for Omarchy and isn't intended to be
|
||||
> distro-portable. It relies on `xdg-terminal-exec`, Hyprland window rules,
|
||||
> Wayland + `wl-clipboard`, and a Nerd Font terminal. There are no X11 /
|
||||
> non-Omarchy fallbacks.
|
||||
|
||||
```
|
||||
omagrab
|
||||
|
||||
Audio ▐ Video ▌
|
||||
|
||||
▸ paste a URL and press enter…
|
||||
|
||||
✓ Big Buck Bunny (1080p) done
|
||||
Sintel — Blender Open Movie ████████░░ 62% 4.2MiB/s
|
||||
○ another queued link queued
|
||||
|
||||
tab audio/video · enter add · ↑↓ select · esc quit
|
||||
empty box → c config · d remove · q quit
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Works with any [yt-dlp-supported site](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md)**
|
||||
— paste a link and go.
|
||||
- **Dual mode** — `Tab` toggles ♪ Audio ↔ 🎬 Video.
|
||||
- **Paste-and-queue** — drop in a URL, press `Enter`, keep adding. A worker
|
||||
downloads them one at a time with live progress, speed and ETA.
|
||||
- **Audio** — extracts to your chosen format (opus / mp3 / m4a / flac) with
|
||||
metadata and thumbnail embedded, into your audio directory.
|
||||
- **Video** — quality selector (best / 2160 / 1440 / 1080 / 720), merged to mp4,
|
||||
with optional English subtitles, into your video directory.
|
||||
- **Config view** — change format, quality and subtitles inline; saved to
|
||||
`~/.config/omagrab/config.json`.
|
||||
- **Clipboard launch** — `omagrab --clip` pre-fills the URL from your clipboard,
|
||||
perfect for a Hyprland keybind (see below).
|
||||
- **Dependency check** — tells you exactly what to `pacman -S` if anything's missing.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [ffmpeg](https://ffmpeg.org/) (for extraction, merging and embedding)
|
||||
- `wl-clipboard` (only for the `--clip` / clipboard launch flow)
|
||||
|
||||
```bash
|
||||
sudo pacman -S yt-dlp ffmpeg wl-clipboard
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
One line — pulls in `yt-dlp`, `ffmpeg` and `wl-clipboard` via pacman if they're
|
||||
missing, installs the binary to `~/.local/bin`, and (on Omarchy) adds a floating
|
||||
Walker entry:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/28allday/omagrab/main/install.sh | bash
|
||||
```
|
||||
|
||||
Or from a clone (builds from source if Go is present, otherwise downloads the
|
||||
latest released binary):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/28allday/omagrab.git
|
||||
cd omagrab
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Make sure `~/.local/bin` is on your `PATH`. The installer prints the optional
|
||||
`SUPER+SHIFT+V` clipboard keybind for you to add.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
omagrab # open the TUI
|
||||
omagrab <URL> # open with a URL pre-filled
|
||||
omagrab --clip # open with the URL from your clipboard pre-filled
|
||||
omagrab --help
|
||||
```
|
||||
|
||||
### Keys
|
||||
|
||||
| Key | Action |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| `Tab` | Switch Audio ↔ Video mode |
|
||||
| `Enter` | Add the URL in the box to the queue |
|
||||
| `↑` / `↓` | Select a queue item |
|
||||
| `c` | Open config (when the URL box is empty) |
|
||||
| `d` / `x` | Remove the selected item (when the box is empty) |
|
||||
| `q` / `Esc` | Quit |
|
||||
|
||||
In the config view: `↑↓` move, `←→` change a value, `Esc` saves and returns.
|
||||
|
||||
> Single-letter commands (`c`, `d`, `q`) only act when the URL box is empty — so
|
||||
> they never get swallowed while you're typing or pasting a link.
|
||||
|
||||
## Clipboard keybind (recommended)
|
||||
|
||||
The smoothest flow on Omarchy: copy a link in your browser, then summon omagrab as
|
||||
a floating window with the URL already filled in.
|
||||
|
||||
Add to `~/.config/hypr/bindings.conf`:
|
||||
|
||||
```ini
|
||||
bindd = SUPER SHIFT, V, omagrab, exec, xdg-terminal-exec --app-id=omagrab -e omagrab --clip
|
||||
```
|
||||
|
||||
And a floating rule in `~/.config/hypr/windows.conf` (Hyprland ≥ 0.55 syntax):
|
||||
|
||||
```ini
|
||||
windowrule = float on, match:class ^(omagrab)$
|
||||
windowrule = center on, match:class ^(omagrab)$
|
||||
windowrule = size 520 260, match:class ^(omagrab)$
|
||||
```
|
||||
|
||||
Reload Hyprland (`hyprctl reload`). Now: **copy a link → `SUPER+SHIFT+V` →
|
||||
omagrab floats with the URL ready → `Tab` to pick mode → `Enter`.**
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.config/omagrab/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"audio_dir": "~/Music",
|
||||
"video_dir": "~/Videos",
|
||||
"audio_format": "opus",
|
||||
"video_quality": "best",
|
||||
"subtitles": false
|
||||
}
|
||||
```
|
||||
|
||||
Edit it directly, or change format / quality / subtitles from the in-app config
|
||||
view (`c`). Directories support a leading `~`.
|
||||
|
||||
## A note on copyright
|
||||
|
||||
Please only download content you have the right to. That means your own uploads,
|
||||
content released under a permissive licence (e.g. Creative Commons), public-domain
|
||||
material, or anything you otherwise have permission to download. Downloading other
|
||||
people's content without permission may be copyright infringement (in the UK this
|
||||
can be a civil matter) and generally breaches the terms of service of the sites
|
||||
involved. omagrab is a convenience wrapper around `yt-dlp`; how you use it is your
|
||||
responsibility.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © 2026 Gavin Nugent
|
||||
88
config.go
Normal file
88
config.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds user preferences, persisted as JSON under ~/.config/omagrab.
|
||||
type Config struct {
|
||||
AudioDir string `json:"audio_dir"`
|
||||
VideoDir string `json:"video_dir"`
|
||||
AudioFormat string `json:"audio_format"` // mp3 | opus | flac | m4a
|
||||
VideoQuality string `json:"video_quality"` // best | 2160 | 1440 | 1080 | 720
|
||||
Subtitles bool `json:"subtitles"`
|
||||
}
|
||||
|
||||
// AudioFormats / VideoQualities are the values cycled through in the config view.
|
||||
var (
|
||||
AudioFormats = []string{"opus", "mp3", "m4a", "flac"}
|
||||
VideoQualities = []string{"best", "2160", "1440", "1080", "720"}
|
||||
)
|
||||
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
AudioDir: "~/Music",
|
||||
VideoDir: "~/Videos",
|
||||
AudioFormat: "opus",
|
||||
VideoQuality: "best",
|
||||
Subtitles: false,
|
||||
}
|
||||
}
|
||||
|
||||
func configPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "omagrab", "config.json"), nil
|
||||
}
|
||||
|
||||
// loadConfig reads the config file, writing defaults on first run.
|
||||
func loadConfig() (Config, error) {
|
||||
path, err := configPath()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
cfg := defaultConfig()
|
||||
return cfg, saveConfig(cfg)
|
||||
}
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg := defaultConfig()
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func saveConfig(cfg Config) error {
|
||||
path, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
// ExpandHome resolves a leading ~ to the user's home directory.
|
||||
func ExpandHome(p string) string {
|
||||
if p == "~" || strings.HasPrefix(p, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
return filepath.Join(home, strings.TrimPrefix(p, "~"))
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
9
desktop/omagrab-url.desktop
Normal file
9
desktop/omagrab-url.desktop
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=omagrab (URL handler)
|
||||
Comment=Download a link with omagrab
|
||||
Exec=xdg-terminal-exec omagrab %u
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
StartupNotify=false
|
||||
MimeType=x-scheme-handler/omagrab;
|
||||
10
desktop/omagrab.desktop
Normal file
10
desktop/omagrab.desktop
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=omagrab
|
||||
Comment=Download audio/video with yt-dlp
|
||||
Exec=xdg-terminal-exec omagrab
|
||||
Terminal=false
|
||||
Icon=folder-download
|
||||
Categories=AudioVideo;Utility;
|
||||
Keywords=yt-dlp;download;video;audio;
|
||||
StartupNotify=false
|
||||
180
download.go
Normal file
180
download.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// progress lines are rendered by yt-dlp from this template; we tag them with a
|
||||
// sentinel so they're trivially distinguished from yt-dlp's other chatter.
|
||||
const progSentinel = "OMAGRABPROG|"
|
||||
const progTemplate = "download:" + progSentinel +
|
||||
"%(progress._percent_str)s|%(progress._speed_str)s|%(progress._eta_str)s"
|
||||
|
||||
// checkDeps returns the list of required external binaries that are missing.
|
||||
func checkDeps() []string {
|
||||
var missing []string
|
||||
for _, bin := range []string{"yt-dlp", "ffmpeg"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
missing = append(missing, bin)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// depPackages maps the missing binaries to their Arch package names.
|
||||
func depPackages(missing []string) string {
|
||||
pkg := map[string]string{"yt-dlp": "yt-dlp", "ffmpeg": "ffmpeg"}
|
||||
var out []string
|
||||
for _, m := range missing {
|
||||
out = append(out, pkg[m])
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// buildArgs returns the yt-dlp arguments for one queued item, snapshotting the
|
||||
// current config so later config changes don't affect an already-queued job.
|
||||
func buildArgs(cfg Config, m mode) []string {
|
||||
args := []string{
|
||||
"--no-warnings", "--newline",
|
||||
"--progress-template", progTemplate,
|
||||
"-o", "%(title)s.%(ext)s",
|
||||
}
|
||||
if m == audioMode {
|
||||
args = append(args,
|
||||
"-P", ExpandHome(cfg.AudioDir),
|
||||
"-x", "--audio-format", cfg.AudioFormat,
|
||||
"--audio-quality", "0",
|
||||
"--embed-metadata", "--embed-thumbnail",
|
||||
)
|
||||
} else {
|
||||
var sel string
|
||||
if cfg.VideoQuality == "best" {
|
||||
sel = "bv*+ba/b"
|
||||
} else {
|
||||
sel = fmt.Sprintf("bv*[height<=%s]+ba/b[height<=%s]", cfg.VideoQuality, cfg.VideoQuality)
|
||||
}
|
||||
args = append(args,
|
||||
"-P", ExpandHome(cfg.VideoDir),
|
||||
"-f", sel,
|
||||
"--merge-output-format", "mp4",
|
||||
"--embed-metadata",
|
||||
)
|
||||
if cfg.Subtitles {
|
||||
args = append(args, "--embed-subs", "--sub-langs", "en.*")
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// worker processes the queue sequentially, streaming events back to the UI.
|
||||
func worker(p *tea.Program, jobs <-chan *item) {
|
||||
for it := range jobs {
|
||||
runOne(p, it)
|
||||
}
|
||||
}
|
||||
|
||||
func runOne(p *tea.Program, it *item) {
|
||||
// 1. resolve a human title (best effort; falls back to the URL).
|
||||
p.Send(statusMsg{url: it.url, status: stFetching})
|
||||
if title := fetchTitle(it.url); title != "" {
|
||||
p.Send(titleMsg{url: it.url, title: title})
|
||||
}
|
||||
|
||||
// 2. download, streaming progress.
|
||||
p.Send(statusMsg{url: it.url, status: stDownloading})
|
||||
args := append(append([]string{}, it.args...), it.url)
|
||||
cmd := exec.Command("yt-dlp", args...)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
p.Send(failMsg{url: it.url, err: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tail := newRing(8)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
sc := bufio.NewScanner(pr)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, progSentinel) {
|
||||
if pct, speed, eta, ok := parseProg(line); ok {
|
||||
p.Send(progressMsg{url: it.url, percent: pct, speed: speed, eta: eta})
|
||||
}
|
||||
continue
|
||||
}
|
||||
tail.push(line)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := cmd.Wait()
|
||||
pw.Close()
|
||||
<-done
|
||||
|
||||
if err != nil {
|
||||
p.Send(failMsg{url: it.url, err: tail.last()})
|
||||
return
|
||||
}
|
||||
p.Send(doneMsg{url: it.url})
|
||||
}
|
||||
|
||||
// fetchTitle does a lightweight metadata-only lookup.
|
||||
func fetchTitle(url string) string {
|
||||
out, err := exec.Command("yt-dlp", "--no-warnings", "--skip-download",
|
||||
"--no-playlist", "--print", "%(title)s", url).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
||||
}
|
||||
|
||||
func parseProg(line string) (pct float64, speed, eta string, ok bool) {
|
||||
parts := strings.SplitN(strings.TrimPrefix(line, progSentinel), "|", 3)
|
||||
if len(parts) != 3 {
|
||||
return 0, "", "", false
|
||||
}
|
||||
ps := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(parts[0]), "%"))
|
||||
pct, err := strconv.ParseFloat(ps, 64)
|
||||
if err != nil {
|
||||
return 0, "", "", false
|
||||
}
|
||||
return pct / 100, strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true
|
||||
}
|
||||
|
||||
// ring is a tiny fixed-size line buffer for capturing the tail of stderr.
|
||||
type ring struct {
|
||||
buf []string
|
||||
n int
|
||||
}
|
||||
|
||||
func newRing(n int) *ring { return &ring{buf: make([]string, 0, n), n: n} }
|
||||
|
||||
func (r *ring) push(s string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
if len(r.buf) == r.n {
|
||||
r.buf = r.buf[1:]
|
||||
}
|
||||
r.buf = append(r.buf, s)
|
||||
}
|
||||
|
||||
func (r *ring) last() string {
|
||||
if len(r.buf) == 0 {
|
||||
return "download failed"
|
||||
}
|
||||
return r.buf[len(r.buf)-1]
|
||||
}
|
||||
36
extension/background.js
Normal file
36
extension/background.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Adds a right-click entry that hands the chosen URL to the native omagrab app
|
||||
// via the custom "omagrab:" URL scheme (registered by omagrab-url.desktop).
|
||||
|
||||
function createMenu() {
|
||||
// removeAll first so reloads / service-worker restarts can't hit a
|
||||
// "duplicate id" error that would leave the menu uncreated.
|
||||
chrome.contextMenus.removeAll(() => {
|
||||
chrome.contextMenus.create({
|
||||
id: "omagrab",
|
||||
title: "⏬ Download with omagrab",
|
||||
contexts: ["link", "page", "video", "audio"],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(createMenu);
|
||||
chrome.runtime.onStartup.addListener(createMenu);
|
||||
// also run on every service-worker spin-up, in case the events above were missed
|
||||
createMenu();
|
||||
|
||||
chrome.contextMenus.onClicked.addListener((info, tab) => {
|
||||
// Prefer the link the user clicked; otherwise fall back to the page itself
|
||||
// (e.g. right-clicking anywhere on a video watch page).
|
||||
const url = info.linkUrl || info.pageUrl || (tab && tab.url);
|
||||
if (!url || !tab || tab.id == null) return;
|
||||
|
||||
const target = "omagrab:" + encodeURIComponent(url);
|
||||
|
||||
// Setting location.href on the active tab triggers the OS protocol handler
|
||||
// without spawning a leftover blank tab. The page itself stays put.
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (u) => { window.location.href = u; },
|
||||
args: [target],
|
||||
});
|
||||
});
|
||||
8
extension/manifest.json
Normal file
8
extension/manifest.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Send to omagrab",
|
||||
"version": "0.1.0",
|
||||
"description": "Right-click a link, video, or page to download it with omagrab.",
|
||||
"permissions": ["contextMenus", "scripting", "activeTab"],
|
||||
"background": { "service_worker": "background.js" }
|
||||
}
|
||||
33
go.mod
Normal file
33
go.mod
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
module github.com/28allday/omagrab
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
52
go.sum
Normal file
52
go.sum
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
50
install-integration.sh
Executable file
50
install-integration.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env bash
|
||||
# Installs omagrab plus its desktop integration:
|
||||
# - binary -> ~/.local/bin/omagrab
|
||||
# - Walker entry -> ~/.local/share/applications/omagrab.desktop
|
||||
# - URL handler -> ~/.local/share/applications/omagrab-url.desktop (omagrab: scheme)
|
||||
# Then prints how to load the Chromium right-click extension.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
bindir="$HOME/.local/bin"
|
||||
appdir="$HOME/.local/share/applications"
|
||||
|
||||
mkdir -p "$bindir" "$appdir"
|
||||
|
||||
# 1. binary (build if needed)
|
||||
if [[ ! -x "$here/omagrab" ]]; then
|
||||
echo "==> building omagrab"
|
||||
(cd "$here" && go build -o omagrab .)
|
||||
fi
|
||||
install -m755 "$here/omagrab" "$bindir/omagrab"
|
||||
echo "==> installed $bindir/omagrab"
|
||||
|
||||
# 2. desktop entries
|
||||
install -m644 "$here/desktop/omagrab.desktop" "$appdir/omagrab.desktop"
|
||||
install -m644 "$here/desktop/omagrab-url.desktop" "$appdir/omagrab-url.desktop"
|
||||
update-desktop-database "$appdir" 2>/dev/null || true
|
||||
echo "==> installed desktop entries"
|
||||
|
||||
# 3. register the omagrab: URL scheme
|
||||
xdg-mime default omagrab-url.desktop x-scheme-handler/omagrab
|
||||
echo "==> registered handler: $(xdg-mime query default x-scheme-handler/omagrab)"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$bindir:"*) ;;
|
||||
*) echo "!! $bindir is not on your PATH — add it so 'omagrab' resolves" ;;
|
||||
esac
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done. Native side is ready.
|
||||
|
||||
To enable right-click in Chromium:
|
||||
1. Open chrome://extensions
|
||||
2. Toggle Developer mode (top-right)
|
||||
3. Click Load unpacked and choose:
|
||||
$here/extension
|
||||
4. Right-click any link / video page -> "⏬ Download with omagrab"
|
||||
|
||||
First click shows Chrome's "Open omagrab?" prompt — tick "Always allow".
|
||||
EOF
|
||||
165
install.sh
Executable file
165
install.sh
Executable file
|
|
@ -0,0 +1,165 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# install.sh — install omagrab, a yt-dlp TUI for Omarchy / Arch.
|
||||
#
|
||||
# Quick install (nothing to clone):
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/28allday/omagrab/main/install.sh | bash
|
||||
#
|
||||
# When run from a git clone it builds from source instead (if Go is present),
|
||||
# otherwise it downloads the latest released binary for your architecture.
|
||||
#
|
||||
# ./install.sh
|
||||
#
|
||||
# Environment overrides:
|
||||
# PREFIX=/somewhere install prefix (default ~/.local → ~/.local/bin)
|
||||
# OMAGRAB_VERSION=v0.1.0 pin a release (default: latest)
|
||||
#
|
||||
# omagrab needs yt-dlp + ffmpeg at run time (and wl-clipboard for --clip); the
|
||||
# installer fetches them via pacman on Arch if they're missing. On an Omarchy
|
||||
# desktop it also adds a floating Walker entry.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="28allday/omagrab"
|
||||
NAME="omagrab"
|
||||
PREFIX="${PREFIX:-$HOME/.local}"
|
||||
BIN_DIR="$PREFIX/bin"
|
||||
BIN="$BIN_DIR/$NAME"
|
||||
APP_DIR="$HOME/.local/share/applications"
|
||||
VERSION="${OMAGRAB_VERSION:-latest}"
|
||||
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
||||
warn() { printf '\033[33m%s\033[0m\n' "$1" >&2; }
|
||||
err() { printf '\033[31m%s\033[0m\n' "$1" >&2; }
|
||||
log() { printf '\033[1;35m==>\033[0m %s\n' "$*"; }
|
||||
|
||||
# ensure_dep makes sure a command exists, installing its package via pacman on
|
||||
# Arch if missing. The third arg ("optional") only warns instead of aborting.
|
||||
ensure_dep() {
|
||||
local cmd="$1" pkg="$2" optional="${3:-}"
|
||||
command -v "$cmd" >/dev/null 2>&1 && return 0
|
||||
if command -v pacman >/dev/null 2>&1; then
|
||||
bold "Installing missing dependency: ${pkg}"
|
||||
sudo pacman -S --needed --noconfirm "$pkg"
|
||||
elif [ "$optional" = "optional" ]; then
|
||||
warn "Optional dependency '${cmd}' (${pkg}) not found — install it for full functionality."
|
||||
else
|
||||
err "Missing dependency '${cmd}'. Install the '${pkg}' package and re-run."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run-time dependencies.
|
||||
ensure_dep yt-dlp yt-dlp # required: the downloader itself
|
||||
ensure_dep ffmpeg ffmpeg # required: extraction / merge / embed
|
||||
ensure_dep wl-paste wl-clipboard optional # optional: needed only for --clip
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
# If the script lives next to the source tree, we're in a clone.
|
||||
SCRIPT_DIR=""
|
||||
if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
fi
|
||||
|
||||
# Detect OS/arch for prebuilt asset names.
|
||||
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) arch=amd64 ;;
|
||||
aarch64 | arm64) arch=arm64 ;;
|
||||
*) arch="" ;;
|
||||
esac
|
||||
|
||||
# ---- obtain the binary ----------------------------------------------------
|
||||
TMPBIN=""
|
||||
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then
|
||||
bold "Building $NAME from source…"
|
||||
TMPBIN="$(mktemp)"
|
||||
trap 'rm -f "$TMPBIN"' EXIT
|
||||
( cd "$SCRIPT_DIR" && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$TMPBIN" . )
|
||||
elif [ -n "$SCRIPT_DIR" ] && [ -n "$arch" ] && [ -x "$SCRIPT_DIR/dist/${NAME}-${os}-${arch}" ]; then
|
||||
log "Using prebuilt binary from dist/"
|
||||
TMPBIN="$SCRIPT_DIR/dist/${NAME}-${os}-${arch}"
|
||||
else
|
||||
# Download the released binary for this OS/arch (curl-style install).
|
||||
[ "$os" = "linux" ] || { err "omagrab ships Linux binaries only (detected: $os). Clone the repo and build with Go."; exit 1; }
|
||||
[ -n "$arch" ] || { err "unsupported architecture: $(uname -m)"; exit 1; }
|
||||
asset="${NAME}-${os}-${arch}"
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
url="https://github.com/$REPO/releases/latest/download/$asset"
|
||||
else
|
||||
url="https://github.com/$REPO/releases/download/$VERSION/$asset"
|
||||
fi
|
||||
log "Downloading $asset ($VERSION)…"
|
||||
TMPBIN="$(mktemp)"
|
||||
trap 'rm -f "$TMPBIN"' EXIT
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fSL --proto '=https' --tlsv1.2 -o "$TMPBIN" "$url"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$TMPBIN" "$url"
|
||||
else
|
||||
err "need curl or wget to download the binary."; exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- install --------------------------------------------------------------
|
||||
install -Dm755 "$TMPBIN" "$BIN"
|
||||
bold "Installed: $BIN"
|
||||
|
||||
# ---- Omarchy desktop integration -----------------------------------------
|
||||
# Only on Omarchy: add a Walker entry that opens omagrab in a floating window.
|
||||
# The TUI.float app-id is matched by Omarchy's stock floating-window rule, so
|
||||
# no Hyprland configuration is required (same approach as tsplay / omarchy-send).
|
||||
if command -v omarchy-launch-tui >/dev/null 2>&1 || [ -d "$HOME/.local/share/omarchy" ]; then
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
# Install a bundled icon into the user's hicolor theme — a download arrow over
|
||||
# a progress bar, in the terminal-accent magenta.
|
||||
icon_dir="$HOME/.local/share/icons/hicolor/scalable/apps"
|
||||
mkdir -p "$icon_dir"
|
||||
cat > "$icon_dir/omagrab.svg" <<'SVG'
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<rect width="256" height="256" rx="56" fill="#16161e"/>
|
||||
<path d="M128 56 L128 150 M92 116 L128 152 L164 116" fill="none" stroke="#bb9af7" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="64" y="190" width="128" height="12" rx="6" fill="#2a2e42"/>
|
||||
<rect x="64" y="190" width="84" height="12" rx="6" fill="#bb9af7"/>
|
||||
</svg>
|
||||
SVG
|
||||
gtk-update-icon-cache -q -t -f "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
|
||||
|
||||
cat > "$APP_DIR/omagrab.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Name=omagrab
|
||||
Comment=yt-dlp TUI — paste a URL, pick Audio or Video, download
|
||||
Exec=xdg-terminal-exec --app-id=TUI.float -e $BIN
|
||||
Icon=omagrab
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=AudioVideo;Network;Utility;
|
||||
Keywords=yt-dlp;download;video;audio;music;
|
||||
EOF
|
||||
bold "Omarchy detected — added floating Walker entry (with icon)."
|
||||
echo " Launch it from Walker by searching 'omagrab'."
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Optional: bind clipboard-launch to a key. Copy a link, hit the key, and omagrab
|
||||
opens with the URL pre-filled. Add to ~/.config/hypr/bindings.conf:
|
||||
|
||||
bindd = SUPER SHIFT, V, omagrab, exec, xdg-terminal-exec --app-id=omagrab -e omagrab --clip
|
||||
|
||||
and a floating rule to ~/.config/hypr/windows.conf (Hyprland >= 0.55):
|
||||
|
||||
windowrule = float on, match:class ^(omagrab)\$
|
||||
windowrule = center on, match:class ^(omagrab)\$
|
||||
windowrule = size 520 260, match:class ^(omagrab)\$
|
||||
|
||||
then run 'hyprctl reload'.
|
||||
EOF
|
||||
fi
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*) : ;;
|
||||
*) warn "Note: $BIN_DIR is not on your PATH. Add it to use 'omagrab' directly." ;;
|
||||
esac
|
||||
echo "Run 'omagrab' to start, or 'omagrab --help' for options."
|
||||
92
main.go
Normal file
92
main.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
var initialURL string
|
||||
var useClip bool
|
||||
for _, a := range os.Args[1:] {
|
||||
switch a {
|
||||
case "-v", "--version":
|
||||
fmt.Println("omagrab", version)
|
||||
return
|
||||
case "-h", "--help":
|
||||
fmt.Println("omagrab — a yt-dlp TUI for Omarchy\n\n" +
|
||||
"Usage: omagrab [URL]\n\n" +
|
||||
" -c, --clip pre-fill the URL from the clipboard (wl-paste)\n\n" +
|
||||
"Paste URLs into the queue; Tab switches Audio/Video mode.\n" +
|
||||
"Launch with a URL (or an omagrab: scheme link) to pre-fill it.\n" +
|
||||
"Config lives in ~/.config/omagrab/config.json")
|
||||
return
|
||||
case "-c", "--clip":
|
||||
useClip = true
|
||||
default:
|
||||
if u := normalizeArgURL(a); u != "" {
|
||||
initialURL = u
|
||||
}
|
||||
}
|
||||
}
|
||||
if initialURL == "" && useClip {
|
||||
initialURL = clipboardURL()
|
||||
}
|
||||
|
||||
if missing := checkDeps(); len(missing) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "omagrab: missing required tools: %v\n", missing)
|
||||
fmt.Fprintf(os.Stderr, "install with: sudo pacman -S %s\n", depPackages(missing))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "omagrab: config error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
jobs := make(chan *item, 256)
|
||||
p := tea.NewProgram(newModel(cfg, jobs, initialURL), tea.WithAltScreen())
|
||||
go worker(p, jobs)
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// clipboardURL reads the Wayland clipboard and returns it if it's an http(s) URL.
|
||||
func clipboardURL() string {
|
||||
out, err := exec.Command("wl-paste", "-n").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeArgURL(string(out))
|
||||
}
|
||||
|
||||
// normalizeArgURL accepts either a plain http(s) URL or an "omagrab:" / "omagrab://"
|
||||
// scheme link (as delivered by the browser extension via xdg-open) and returns a
|
||||
// clean http(s) URL, or "" if the argument isn't a usable URL.
|
||||
func normalizeArgURL(a string) string {
|
||||
s := strings.TrimSpace(a)
|
||||
for _, pfx := range []string{"omagrab://", "omagrab:"} {
|
||||
if strings.HasPrefix(s, pfx) {
|
||||
s = strings.TrimPrefix(s, pfx)
|
||||
if dec, err := url.QueryUnescape(s); err == nil {
|
||||
s = dec
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
28
main_test.go
Normal file
28
main_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeArgURL(t *testing.T) {
|
||||
want := "https://example.com/watch?v=abc123&t=10s"
|
||||
cases := map[string]string{
|
||||
"plain https": want,
|
||||
"omagrab scheme": "omagrab:https%3A%2F%2Fexample.com%2Fwatch%3Fv%3Dabc123%26t%3D10s",
|
||||
"omagrab slashes": "omagrab://https%3A%2F%2Fexample.com%2Fwatch%3Fv%3Dabc123%26t%3D10s",
|
||||
"unescaped scheme": "omagrab:https://example.com/watch?v=abc123&t=10s",
|
||||
}
|
||||
for name, in := range cases {
|
||||
if got := normalizeArgURL(in); got != want {
|
||||
t.Errorf("%s: normalizeArgURL(%q) = %q, want %q", name, in, got, want)
|
||||
}
|
||||
}
|
||||
for name, in := range map[string]string{
|
||||
"junk": "not a url",
|
||||
"ftp": "ftp://example.com",
|
||||
"empty": "",
|
||||
"scheme only": "omagrab:",
|
||||
} {
|
||||
if got := normalizeArgURL(in); got != "" {
|
||||
t.Errorf("%s: normalizeArgURL(%q) = %q, want empty", name, in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
300
model.go
Normal file
300
model.go
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
audioMode mode = iota
|
||||
videoMode
|
||||
)
|
||||
|
||||
type status int
|
||||
|
||||
const (
|
||||
stQueued status = iota
|
||||
stFetching
|
||||
stDownloading
|
||||
stDone
|
||||
stFailed
|
||||
)
|
||||
|
||||
// item is one entry in the download queue.
|
||||
type item struct {
|
||||
url string
|
||||
title string
|
||||
args []string // yt-dlp args snapshotted at enqueue time
|
||||
status status
|
||||
percent float64
|
||||
speed string
|
||||
eta string
|
||||
err string
|
||||
}
|
||||
|
||||
func (i *item) label() string {
|
||||
if i.title != "" {
|
||||
return i.title
|
||||
}
|
||||
return i.url
|
||||
}
|
||||
|
||||
// --- messages from the worker ---
|
||||
|
||||
type titleMsg struct{ url, title string }
|
||||
type statusMsg struct {
|
||||
url string
|
||||
status status
|
||||
}
|
||||
type progressMsg struct {
|
||||
url string
|
||||
percent float64
|
||||
speed string
|
||||
eta string
|
||||
}
|
||||
type doneMsg struct{ url string }
|
||||
type failMsg struct{ url, err string }
|
||||
|
||||
type view int
|
||||
|
||||
const (
|
||||
queueView view = iota
|
||||
configView
|
||||
)
|
||||
|
||||
type model struct {
|
||||
cfg Config
|
||||
mode mode
|
||||
view view
|
||||
input textinput.Model
|
||||
spinner spinner.Model
|
||||
items []*item
|
||||
cursor int
|
||||
cfgCur int // selected row in the config view
|
||||
jobs chan<- *item
|
||||
w, h int
|
||||
flash string
|
||||
}
|
||||
|
||||
func newModel(cfg Config, jobs chan<- *item, initialURL string) model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "paste a URL and press enter…"
|
||||
ti.Prompt = "▸ "
|
||||
ti.Focus()
|
||||
ti.CharLimit = 2048
|
||||
if initialURL != "" {
|
||||
ti.SetValue(initialURL)
|
||||
ti.CursorEnd()
|
||||
}
|
||||
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
|
||||
return model{
|
||||
cfg: cfg,
|
||||
mode: videoMode, // start in Video mode
|
||||
input: ti,
|
||||
spinner: sp,
|
||||
jobs: jobs,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(textinput.Blink, m.spinner.Tick)
|
||||
}
|
||||
|
||||
// find returns the item with the given URL, or nil.
|
||||
func (m *model) find(url string) *item {
|
||||
for _, it := range m.items {
|
||||
if it.url == url {
|
||||
return it
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.w, m.h = msg.Width, msg.Height
|
||||
m.input.Width = m.cardWidth() - 4
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case titleMsg:
|
||||
if it := m.find(msg.url); it != nil {
|
||||
it.title = msg.title
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case statusMsg:
|
||||
if it := m.find(msg.url); it != nil {
|
||||
it.status = msg.status
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case progressMsg:
|
||||
if it := m.find(msg.url); it != nil {
|
||||
it.status = stDownloading
|
||||
it.percent, it.speed, it.eta = msg.percent, msg.speed, msg.eta
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case doneMsg:
|
||||
if it := m.find(msg.url); it != nil {
|
||||
it.status, it.percent = stDone, 1
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case failMsg:
|
||||
if it := m.find(msg.url); it != nil {
|
||||
it.status, it.err = stFailed, msg.err
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.view == configView {
|
||||
return m.updateConfig(msg)
|
||||
}
|
||||
return m.updateQueue(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) updateQueue(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.flash = ""
|
||||
key := msg.String()
|
||||
switch key {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "tab":
|
||||
if m.mode == audioMode {
|
||||
m.mode = videoMode
|
||||
} else {
|
||||
m.mode = audioMode
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
return m.enqueue()
|
||||
case "up", "ctrl+p":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "ctrl+n":
|
||||
if m.cursor < len(m.items)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Single-letter commands act only when the URL box is empty (its resting
|
||||
// state), so they don't get swallowed while you're typing/pasting a URL.
|
||||
if strings.TrimSpace(m.input.Value()) == "" {
|
||||
switch key {
|
||||
case "c":
|
||||
m.view = configView
|
||||
return m, nil
|
||||
case "d", "x":
|
||||
return m.removeSelected()
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) enqueue() (tea.Model, tea.Cmd) {
|
||||
url := strings.TrimSpace(m.input.Value())
|
||||
if url == "" {
|
||||
return m, nil
|
||||
}
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
m.flash = "that doesn't look like a URL"
|
||||
return m, nil
|
||||
}
|
||||
it := &item{url: url, status: stQueued, args: buildArgs(m.cfg, m.mode)}
|
||||
m.items = append(m.items, it)
|
||||
m.input.SetValue("")
|
||||
m.jobs <- it
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) removeSelected() (tea.Model, tea.Cmd) {
|
||||
if m.cursor < 0 || m.cursor >= len(m.items) {
|
||||
return m, nil
|
||||
}
|
||||
it := m.items[m.cursor]
|
||||
if it.status == stDownloading || it.status == stFetching {
|
||||
m.flash = "can't remove an active download"
|
||||
return m, nil
|
||||
}
|
||||
m.items = append(m.items[:m.cursor], m.items[m.cursor+1:]...)
|
||||
if m.cursor >= len(m.items) && m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// config view rows
|
||||
const (
|
||||
cfgAudioFmt = iota
|
||||
cfgVideoQual
|
||||
cfgSubs
|
||||
cfgRowCount
|
||||
)
|
||||
|
||||
func (m model) updateConfig(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc", "ctrl+e", "q":
|
||||
_ = saveConfig(m.cfg)
|
||||
m.view = queueView
|
||||
m.flash = "config saved"
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
if m.cfgCur > 0 {
|
||||
m.cfgCur--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cfgCur < cfgRowCount-1 {
|
||||
m.cfgCur++
|
||||
}
|
||||
case "left", "h", "right", "l", "enter", " ":
|
||||
m.cycleConfig()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) cycleConfig() {
|
||||
switch m.cfgCur {
|
||||
case cfgAudioFmt:
|
||||
m.cfg.AudioFormat = cycle(AudioFormats, m.cfg.AudioFormat)
|
||||
case cfgVideoQual:
|
||||
m.cfg.VideoQuality = cycle(VideoQualities, m.cfg.VideoQuality)
|
||||
case cfgSubs:
|
||||
m.cfg.Subtitles = !m.cfg.Subtitles
|
||||
}
|
||||
}
|
||||
|
||||
func cycle(opts []string, cur string) string {
|
||||
for i, o := range opts {
|
||||
if o == cur {
|
||||
return opts[(i+1)%len(opts)]
|
||||
}
|
||||
}
|
||||
return opts[0]
|
||||
}
|
||||
219
view.go
Normal file
219
view.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Nerd Font glyphs (Omarchy terminals ship a Nerd Font).
|
||||
const (
|
||||
iconDownload = "" // nf-fa-download
|
||||
iconGear = "" // nf-fa-cog
|
||||
)
|
||||
|
||||
var (
|
||||
accent = lipgloss.Color("13") // magenta
|
||||
dim = lipgloss.Color("240")
|
||||
good = lipgloss.Color("10")
|
||||
bad = lipgloss.Color("9")
|
||||
fgFaint = lipgloss.Color("245")
|
||||
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent)
|
||||
tabOn = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("0")).Background(accent).Padding(0, 1)
|
||||
tabOff = lipgloss.NewStyle().Foreground(dim).Padding(0, 1)
|
||||
dimStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
faintStyle = lipgloss.NewStyle().Foreground(fgFaint)
|
||||
goodStyle = lipgloss.NewStyle().Foreground(good)
|
||||
badStyle = lipgloss.NewStyle().Foreground(bad)
|
||||
cursorStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||
helpStyle = lipgloss.NewStyle().Foreground(dim)
|
||||
flashStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
|
||||
)
|
||||
|
||||
func (m model) View() string {
|
||||
var body string
|
||||
if m.view == configView {
|
||||
body = m.configCard()
|
||||
} else {
|
||||
body = m.queueCard()
|
||||
}
|
||||
// Center the whole card horizontally and vertically in the window.
|
||||
if m.w > 0 && m.h > 0 {
|
||||
return lipgloss.Place(m.w, m.h, lipgloss.Center, lipgloss.Center, body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// width returns the usable terminal width, with a sane fallback before the
|
||||
// first WindowSizeMsg arrives.
|
||||
func (m model) width() int {
|
||||
if m.w > 0 {
|
||||
return m.w
|
||||
}
|
||||
return 72
|
||||
}
|
||||
|
||||
// cardWidth is the width of the centered content column.
|
||||
func (m model) cardWidth() int {
|
||||
w := m.width() - 8
|
||||
if w < 44 {
|
||||
w = 44
|
||||
}
|
||||
if w > 60 {
|
||||
w = 60
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (m model) tabs() string {
|
||||
if m.mode == audioMode {
|
||||
return tabOn.Render("Audio") + " " + tabOff.Render("Video")
|
||||
}
|
||||
return tabOff.Render("Audio") + " " + tabOn.Render("Video")
|
||||
}
|
||||
|
||||
func (m model) queueCard() string {
|
||||
cw := m.cardWidth()
|
||||
center := lipgloss.NewStyle().Width(cw).Align(lipgloss.Center)
|
||||
leftCol := lipgloss.NewStyle().Width(cw).Align(lipgloss.Left)
|
||||
|
||||
title := center.Render(titleStyle.Render(iconDownload + " omagrab"))
|
||||
tabs := center.Render(m.tabs())
|
||||
input := center.Render(m.input.View())
|
||||
|
||||
var queue string
|
||||
if len(m.items) == 0 {
|
||||
queue = center.Render(dimStyle.Render("queue empty — paste a URL above"))
|
||||
} else {
|
||||
rows := make([]string, len(m.items))
|
||||
for i, it := range m.items {
|
||||
rows[i] = m.renderItem(i, it, cw)
|
||||
}
|
||||
queue = leftCol.Render(strings.Join(rows, "\n"))
|
||||
}
|
||||
|
||||
help := center.Render(
|
||||
helpStyle.Render("tab audio/video · enter add · ↑↓ select · esc quit") + "\n" +
|
||||
helpStyle.Render("empty box → c config · d remove · q quit"))
|
||||
|
||||
parts := []string{title, tabs, "", input, "", queue, "", help}
|
||||
if m.flash != "" {
|
||||
parts = append(parts, "", center.Render(flashStyle.Render(m.flash)))
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Center, parts...)
|
||||
}
|
||||
|
||||
func (m model) renderItem(i int, it *item, w int) string {
|
||||
cur := " "
|
||||
if i == m.cursor {
|
||||
cur = cursorStyle.Render("▌ ")
|
||||
}
|
||||
|
||||
barW := 14
|
||||
if w < 72 {
|
||||
barW = 10
|
||||
}
|
||||
tailBudget := barW + 16 // bar + percent + speed, kept stable so names align
|
||||
|
||||
var icon, tail string
|
||||
switch it.status {
|
||||
case stQueued:
|
||||
icon = dimStyle.Render("○")
|
||||
tail = dimStyle.Render("queued")
|
||||
case stFetching:
|
||||
icon = m.spinner.View()
|
||||
tail = dimStyle.Render("fetching…")
|
||||
case stDownloading:
|
||||
icon = m.spinner.View()
|
||||
tail = bar(it.percent, barW) + " " + faintStyle.Render(it.speed)
|
||||
case stDone:
|
||||
icon = goodStyle.Render("✓")
|
||||
tail = goodStyle.Render("done")
|
||||
case stFailed:
|
||||
icon = badStyle.Render("✗")
|
||||
tail = badStyle.Render(truncate(it.err, tailBudget))
|
||||
}
|
||||
|
||||
nameW := w - 6 - tailBudget
|
||||
if nameW < 16 {
|
||||
nameW = 16
|
||||
}
|
||||
if nameW > 50 {
|
||||
nameW = 50
|
||||
}
|
||||
name := truncate(it.label(), nameW)
|
||||
return fmt.Sprintf("%s%s %-*s %s", cur, icon, nameW, name, tail)
|
||||
}
|
||||
|
||||
func (m model) configCard() string {
|
||||
cw := m.cardWidth()
|
||||
center := lipgloss.NewStyle().Width(cw).Align(lipgloss.Center)
|
||||
leftCol := lipgloss.NewStyle().Width(cw).Align(lipgloss.Left)
|
||||
|
||||
title := center.Render(titleStyle.Render(iconGear + " omagrab · config"))
|
||||
|
||||
fields := []struct{ label, val string }{
|
||||
{"Audio format", m.cfg.AudioFormat},
|
||||
{"Video quality", m.cfg.VideoQuality},
|
||||
{"Subtitles", onoff(m.cfg.Subtitles)},
|
||||
}
|
||||
rows := make([]string, len(fields))
|
||||
for i, f := range fields {
|
||||
marker := " "
|
||||
val := f.val
|
||||
if i == m.cfgCur {
|
||||
marker = cursorStyle.Render("▌ ")
|
||||
val = cursorStyle.Render("‹ " + f.val + " ›")
|
||||
}
|
||||
rows[i] = fmt.Sprintf("%s%-16s %s", marker, f.label, val)
|
||||
}
|
||||
block := leftCol.Render(strings.Join(rows, "\n"))
|
||||
|
||||
dirs := center.Render(
|
||||
faintStyle.Render("audio → "+m.cfg.AudioDir) + "\n" +
|
||||
faintStyle.Render("video → "+m.cfg.VideoDir))
|
||||
help := center.Render(helpStyle.Render("↑↓ move · ←→ change · esc save & back"))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Center, title, "", block, "", dirs, "", help)
|
||||
}
|
||||
|
||||
// bar renders a simple unicode progress bar.
|
||||
func bar(frac float64, width int) string {
|
||||
if frac < 0 {
|
||||
frac = 0
|
||||
}
|
||||
if frac > 1 {
|
||||
frac = 1
|
||||
}
|
||||
filled := int(frac * float64(width))
|
||||
full := lipgloss.NewStyle().Foreground(accent).Render(strings.Repeat("█", filled))
|
||||
empty := dimStyle.Render(strings.Repeat("░", width-filled))
|
||||
return full + empty + fmt.Sprintf(" %3.0f%%", frac*100)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 1 {
|
||||
return string(r[:n])
|
||||
}
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
|
||||
func pad(s string, n int) string {
|
||||
if len(s) >= n {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", n-len(s))
|
||||
}
|
||||
|
||||
func onoff(b bool) string {
|
||||
if b {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue