feeds it URLs. spits out videos. no questions asked.
A desktop GUI wrapper for yt-dlp built with Dioxus in Rust.
No more memorizing flags. No more copy-pasting commands. Just paste, click, eat.
nomnom is a point-and-click frontend for yt-dlp.
Instead of typing (and forgetting) flags every time, you:
- Paste a URL
- Pick a preset or toggle individual flags
- Choose where to save
- Hit download
That's it. It streams the output live so you can watch it nom.
You need yt-dlp installed and available in your PATH.
# macOS
brew install yt-dlp
# Linux
pip install yt-dlp
# or grab the binary: https://github.com/yt-dlp/yt-dlp/releases
# Windows
winget install yt-dlpThen clone and run:
git clone https://github.com/seyallius/nomnom
cd nomnom
cargo run --release| Thing | Details |
|---|---|
| Presets | One-click configs for common tasks (Best Video, Audio Only, Playlist, Subtitles, Full Archive) |
| Flag toggles | Every major yt-dlp flag as a button, grouped by category |
| Command preview | See the exact command that will run, live, before you hit download |
| Folder picker | Native OS folder dialog β no typing paths |
| Terminal panel | Drop down to raw yt-dlp commands when you need full control |
| Live log | Streamed stdout/stderr with color-coded lines |
| Async | Downloads run in the background; UI never freezes |
nomnom/
βββ Cargo.toml
βββ src/
βββ main.rs # window config, app launch
βββ app.rs # root component, all shared state lives here
β
βββ core/ # pure logic, no UI
β βββ mod.rs
β βββ flags.rs # every yt-dlp flag definition
β βββ presets.rs # preset collections (bundles of flags)
β βββ runner.rs # spawns yt-dlp, streams output
β
βββ components/ # UI components, one per concern
βββ mod.rs
βββ flag_panel.rs # left sidebar: flag toggle buttons
βββ output_log.rs # bottom: live log output
βββ preset_panel.rs # left sidebar: preset cards
βββ terminal_panel.rs # middle: raw command input
βββ url_bar.rs # top: URL input, folder picker, download button
app.rs (owns all Signals)
β
ββββΆ preset_panel reads/writes active_preset, active_flags
ββββΆ flag_panel reads/writes active_flags
ββββΆ url_bar reads/writes url, output_dir
β reads active_flags, built_command
β writes log_lines, is_running
ββββΆ terminal_panel reads/writes is_running, log_lines
ββββΆ output_log reads log_lines
All state is Signal<T> defined in app.rs and passed down as props.
No global state, no context magic. If you can read props, you can read the app.
This section is the whole point of this README.
Each extension has exactly one place to touch.
Open src/core/flags.rs.
Find the all_flags() function. Add your flag to the right category:
Flag {
flag: "--sponsorblock-remove all",
label: "Skip Sponsors",
description: "Remove sponsor segments via SponsorBlock",
category: FlagCategory::Misc,
},That's it. It will automatically appear as a toggle button in the UI under the correct category group.
No component changes needed.
Still in src/core/flags.rs:
- Add a variant to the
FlagCategoryenum:
pub enum FlagCategory {
// ...existing...
Chapters, // π new
}- Give it a label in the
label()method:
FlagCategory::Chapters => "π Chapters",- Add it to the ordered list in
src/components/flag_panel.rs:
let categories: Vec<FlagCategory> = vec![
// ...existing...
FlagCategory::Chapters, // π add it where you want it to appear
];- Tag your new flags with
category: FlagCategory::Chaptersinflags.rs.
Done. The panel renders it as a new group automatically.
Open src/core/presets.rs.
Add a new Preset to the all_presets() vec:
Preset {
id: "music_video", // unique, used for equality checks
label: "Music Video",
description: "Best video + audio, embed thumbnail, no playlist",
icon: "πΈ",
flag_keys: vec![
"--add-metadata",
"--add-thumbnail",
"--no-playlist",
"--merge-output-format mp4",
],
},flag_keys are matched against Flag::flag strings defined in flags.rs.
The preset card appears in the sidebar automatically. No UI code to touch.
Tip: if you reference a flag key that doesn't exist in
all_flags(),
it is silently skipped.resolve_preset_flags()usesfilter_map.
In src/core/presets.rs, default_preset() returns the first preset:
pub fn default_preset() -> Preset {
all_presets().into_iter().next().unwrap()
}Change .next() to .find(|p| p.id == "audio_only") to boot into a different preset:
pub fn default_preset() -> Preset {
all_presets()
.into_iter()
.find(|p| p.id == "audio_only")
.unwrap()
}Open src/core/runner.rs.
build_command_string() builds the preview string.
run_download() builds the actual args: Vec<String> passed to the subprocess.
Both live next to each other. If you want to add a fixed flag that always runs
(e.g., always pass --no-warnings), add it to the args vec in run_download():
let mut args: Vec<String> = vec![
"-o".to_string(),
output_template,
"--no-warnings".to_string(), // π always present
];- Create
src/components/your_panel.rs - Define a
#[component]function with aPropsstruct - Register it in
src/components/mod.rs:
pub mod your_panel;- Import and place it in
src/app.rswhere you want it in the layout.
Pass whateverSignal<T>it needs as props β they're all defined at the top ofApp().
The layout in app.rs is plain flexbox divs. The left sidebar and right column
are clearly commented. Drop your component where it makes visual sense.
Nothing is persisted yet. A good place to add it:
- Read on startup: inside
App()inapp.rs, replace theuse_signal(|| ...)defaults
with values loaded from a config file. - Write on change: use
use_effectinapp.rsto watch signals and serialize on change. - Config format suggestion:
~/.config/nomnom/config.jsonviadirs::config_dir()(already in deps).
The Flag, Preset, and their fields all derive Serialize / Deserialize already.
In src/core/runner.rs, find:
rust let output_template = format!("{}/%(title)s.%(ext)s", output_dir.trim_end_matches('/'));
%(title)s, %(uploader)s, %(upload_date)s, %(id)s etc. are yt-dlp
output template fields.
Change the format string to whatever naming convention you want.
A future extension: expose this as a text input in url_bar.rs.
-
core/knows nothing about Dioxus. It is plain Rust logic.
You can test it, reuse it, or swap the frontend without touching it. -
components/know nothing aboutyt-dlpinternals.
They render state and fire events. That's all. -
app.rsis the glue. It owns state and wires components together.
If something feels hard to connect, the answer is usually: lift the signal toapp.rs. -
Signals flow down, events bubble up via callbacks or by passing writable signals as props.
MIT. eat freely.