foxygit / ytmdl Log in
commit d62f5f12bb830bea609c9f395e28e05ec076d84e
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Wed Aug 12 19:49:44 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Wed Aug 12 19:49:44 2026 +0200

    Initial commit
---
 .gitignore              |   9 ++
 README-python.md        |  68 ++++++++++
 README.md               |  23 ++++
 c/Makefile              |  20 +++
 c/README.md             |  98 ++++++++++++++
 c/src/app.c             | 273 ++++++++++++++++++++++++++++++++++++++
 c/src/app.h             |  56 ++++++++
 c/src/colors.c          |  31 +++++
 c/src/colors.h          |  31 +++++
 c/src/dirpicker.c       | 166 +++++++++++++++++++++++
 c/src/dirpicker.h       |  12 ++
 c/src/downloader.c      | 239 +++++++++++++++++++++++++++++++++
 c/src/downloader.h      |  26 ++++
 c/src/icons.h           |  15 +++
 c/src/input_field.c     |  92 +++++++++++++
 c/src/input_field.h     |  25 ++++
 c/src/main.c            |  90 +++++++++++++
 c/src/queue.c           | 118 +++++++++++++++++
 c/src/queue.h           |  59 +++++++++
 c/src/ui.c              | 345 ++++++++++++++++++++++++++++++++++++++++++++++++
 c/src/ui.h              |  32 +++++
 c/src/util.c            |  82 ++++++++++++
 c/src/util.h            |  17 +++
 pyproject.toml          |  19 +++
 src/ytmdl/__init__.py   |   3 +
 src/ytmdl/__main__.py   |  36 +++++
 src/ytmdl/app.py        | 327 +++++++++++++++++++++++++++++++++++++++++++++
 src/ytmdl/downloader.py | 114 ++++++++++++++++
 src/ytmdl/queue.py      |  35 +++++
 src/ytmdl/screens.py    | 129 ++++++++++++++++++
 start-python.sh         |  11 ++
 start.sh                |  19 +++
 32 files changed, 2620 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..16201a2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.venv/
+__pycache__/
+*.pyc
+*.egg-info/
+build/
+dist/
+
+c/ytmdl
+c/src/*.o
diff --git a/README-python.md b/README-python.md
new file mode 100644
index 0000000..81bd23d
--- /dev/null
+++ b/README-python.md
@@ -0,0 +1,68 @@
+# ytmdl
+
+A terminal UI for downloading audio from YouTube / YouTube Music video, playlist, and
+album URLs, built on [Textual](https://github.com/Textualize/textual) and
+[yt-dlp](https://github.com/yt-dlp/yt-dlp).
+
+## Requirements
+
+- Python 3.10+
+- [ffmpeg](https://ffmpeg.org/) on your `PATH` (used for audio extraction, thumbnail
+  embedding, and metadata tagging)
+- A [Nerd Font](https://www.nerdfonts.com/) in your terminal — the folder browser, the
+  queue status column, and the command palette all use Nerd Font glyphs; without one
+  they'll show as missing-glyph boxes
+
+## Install
+
+```bash
+python3 -m venv .venv
+.venv/bin/pip install -e .
+```
+
+## Run
+
+```bash
+.venv/bin/ytmdl
+```
+
+Or without installing the entry point:
+
+```bash
+.venv/bin/python -m ytmdl
+```
+
+Paste a video, playlist, or album URL into the input field and press Enter. Playlists
+and albums are expanded into individual tracks, each downloaded and tagged separately,
+with up to 2 downloads running concurrently.
+
+The "Saving to:" link below the URL box shows where files are saved. Click it (or press
+`Ctrl+O`) to open a folder browser and pick a new destination for any downloads queued
+from that point on (in-flight downloads keep going to the old directory).
+
+The "Create a folder per playlist/album" switch (on by default) controls whether tracks
+from a playlist or album URL get saved directly into the save directory, or nested in a
+subfolder named after the playlist/album title. Single-video URLs are never nested.
+
+### Options
+
+```
+ytmdl [-o OUTPUT_DIR] [-f {mp3,m4a,flac,opus,wav,vorbis}]
+```
+
+- `-o/--output-dir` — where files are saved (default `~/Music/ytmdl`)
+- `-f/--format` — output audio format (default `mp3`)
+
+### Keybindings
+
+- `Enter` — submit the URL in the input box
+- `Ctrl+O` — open the folder browser to change the download directory
+  - `Backspace` or the `Up` button — go to the parent folder
+  - `Esc` — cancel without changing the directory
+- `Ctrl+N` — clear finished: remove every completed/errored row from the queue in one go,
+  so you can start a fresh batch. Works no matter what has focus. Also available from the
+  command palette (`Ctrl+P` → "Clear finished").
+- `d` / `Delete` — remove the selected queue entry (not while it's actively downloading;
+  select a row first by clicking the table or pressing `Tab` until it's focused)
+- `r` — retry a queue entry that errored (same focus requirement as above)
+- `q` — quit
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..13625da
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# ytmdl
+
+A terminal UI for downloading audio from YouTube / YouTube Music video, playlist, and
+album URLs.
+
+The primary implementation is now **C + ncurses**, in [`c/`](c/) — see
+[`c/README.md`](c/README.md) for build/run instructions, requirements, and keybindings.
+
+```bash
+./start.sh
+```
+
+builds (if needed) and runs it.
+
+## Earlier Python version
+
+This project started as a Python + [Textual](https://github.com/Textualize/textual)
+implementation; it's kept in place (unchanged, still functional) rather than deleted.
+See [`README-python.md`](README-python.md) for its docs, and run it with
+`./start-python.sh`. Both versions shell out to
+[yt-dlp](https://github.com/yt-dlp/yt-dlp) for the actual extraction/downloading and
+share the same feature set (URL/playlist queue, concurrent downloads, folder picker,
+per-playlist subfolders, embedded thumbnail/metadata).
diff --git a/c/Makefile b/c/Makefile
new file mode 100644
index 0000000..0a23a7d
--- /dev/null
+++ b/c/Makefile
@@ -0,0 +1,20 @@
+CC = gcc
+CFLAGS = -std=c11 -Wall -Wextra $(shell pkg-config --cflags ncursesw) -D_DEFAULT_SOURCE
+LDFLAGS = $(shell pkg-config --libs ncursesw) -lpthread
+
+SRC = src/main.c src/app.c src/queue.c src/downloader.c src/ui.c src/input_field.c src/dirpicker.c src/util.c src/colors.c
+OBJ = $(SRC:.c=.o)
+BIN = ytmdl
+
+.PHONY: all clean
+
+all: $(BIN)
+
+$(BIN): $(OBJ)
+	$(CC) $(OBJ) -o $(BIN) $(LDFLAGS)
+
+%.o: %.c
+	$(CC) $(CFLAGS) -c $< -o $@
+
+clean:
+	rm -f $(OBJ) $(BIN)
diff --git a/c/README.md b/c/README.md
new file mode 100644
index 0000000..a3c45ca
--- /dev/null
+++ b/c/README.md
@@ -0,0 +1,98 @@
+# ytmdl (C)
+
+A terminal UI for downloading audio from YouTube / YouTube Music video, playlist, and
+album URLs, written in C with ncurses. All the actual extraction/downloading is done by
+shelling out to [yt-dlp](https://github.com/yt-dlp/yt-dlp) — this program is the queue,
+the TUI, and the orchestration around it.
+
+## Requirements
+
+- gcc (or another C11 compiler) and `make`
+- `ncursesw` development headers (`libncursesw5-dev` / `ncurses-devel` depending on distro)
+- `pthread` (part of glibc on Linux)
+- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on your `PATH` — needs to be reasonably
+  recent, since YouTube changes frequently break older releases. An outdated version can
+  fail to resolve *any* playlist and surface confusing errors (e.g. "YouTube Music is
+  not directly supported" or zero entries found) even though the URL itself is fine —
+  it's the extractor being too old to parse YouTube's current page format, not an actual
+  YouTube Music limitation. Update with `yt-dlp -U` or `pip install -U yt-dlp`.
+  `./start.sh` (from the repo root) automatically prefers the copy kept up to date in
+  `../.venv/bin` over an older system package, if present.
+- [ffmpeg](https://ffmpeg.org/) on your `PATH` (yt-dlp uses it for audio extraction,
+  thumbnail embedding, and metadata tagging)
+- A [Nerd Font](https://www.nerdfonts.com/) in your terminal — the queue status column
+  and the folder browser use Nerd Font glyphs; without one they'll show as missing-glyph
+  boxes
+- A terminal with color support for the best look (color-coded status icons/progress
+  bars, focus-highlighted panel borders); the UI still works without it, just flatter
+
+## Build
+
+```bash
+make
+```
+
+Produces a single `ytmdl` binary in this directory.
+
+## Run
+
+```bash
+./ytmdl
+```
+
+or from the repo root: `./start.sh` (builds if needed, then runs it).
+
+Paste a video, playlist, or album URL into the URL field and press Enter. Playlists and
+albums are expanded into individual tracks, each downloaded and tagged separately, with
+up to 2 downloads running concurrently.
+
+### Options
+
+```
+./ytmdl [-o output_dir] [-f audio_format]
+```
+
+- `-o` — where files are saved (default `~/Music/ytmdl`)
+- `-f` — output audio format: `mp3`, `m4a`, `flac`, `opus`, `wav`, `vorbis` (default `mp3`)
+
+### Keybindings
+
+Global (work no matter what has focus):
+
+- `Ctrl+O` — open a folder browser to change the download directory
+  - `Up`/`Down` — move selection, `Enter`/`Right` — open the highlighted folder,
+    `Backspace`/`Left` — go to the parent folder, `s` — select the currently browsed
+    folder, `Esc` — cancel
+- `Ctrl+F` — toggle "create a folder per playlist/album" (on by default; named after the
+  playlist/album title; single videos are never nested)
+- `Ctrl+N` — clear finished: remove every successfully completed row from the queue
+  (errored rows stay, so you can still see what failed and retry with `r`)
+- `Ctrl+Q` — quit
+- `Tab` — switch focus between the URL field and the queue table
+
+URL field focused:
+
+- `Enter` — submit the URL
+
+Queue table focused:
+
+- `Up`/`Down` — move the selected row
+- `d` / `Delete` — remove the selected row (not while it's actively downloading)
+- `r` — retry a row that errored
+
+## Architecture
+
+- `queue.c/.h` — thread-safe queue of `QueueItem`s (id, url, title, subfolder, status,
+  progress), a fixed-capacity array behind a mutex so worker threads can hold stable ids
+  across UI-driven removals.
+- `downloader.c/.h` — spawns `yt-dlp` via `fork`/`exec`, parsing structured
+  `--print`-template output (unit-separator-delimited fields) to resolve playlists, and
+  `--newline` progress lines to track download percent/speed/eta.
+- `app.c/.h` — app state, settings (output dir, audio format, playlist-folder toggle), a
+  small fixed pool of worker threads pulling from a work queue, and the actions the UI
+  calls into (submit URL, clear finished, remove/retry).
+- `ui.c/.h`, `input_field.c/.h`, `dirpicker.c/.h` — ncurses rendering and input handling.
+- `icons.h` — Nerd Font glyph codepoints, written as `\uXXXX` escapes.
+
+There is no equivalent of Textual's command palette in this version — every action has a
+direct, always-available keybinding instead.
diff --git a/c/src/app.c b/c/src/app.c
new file mode 100644
index 0000000..c589c77
--- /dev/null
+++ b/c/src/app.c
@@ -0,0 +1,273 @@
+#include "app.h"
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "downloader.h"
+#include "util.h"
+
+#define MESSAGE_TTL_TICKS 30 /* ~ a few seconds at the UI's redraw rate */
+#define DOWNLOAD_MAX_ATTEMPTS 3
+#define DOWNLOAD_RETRY_DELAY_SECS 2
+
+void app_init(App *app, const char *output_dir, const char *audio_format) {
+    memset(app, 0, sizeof(*app));
+    queue_init(&app->queue);
+    pthread_mutex_init(&app->settings_lock, NULL);
+    pthread_mutex_init(&app->work_lock, NULL);
+    pthread_cond_init(&app->work_cond, NULL);
+    pthread_mutex_init(&app->message_lock, NULL);
+
+    safe_strcpy(app->output_dir, sizeof(app->output_dir), output_dir);
+    safe_strcpy(app->audio_format, sizeof(app->audio_format), audio_format);
+    app->playlist_folders = 1;
+}
+
+/* ---- settings ---------------------------------------------------------- */
+
+void app_set_output_dir(App *app, const char *path) {
+    pthread_mutex_lock(&app->settings_lock);
+    safe_strcpy(app->output_dir, sizeof(app->output_dir), path);
+    pthread_mutex_unlock(&app->settings_lock);
+    app_notify(app, 0, "Now saving to %s", path);
+}
+
+void app_get_output_dir(App *app, char *out, size_t cap) {
+    pthread_mutex_lock(&app->settings_lock);
+    safe_strcpy(out, cap, app->output_dir);
+    pthread_mutex_unlock(&app->settings_lock);
+}
+
+void app_set_playlist_folders(App *app, int enabled) {
+    pthread_mutex_lock(&app->settings_lock);
+    app->playlist_folders = enabled;
+    pthread_mutex_unlock(&app->settings_lock);
+}
+
+int app_get_playlist_folders(App *app) {
+    pthread_mutex_lock(&app->settings_lock);
+    int v = app->playlist_folders;
+    pthread_mutex_unlock(&app->settings_lock);
+    return v;
+}
+
+static void app_get_audio_format(App *app, char *out, size_t cap) {
+    pthread_mutex_lock(&app->settings_lock);
+    safe_strcpy(out, cap, app->audio_format);
+    pthread_mutex_unlock(&app->settings_lock);
+}
+
+/* ---- messages ------------------------------------------------------------ */
+
+void app_notify(App *app, int is_error, const char *fmt, ...) {
+    char buf[MESSAGE_CAP];
+    va_list ap;
+    va_start(ap, fmt);
+    vsnprintf(buf, sizeof(buf), fmt, ap);
+    va_end(ap);
+
+    pthread_mutex_lock(&app->message_lock);
+    safe_strcpy(app->message, sizeof(app->message), buf);
+    app->message_ttl_ticks = MESSAGE_TTL_TICKS;
+    app->message_is_error = is_error;
+    pthread_mutex_unlock(&app->message_lock);
+}
+
+void app_get_message(App *app, char *out, size_t cap, int *is_error) {
+    pthread_mutex_lock(&app->message_lock);
+    if (app->message_ttl_ticks > 0) {
+        safe_strcpy(out, cap, app->message);
+        if (is_error) *is_error = app->message_is_error;
+        app->message_ttl_ticks--;
+    } else {
+        if (cap > 0) out[0] = '\0';
+        if (is_error) *is_error = 0;
+    }
+    pthread_mutex_unlock(&app->message_lock);
+}
+
+/* ---- work queue ---------------------------------------------------------- */
+
+static void app_enqueue_work(App *app, int id) {
+    pthread_mutex_lock(&app->work_lock);
+    if (app->work_count < MAX_QUEUE_ITEMS) {
+        app->work_ids[app->work_tail] = id;
+        app->work_tail = (app->work_tail + 1) % MAX_QUEUE_ITEMS;
+        app->work_count++;
+        pthread_cond_signal(&app->work_cond);
+    }
+    pthread_mutex_unlock(&app->work_lock);
+}
+
+/* ---- resolving ------------------------------------------------------------ */
+
+struct entry_ctx {
+    App *app;
+    int playlist_folders;
+    char *playlist_title; /* shared buffer, populated by downloader_resolve as entries stream in */
+};
+
+static void entry_trampoline(void *ud, const char *url, const char *title) {
+    struct entry_ctx *ctx = ud;
+    char subdir[SUBDIR_CAP] = "";
+    if (ctx->playlist_folders && ctx->playlist_title[0]) {
+        sanitize_dirname(ctx->playlist_title, subdir, sizeof(subdir));
+    }
+    int id = queue_add(&ctx->app->queue, url, title, subdir);
+    if (id >= 0) app_enqueue_work(ctx->app, id);
+}
+
+struct resolve_thread_arg {
+    App *app;
+    char url[URL_CAP];
+};
+
+static void *resolver_thread_fn(void *arg) {
+    struct resolve_thread_arg *rta = arg;
+    App *app = rta->app;
+
+    struct entry_ctx ectx;
+    ectx.app = app;
+    ectx.playlist_folders = app_get_playlist_folders(app);
+    char playlist_title[TITLE_CAP] = "";
+    ectx.playlist_title = playlist_title;
+
+    char err[DL_ERROR_CAP];
+    int rc = downloader_resolve(rta->url, entry_trampoline, &ectx, playlist_title,
+                                 sizeof(playlist_title), err, sizeof(err));
+    if (rc != 0) {
+        app_notify(app, 1, "Failed to resolve: %s", err);
+    }
+
+    free(rta);
+    return NULL;
+}
+
+void app_submit_url(App *app, const char *url) {
+    struct resolve_thread_arg *rta = malloc(sizeof(*rta));
+    rta->app = app;
+    safe_strcpy(rta->url, sizeof(rta->url), url);
+
+    app_notify(app, 0, "Resolving %s...", url);
+
+    pthread_t t;
+    pthread_create(&t, NULL, resolver_thread_fn, rta);
+    pthread_detach(t);
+}
+
+/* ---- downloading ----------------------------------------------------------- */
+
+struct progress_ud {
+    Queue *q;
+    int id;
+};
+
+static void progress_trampoline(void *ud, float percent, const char *speed, const char *eta) {
+    struct progress_ud *p = ud;
+    queue_set_progress(p->q, p->id, percent, speed, eta);
+}
+
+static void process_item(App *app, int id) {
+    QueueItem item;
+    if (!queue_get(&app->queue, id, &item)) return;
+    if (item.status == STATUS_DONE) return;
+
+    queue_set_status(&app->queue, id, STATUS_DOWNLOADING, "");
+
+    char output_dir[OUTPUT_DIR_CAP];
+    char audio_format[AUDIO_FORMAT_CAP];
+    app_get_output_dir(app, output_dir, sizeof(output_dir));
+    app_get_audio_format(app, audio_format, sizeof(audio_format));
+
+    char target_dir[OUTPUT_DIR_CAP + SUBDIR_CAP + 2];
+    if (item.subdir[0]) {
+        snprintf(target_dir, sizeof(target_dir), "%s/%s", output_dir, item.subdir);
+    } else {
+        safe_strcpy(target_dir, sizeof(target_dir), output_dir);
+    }
+
+    struct progress_ud pud = {.q = &app->queue, .id = id};
+    char err[DL_ERROR_CAP];
+    int rc = -1;
+
+    /* YouTube occasionally returns a transient HTTP 403 on the signed media
+     * URL, especially under concurrent downloads; a fresh extraction
+     * (a brand new yt-dlp invocation) gets a new URL and usually succeeds.
+     * Retry a couple of times with a short backoff before giving up. */
+    for (int attempt = 0; attempt < DOWNLOAD_MAX_ATTEMPTS; attempt++) {
+        if (attempt > 0) sleep(DOWNLOAD_RETRY_DELAY_SECS);
+        rc = downloader_download(item.url, target_dir, audio_format, progress_trampoline, &pud,
+                                  err, sizeof(err));
+        if (rc == 0) break;
+    }
+
+    if (rc == 0) {
+        queue_set_status(&app->queue, id, STATUS_DONE, NULL);
+    } else {
+        queue_set_status(&app->queue, id, STATUS_ERROR, err);
+    }
+}
+
+static void *worker_thread_fn(void *arg) {
+    App *app = arg;
+    for (;;) {
+        pthread_mutex_lock(&app->work_lock);
+        while (app->work_count == 0 && !app->shutdown) {
+            pthread_cond_wait(&app->work_cond, &app->work_lock);
+        }
+        if (app->shutdown && app->work_count == 0) {
+            pthread_mutex_unlock(&app->work_lock);
+            break;
+        }
+        int id = app->work_ids[app->work_head];
+        app->work_head = (app->work_head + 1) % MAX_QUEUE_ITEMS;
+        app->work_count--;
+        pthread_mutex_unlock(&app->work_lock);
+
+        process_item(app, id);
+    }
+    return NULL;
+}
+
+void app_start_workers(App *app) {
+    for (int i = 0; i < CONCURRENT_DOWNLOADS; i++) {
+        pthread_create(&app->workers[i], NULL, worker_thread_fn, app);
+    }
+}
+
+void app_shutdown(App *app) {
+    pthread_mutex_lock(&app->work_lock);
+    app->shutdown = 1;
+    pthread_cond_broadcast(&app->work_cond);
+    pthread_mutex_unlock(&app->work_lock);
+
+    for (int i = 0; i < CONCURRENT_DOWNLOADS; i++) {
+        pthread_join(app->workers[i], NULL);
+    }
+}
+
+/* ---- queue actions ----------------------------------------------------------- */
+
+void app_clear_finished(App *app) {
+    int n = queue_clear_finished(&app->queue);
+    if (n > 0) {
+        app_notify(app, 0, "Cleared %d finished item(s)", n);
+    } else {
+        app_notify(app, 0, "Nothing to clear");
+    }
+}
+
+void app_remove_item(App *app, int id) {
+    queue_remove(&app->queue, id);
+}
+
+void app_retry_item(App *app, int id) {
+    QueueItem item;
+    if (!queue_get(&app->queue, id, &item)) return;
+    if (item.status != STATUS_ERROR) return;
+    queue_reset_for_retry(&app->queue, id);
+    app_enqueue_work(app, id);
+}
diff --git a/c/src/app.h b/c/src/app.h
new file mode 100644
index 0000000..c83e995
--- /dev/null
+++ b/c/src/app.h
@@ -0,0 +1,56 @@
+#ifndef YTMDL_APP_H
+#define YTMDL_APP_H
+
+#include <pthread.h>
+
+#include "queue.h"
+
+#define CONCURRENT_DOWNLOADS 2
+#define OUTPUT_DIR_CAP 1024
+#define AUDIO_FORMAT_CAP 16
+#define MESSAGE_CAP 256
+
+typedef struct {
+    Queue queue;
+
+    pthread_mutex_t settings_lock;
+    char output_dir[OUTPUT_DIR_CAP];
+    int playlist_folders;
+    char audio_format[AUDIO_FORMAT_CAP];
+
+    int work_ids[MAX_QUEUE_ITEMS];
+    int work_head, work_tail, work_count;
+    pthread_mutex_t work_lock;
+    pthread_cond_t work_cond;
+    int shutdown;
+    pthread_t workers[CONCURRENT_DOWNLOADS];
+
+    pthread_mutex_t message_lock;
+    char message[MESSAGE_CAP];
+    int message_ttl_ticks;
+    int message_is_error;
+} App;
+
+void app_init(App *app, const char *output_dir, const char *audio_format);
+void app_start_workers(App *app);
+void app_shutdown(App *app);
+
+/* Spawns a detached thread to resolve+enqueue the given URL. */
+void app_submit_url(App *app, const char *url);
+
+void app_set_output_dir(App *app, const char *path);
+void app_get_output_dir(App *app, char *out, size_t cap);
+
+void app_set_playlist_folders(App *app, int enabled);
+int app_get_playlist_folders(App *app);
+
+void app_clear_finished(App *app);
+void app_remove_item(App *app, int id);
+void app_retry_item(App *app, int id);
+
+void app_notify(App *app, int is_error, const char *fmt, ...);
+/* Copies the current message into out (empty string if none/expired) and
+ * ticks its remaining lifetime down by one. */
+void app_get_message(App *app, char *out, size_t cap, int *is_error);
+
+#endif
diff --git a/c/src/colors.c b/c/src/colors.c
new file mode 100644
index 0000000..dde582f
--- /dev/null
+++ b/c/src/colors.c
@@ -0,0 +1,31 @@
+#include "colors.h"
+
+void colors_init(void) {
+    if (!has_colors()) return;
+    start_color();
+    use_default_colors();
+
+    init_pair(PAIR_BORDER, COLOR_BLUE, -1);
+    init_pair(PAIR_BORDER_FOCUS, COLOR_CYAN, -1);
+    init_pair(PAIR_TITLE, COLOR_CYAN, -1);
+    init_pair(PAIR_STATUS_PENDING, COLOR_YELLOW, -1);
+    init_pair(PAIR_STATUS_DOWNLOADING, COLOR_CYAN, -1);
+    init_pair(PAIR_STATUS_DONE, COLOR_GREEN, -1);
+    init_pair(PAIR_STATUS_ERROR, COLOR_RED, -1);
+    init_pair(PAIR_PROGRESS_FILLED, COLOR_GREEN, -1);
+    init_pair(PAIR_MSG_ERROR, COLOR_RED, -1);
+    init_pair(PAIR_MSG_INFO, COLOR_GREEN, -1);
+}
+
+void draw_rounded_box(WINDOW *win, int pair) {
+    int has_pair = pair > 0 && has_colors();
+    if (has_pair) wattron(win, COLOR_PAIR(pair));
+    box(win, 0, 0);
+    int h, w;
+    getmaxyx(win, h, w);
+    mvwaddstr(win, 0, 0, "╭");         /* rounded top-left */
+    mvwaddstr(win, 0, w - 1, "╮");     /* rounded top-right */
+    mvwaddstr(win, h - 1, 0, "╰");     /* rounded bottom-left */
+    mvwaddstr(win, h - 1, w - 1, "╯"); /* rounded bottom-right */
+    if (has_pair) wattroff(win, COLOR_PAIR(pair));
+}
diff --git a/c/src/colors.h b/c/src/colors.h
new file mode 100644
index 0000000..931ac0e
--- /dev/null
+++ b/c/src/colors.h
@@ -0,0 +1,31 @@
+#ifndef YTMDL_COLORS_H
+#define YTMDL_COLORS_H
+
+#include <ncurses.h>
+
+enum {
+    PAIR_BORDER = 1,
+    PAIR_BORDER_FOCUS,
+    PAIR_TITLE,
+    PAIR_STATUS_PENDING,
+    PAIR_STATUS_DOWNLOADING,
+    PAIR_STATUS_DONE,
+    PAIR_STATUS_ERROR,
+    PAIR_PROGRESS_FILLED,
+    PAIR_MSG_ERROR,
+    PAIR_MSG_INFO,
+};
+
+/* Unicode block-element glyphs for progress bars (not Nerd-Font-specific;
+ * broadly supported wherever UTF-8 is). */
+#define BLOCK_FULL "█"
+#define BLOCK_LIGHT "░"
+
+/* Sets up color pairs. Safe to call even on a monochrome terminal (no-op). */
+void colors_init(void);
+
+/* Draws a box with rounded corners around win, in the given color pair
+ * (or no color if pair <= 0). */
+void draw_rounded_box(WINDOW *win, int pair);
+
+#endif
diff --git a/c/src/dirpicker.c b/c/src/dirpicker.c
new file mode 100644
index 0000000..9c45cfc
--- /dev/null
+++ b/c/src/dirpicker.c
@@ -0,0 +1,166 @@
+#include "dirpicker.h"
+
+#include <dirent.h>
+#include <ncurses.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+
+#include "colors.h"
+#include "icons.h"
+#include "util.h"
+
+#define MAX_ENTRIES 1024
+#define NAME_CAP 256
+
+static int cmp_names(const void *a, const void *b) {
+    return strcasecmp((const char *)a, (const char *)b);
+}
+
+static int list_subdirs(const char *dir, char entries[][NAME_CAP], int max) {
+    DIR *d = opendir(dir);
+    if (!d) return 0;
+    int n = 0;
+    struct dirent *de;
+    while (n < max && (de = readdir(d)) != NULL) {
+        if (de->d_name[0] == '.') continue;
+        char full[2048];
+        snprintf(full, sizeof(full), "%s/%s", dir, de->d_name);
+        struct stat st;
+        if (stat(full, &st) != 0 || !S_ISDIR(st.st_mode)) continue;
+        safe_strcpy(entries[n], NAME_CAP, de->d_name);
+        n++;
+    }
+    closedir(d);
+    qsort(entries, n, NAME_CAP, cmp_names);
+    return n;
+}
+
+static void nearest_existing_dir(const char *start, char *out, size_t cap) {
+    char path[1024];
+    safe_strcpy(path, sizeof(path), start);
+    struct stat st;
+    while (stat(path, &st) != 0 || !S_ISDIR(st.st_mode)) {
+        char *slash = strrchr(path, '/');
+        if (!slash || slash == path) {
+            safe_strcpy(path, sizeof(path), getenv("HOME") ? getenv("HOME") : "/");
+            break;
+        }
+        *slash = '\0';
+        if (path[0] == '\0') safe_strcpy(path, sizeof(path), "/");
+    }
+    safe_strcpy(out, cap, path);
+}
+
+static void go_to_parent(char *dir) {
+    if (strcmp(dir, "/") == 0) return;
+    char *slash = strrchr(dir, '/');
+    if (!slash) return;
+    if (slash == dir) {
+        dir[1] = '\0';
+    } else {
+        *slash = '\0';
+    }
+}
+
+int dirpicker_run(const char *start_path, char *chosen, size_t cap) {
+    char current_dir[1024];
+    nearest_existing_dir(start_path, current_dir, sizeof(current_dir));
+
+    static char entries[MAX_ENTRIES][NAME_CAP];
+    int entry_count = list_subdirs(current_dir, entries, MAX_ENTRIES);
+    int selected = 0;
+
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+    int win_h = rows * 4 / 5;
+    int win_w = cols * 4 / 5;
+    int win_y = (rows - win_h) / 2;
+    int win_x = (cols - win_w) / 2;
+
+    WINDOW *win = newwin(win_h, win_w, win_y, win_x);
+    keypad(win, TRUE);
+
+    int result = 0;
+    int running = 1;
+    while (running) {
+        werase(win);
+        draw_rounded_box(win, PAIR_BORDER_FOCUS);
+        wattron(win, A_BOLD);
+        mvwprintw(win, 0, 2, " Choose a folder to save downloads to ");
+        wattroff(win, A_BOLD);
+
+        mvwprintw(win, 1, 2, "Current: %.*s", win_w - 14, current_dir);
+
+        int list_top = 3;
+        int list_h = win_h - list_top - 3;
+        int start_idx = 0;
+        if (selected >= list_h) start_idx = selected - list_h + 1;
+
+        if (entry_count == 0) {
+            wattron(win, A_DIM);
+            mvwprintw(win, list_top, 4, "(no subfolders)");
+            wattroff(win, A_DIM);
+        }
+        for (int row = 0; row < list_h && start_idx + row < entry_count; row++) {
+            int idx = start_idx + row;
+            if (idx == selected) wattron(win, A_REVERSE);
+            wattron(win, COLOR_PAIR(PAIR_STATUS_PENDING));
+            mvwprintw(win, list_top + row, 2, "%s", ICON_FOLDER);
+            wattroff(win, COLOR_PAIR(PAIR_STATUS_PENDING));
+            mvwprintw(win, list_top + row, 4, "%.*s", win_w - 8, entries[idx]);
+            if (idx == selected) wattroff(win, A_REVERSE);
+        }
+
+        wattron(win, A_DIM);
+        mvwprintw(win, win_h - 2, 2,
+                  "Up/Down move  Enter/Right open  Backspace/Left up  s select  Esc cancel");
+        wattroff(win, A_DIM);
+        wnoutrefresh(win);
+        doupdate();
+
+        int ch = wgetch(win);
+        switch (ch) {
+            case KEY_UP:
+                if (selected > 0) selected--;
+                break;
+            case KEY_DOWN:
+                if (selected < entry_count - 1) selected++;
+                break;
+            case KEY_RIGHT:
+            case '\n':
+            case '\r':
+            case KEY_ENTER:
+                if (entry_count > 0) {
+                    char next[1024 + NAME_CAP];
+                    snprintf(next, sizeof(next), "%s/%s", current_dir, entries[selected]);
+                    safe_strcpy(current_dir, sizeof(current_dir), next);
+                    entry_count = list_subdirs(current_dir, entries, MAX_ENTRIES);
+                    selected = 0;
+                }
+                break;
+            case KEY_LEFT:
+            case KEY_BACKSPACE:
+            case 127:
+            case 8:
+                go_to_parent(current_dir);
+                entry_count = list_subdirs(current_dir, entries, MAX_ENTRIES);
+                selected = 0;
+                break;
+            case 's':
+            case 'S':
+                safe_strcpy(chosen, cap, current_dir);
+                result = 1;
+                running = 0;
+                break;
+            case 27: /* Esc */
+                running = 0;
+                break;
+            default:
+                break;
+        }
+    }
+
+    delwin(win);
+    return result;
+}
diff --git a/c/src/dirpicker.h b/c/src/dirpicker.h
new file mode 100644
index 0000000..e76da10
--- /dev/null
+++ b/c/src/dirpicker.h
@@ -0,0 +1,12 @@
+#ifndef YTMDL_DIRPICKER_H
+#define YTMDL_DIRPICKER_H
+
+#include <stddef.h>
+
+/* Runs a modal, full-screen-centered directory browser starting at
+ * start_path (or its nearest existing ancestor). On selection, writes the
+ * chosen path into chosen (size cap) and returns 1; returns 0 if the user
+ * cancelled. */
+int dirpicker_run(const char *start_path, char *chosen, size_t cap);
+
+#endif
diff --git a/c/src/downloader.c b/c/src/downloader.c
new file mode 100644
index 0000000..cdd2774
--- /dev/null
+++ b/c/src/downloader.c
@@ -0,0 +1,239 @@
+#include "downloader.h"
+
+#include <errno.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "util.h"
+
+typedef void (*line_cb_t)(void *ud, const char *line);
+
+struct stderr_reader_args {
+    int fd;
+    char *buf;
+    size_t cap;
+};
+
+static void *stderr_reader_thread(void *arg) {
+    struct stderr_reader_args *a = arg;
+    size_t used = 0;
+    char chunk[512];
+    ssize_t n;
+    while ((n = read(a->fd, chunk, sizeof(chunk))) > 0) {
+        if (a->cap > 0 && used < a->cap - 1) {
+            size_t room = a->cap - 1 - used;
+            size_t take = (size_t)n < room ? (size_t)n : room;
+            memcpy(a->buf + used, chunk, take);
+            used += take;
+            a->buf[used] = '\0';
+        }
+        /* otherwise keep draining and discard, so the child never blocks on a full pipe */
+    }
+    return NULL;
+}
+
+/* Forks + execs argv, streams stdout lines to stdout_cb, captures stderr into err.
+ * Returns 0 if the child exited with status 0, -1 otherwise. */
+static int run_process(char *const argv[], line_cb_t stdout_cb, void *stdout_ud,
+                        char *err, size_t err_cap) {
+    int out_pipe[2], err_pipe[2];
+    if (pipe(out_pipe) != 0 || pipe(err_pipe) != 0) {
+        snprintf(err, err_cap, "pipe() failed: %s", strerror(errno));
+        return -1;
+    }
+
+    pid_t pid = fork();
+    if (pid < 0) {
+        snprintf(err, err_cap, "fork() failed: %s", strerror(errno));
+        return -1;
+    }
+
+    if (pid == 0) {
+        dup2(out_pipe[1], STDOUT_FILENO);
+        dup2(err_pipe[1], STDERR_FILENO);
+        close(out_pipe[0]);
+        close(out_pipe[1]);
+        close(err_pipe[0]);
+        close(err_pipe[1]);
+        execvp(argv[0], argv);
+        _exit(127);
+    }
+
+    close(out_pipe[1]);
+    close(err_pipe[1]);
+
+    if (err_cap > 0) err[0] = '\0';
+    struct stderr_reader_args sargs = {.fd = err_pipe[0], .buf = err, .cap = err_cap};
+    pthread_t err_thread;
+    pthread_create(&err_thread, NULL, stderr_reader_thread, &sargs);
+
+    FILE *out_f = fdopen(out_pipe[0], "r");
+    if (out_f) {
+        char *line = NULL;
+        size_t linecap = 0;
+        ssize_t len;
+        while ((len = getline(&line, &linecap, out_f)) >= 0) {
+            while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
+                line[--len] = '\0';
+            }
+            if (stdout_cb) stdout_cb(stdout_ud, line);
+        }
+        free(line);
+        fclose(out_f);
+    } else {
+        close(out_pipe[0]);
+    }
+
+    pthread_join(err_thread, NULL);
+    close(err_pipe[0]);
+
+    int status = 0;
+    waitpid(pid, &status, 0);
+    if (WIFEXITED(status) && WEXITSTATUS(status) == 0) return 0;
+    return -1;
+}
+
+/* ---- resolve -------------------------------------------------------- */
+
+#define RESOLVE_TITLE_CAP 512
+#define RESOLVE_URL_CAP 2048
+
+struct resolve_ctx {
+    entry_cb_t on_entry;
+    void *user_data;
+    char *playlist_title;
+    size_t playlist_title_cap;
+    int got_playlist_title;
+    int entry_count;
+};
+
+static void resolve_line_cb(void *ud, const char *line) {
+    struct resolve_ctx *ctx = ud;
+
+    const char *sep1 = strchr(line, '\x1f');
+    if (!sep1) return;
+    const char *sep2 = strchr(sep1 + 1, '\x1f');
+    if (!sep2) return;
+
+    char title[RESOLVE_TITLE_CAP];
+    char url[RESOLVE_URL_CAP];
+    size_t title_len = (size_t)(sep1 - line);
+    size_t url_len = (size_t)(sep2 - (sep1 + 1));
+    if (title_len >= sizeof(title)) title_len = sizeof(title) - 1;
+    if (url_len >= sizeof(url)) url_len = sizeof(url) - 1;
+    memcpy(title, line, title_len);
+    title[title_len] = '\0';
+    memcpy(url, sep1 + 1, url_len);
+    url[url_len] = '\0';
+
+    const char *playlist_title_field = sep2 + 1;
+    if (!ctx->got_playlist_title && *playlist_title_field) {
+        safe_strcpy(ctx->playlist_title, ctx->playlist_title_cap, playlist_title_field);
+        ctx->got_playlist_title = 1;
+    }
+
+    if (*url && ctx->on_entry) {
+        ctx->on_entry(ctx->user_data, url, *title ? title : url);
+        ctx->entry_count++;
+    }
+}
+
+int downloader_resolve(const char *url, entry_cb_t on_entry, void *user_data,
+                        char *playlist_title, size_t playlist_title_cap,
+                        char *err, size_t err_cap) {
+    if (playlist_title_cap > 0) playlist_title[0] = '\0';
+
+    static const char *template = "%(title)s\x1f%(webpage_url,url)s\x1f%(playlist_title|)s";
+    char *argv[] = {
+        "yt-dlp", "--flat-playlist", "--skip-download",
+        "--no-cache-dir", /* concurrent yt-dlp invocations sharing ~/.cache/yt-dlp can race */
+        "--print", (char *)template,
+        (char *)url, NULL,
+    };
+
+    struct resolve_ctx ctx = {
+        .on_entry = on_entry,
+        .user_data = user_data,
+        .playlist_title = playlist_title,
+        .playlist_title_cap = playlist_title_cap,
+        .got_playlist_title = 0,
+        .entry_count = 0,
+    };
+
+    char proc_err[DL_ERROR_CAP];
+    run_process(argv, resolve_line_cb, &ctx, proc_err, sizeof(proc_err));
+
+    if (ctx.entry_count == 0) {
+        if (*proc_err) {
+            snprintf(err, err_cap, "%s", proc_err);
+        } else {
+            snprintf(err, err_cap, "Nothing downloadable found at %s", url);
+        }
+        return -1;
+    }
+    return 0;
+}
+
+/* ---- download --------------------------------------------------------- */
+
+struct download_ctx {
+    progress_cb_t cb;
+    void *user_data;
+};
+
+static void download_line_cb(void *ud, const char *line) {
+    struct download_ctx *ctx = ud;
+    if (strncmp(line, "[download]", 10) != 0) return;
+
+    float percent;
+    char speed[64];
+    char eta[32];
+    int n = sscanf(line, "[download] %f%% of %*s at %63s ETA %31s", &percent, speed, eta);
+    if (n == 3 && ctx->cb) {
+        ctx->cb(ctx->user_data, percent, speed, eta);
+    }
+}
+
+int downloader_download(const char *url, const char *output_dir, const char *audio_format,
+                         progress_cb_t cb, void *user_data,
+                         char *err, size_t err_cap) {
+    if (mkdir_p(output_dir) != 0) {
+        snprintf(err, err_cap, "Could not create %s: %s", output_dir, strerror(errno));
+        return -1;
+    }
+
+    char outtmpl[2200];
+    snprintf(outtmpl, sizeof(outtmpl), "%s/%%(title)s.%%(ext)s", output_dir);
+
+    char *argv[] = {
+        "yt-dlp",
+        "-f", "bestaudio/best",
+        "-x", "--audio-format", (char *)audio_format,
+        "--audio-quality", "0",
+        "--embed-thumbnail",
+        "--embed-metadata",
+        "--no-cache-dir", /* concurrent yt-dlp invocations sharing ~/.cache/yt-dlp can race */
+        "--newline",
+        "-o", outtmpl,
+        (char *)url,
+        NULL,
+    };
+
+    struct download_ctx ctx = {.cb = cb, .user_data = user_data};
+
+    char proc_err[DL_ERROR_CAP];
+    int rc = run_process(argv, download_line_cb, &ctx, proc_err, sizeof(proc_err));
+    if (rc != 0) {
+        if (*proc_err) {
+            snprintf(err, err_cap, "%s", proc_err);
+        } else {
+            snprintf(err, err_cap, "yt-dlp exited with an error");
+        }
+        return -1;
+    }
+    return 0;
+}
diff --git a/c/src/downloader.h b/c/src/downloader.h
new file mode 100644
index 0000000..d02e614
--- /dev/null
+++ b/c/src/downloader.h
@@ -0,0 +1,26 @@
+#ifndef YTMDL_DOWNLOADER_H
+#define YTMDL_DOWNLOADER_H
+
+#include <stddef.h>
+
+#define DL_ERROR_CAP 1024
+
+typedef void (*entry_cb_t)(void *user_data, const char *url, const char *title);
+typedef void (*progress_cb_t)(void *user_data, float percent, const char *speed, const char *eta);
+
+/* Expands a video/playlist/album URL. Calls on_entry once per downloadable
+ * item (in order) and, if it's a playlist/album, writes its title into
+ * playlist_title (playlist_title[0] == '\0' for a single video).
+ * Returns 0 on success, -1 on failure (err filled with a message). */
+int downloader_resolve(const char *url, entry_cb_t on_entry, void *user_data,
+                        char *playlist_title, size_t playlist_title_cap,
+                        char *err, size_t err_cap);
+
+/* Downloads a single URL as an audio file with embedded thumbnail/metadata
+ * into output_dir (created if needed). Calls cb with progress updates.
+ * Returns 0 on success, -1 on failure (err filled with a message). */
+int downloader_download(const char *url, const char *output_dir, const char *audio_format,
+                         progress_cb_t cb, void *user_data,
+                         char *err, size_t err_cap);
+
+#endif
diff --git a/c/src/icons.h b/c/src/icons.h
new file mode 100644
index 0000000..b35a0ad
--- /dev/null
+++ b/c/src/icons.h
@@ -0,0 +1,15 @@
+#ifndef YTMDL_ICONS_H
+#define YTMDL_ICONS_H
+
+/* Nerd Font (Font Awesome subset) glyphs. Require a Nerd Font in the
+ * terminal; encoded via \uXXXX escapes rather than literal glyphs so the
+ * codepoints survive any editor/terminal transcoding intact. */
+
+#define ICON_FOLDER ""        /* nf-fa-folder */
+#define ICON_FOLDER_OPEN ""   /* nf-fa-folder_open */
+#define ICON_PENDING ""       /* nf-fa-clock_o */
+#define ICON_DOWNLOADING ""   /* nf-fa-download */
+#define ICON_DONE ""          /* nf-fa-check */
+#define ICON_ERROR ""         /* nf-fa-times */
+
+#endif
diff --git a/c/src/input_field.c b/c/src/input_field.c
new file mode 100644
index 0000000..4f212c0
--- /dev/null
+++ b/c/src/input_field.c
@@ -0,0 +1,92 @@
+#include "input_field.h"
+
+#include <string.h>
+
+void input_field_init(InputField *f) {
+    f->buf[0] = '\0';
+    f->len = 0;
+    f->cursor = 0;
+}
+
+void input_field_clear(InputField *f) {
+    input_field_init(f);
+}
+
+int input_field_handle_key(InputField *f, int ch) {
+    if (ch >= 32 && ch < 127) {
+        if (f->len + 1 < sizeof(f->buf)) {
+            memmove(f->buf + f->cursor + 1, f->buf + f->cursor, f->len - f->cursor + 1);
+            f->buf[f->cursor] = (char)ch;
+            f->cursor++;
+            f->len++;
+        }
+        return 1;
+    }
+    switch (ch) {
+        case KEY_BACKSPACE:
+        case 127:
+        case 8:
+            if (f->cursor > 0) {
+                memmove(f->buf + f->cursor - 1, f->buf + f->cursor, f->len - f->cursor + 1);
+                f->cursor--;
+                f->len--;
+            }
+            return 1;
+        case KEY_DC:
+            if (f->cursor < f->len) {
+                memmove(f->buf + f->cursor, f->buf + f->cursor + 1, f->len - f->cursor);
+                f->len--;
+            }
+            return 1;
+        case KEY_LEFT:
+            if (f->cursor > 0) f->cursor--;
+            return 1;
+        case KEY_RIGHT:
+            if (f->cursor < f->len) f->cursor++;
+            return 1;
+        case KEY_HOME:
+        case 1: /* Ctrl+A */
+            f->cursor = 0;
+            return 1;
+        case KEY_END:
+        case 5: /* Ctrl+E */
+            f->cursor = f->len;
+            return 1;
+        case 21: /* Ctrl+U: clear to start */
+            memmove(f->buf, f->buf + f->cursor, f->len - f->cursor + 1);
+            f->len -= f->cursor;
+            f->cursor = 0;
+            return 1;
+        case 11: /* Ctrl+K: clear to end */
+            f->buf[f->cursor] = '\0';
+            f->len = f->cursor;
+            return 1;
+        default:
+            return 0;
+    }
+}
+
+void input_field_draw(WINDOW *win, int y, int x, int width, const InputField *f, int focused,
+                       const char *placeholder) {
+    if (width <= 0) return;
+    wmove(win, y, x);
+    for (int i = 0; i < width; i++) waddch(win, ' ');
+
+    if (f->len == 0 && !focused) {
+        wattron(win, A_DIM);
+        mvwaddnstr(win, y, x, placeholder, width);
+        wattroff(win, A_DIM);
+        return;
+    }
+
+    size_t start = 0;
+    if (f->cursor >= (size_t)width) start = f->cursor - width + 1;
+    size_t visible_len = f->len - start;
+    if (visible_len > (size_t)width) visible_len = width;
+
+    mvwaddnstr(win, y, x, f->buf + start, (int)visible_len);
+
+    if (focused) {
+        wmove(win, y, x + (int)(f->cursor - start));
+    }
+}
diff --git a/c/src/input_field.h b/c/src/input_field.h
new file mode 100644
index 0000000..81d0b35
--- /dev/null
+++ b/c/src/input_field.h
@@ -0,0 +1,25 @@
+#ifndef YTMDL_INPUT_FIELD_H
+#define YTMDL_INPUT_FIELD_H
+
+#include <ncurses.h>
+#include <stddef.h>
+
+#define INPUT_FIELD_CAP 2048
+
+typedef struct {
+    char buf[INPUT_FIELD_CAP];
+    size_t len;
+    size_t cursor;
+} InputField;
+
+void input_field_init(InputField *f);
+void input_field_clear(InputField *f);
+
+/* Feeds a curses key code to the field. Returns 1 if it was an editing key
+ * (consumed), 0 if the caller should handle it itself (e.g. Enter, Tab). */
+int input_field_handle_key(InputField *f, int ch);
+
+void input_field_draw(WINDOW *win, int y, int x, int width, const InputField *f, int focused,
+                       const char *placeholder);
+
+#endif
diff --git a/c/src/main.c b/c/src/main.c
new file mode 100644
index 0000000..59aa2cf
--- /dev/null
+++ b/c/src/main.c
@@ -0,0 +1,90 @@
+#include <locale.h>
+#include <ncurses.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+
+#include "app.h"
+#include "ui.h"
+
+static void build_default_output_dir(char *out, size_t cap) {
+    const char *home = getenv("HOME");
+    if (!home) home = "/tmp";
+    snprintf(out, cap, "%s/Music/ytmdl", home);
+}
+
+static void print_usage(const char *prog) {
+    fprintf(stderr,
+            "Usage: %s [-o output_dir] [-f audio_format]\n"
+            "  -o  Directory to save downloaded audio files (default: ~/Music/ytmdl)\n"
+            "  -f  Audio format: mp3, m4a, flac, opus, wav, vorbis (default: mp3)\n",
+            prog);
+}
+
+int main(int argc, char **argv) {
+    char output_dir[OUTPUT_DIR_CAP];
+    build_default_output_dir(output_dir, sizeof(output_dir));
+    char audio_format[AUDIO_FORMAT_CAP] = "mp3";
+
+    int opt;
+    while ((opt = getopt(argc, argv, "o:f:h")) != -1) {
+        switch (opt) {
+            case 'o':
+                snprintf(output_dir, sizeof(output_dir), "%s", optarg);
+                break;
+            case 'f':
+                snprintf(audio_format, sizeof(audio_format), "%s", optarg);
+                break;
+            case 'h':
+            default:
+                print_usage(argv[0]);
+                return opt == 'h' ? 0 : 1;
+        }
+    }
+
+    setlocale(LC_ALL, "");
+
+    /* Heap-allocated: Queue's fixed-size item array makes App several MB,
+     * too large to safely put on the stack. */
+    App *app = malloc(sizeof(App));
+    if (!app) {
+        fprintf(stderr, "Out of memory\n");
+        return 1;
+    }
+    app_init(app, output_dir, audio_format);
+    app_start_workers(app);
+
+    initscr();
+    cbreak();
+    noecho();
+    keypad(stdscr, TRUE);
+    curs_set(1);
+    timeout(150); /* ms; also drives periodic redraws for background progress */
+
+    /* Disable software flow control (IXON/IXOFF) so Ctrl+Q/Ctrl+S reach the
+     * app as normal keys instead of being swallowed by the tty driver. */
+    struct termios term;
+    if (tcgetattr(STDIN_FILENO, &term) == 0) {
+        term.c_iflag &= ~(IXON | IXOFF);
+        tcsetattr(STDIN_FILENO, TCSANOW, &term);
+    }
+
+    UiState ui;
+    ui_init(&ui);
+
+    while (!ui.quit_requested) {
+        ui_draw(app, &ui);
+        int ch = getch();
+        if (ch != ERR) {
+            ui_handle_key(app, &ui, ch);
+        }
+    }
+
+    ui_destroy(&ui);
+    endwin();
+    app_shutdown(app);
+    free(app);
+    return 0;
+}
diff --git a/c/src/queue.c b/c/src/queue.c
new file mode 100644
index 0000000..4b31a4a
--- /dev/null
+++ b/c/src/queue.c
@@ -0,0 +1,118 @@
+#include "queue.h"
+
+#include <string.h>
+
+#include "util.h"
+
+void queue_init(Queue *q) {
+    memset(q, 0, sizeof(*q));
+    q->count = 0;
+    q->next_id = 1;
+    pthread_mutex_init(&q->lock, NULL);
+}
+
+static int find_index_locked(Queue *q, int id) {
+    for (int i = 0; i < q->count; i++) {
+        if (q->items[i].id == id) return i;
+    }
+    return -1;
+}
+
+int queue_add(Queue *q, const char *url, const char *title, const char *subdir) {
+    pthread_mutex_lock(&q->lock);
+    if (q->count >= MAX_QUEUE_ITEMS) {
+        pthread_mutex_unlock(&q->lock);
+        return -1;
+    }
+    QueueItem *item = &q->items[q->count++];
+    memset(item, 0, sizeof(*item));
+    item->id = q->next_id++;
+    safe_strcpy(item->url, sizeof(item->url), url);
+    safe_strcpy(item->title, sizeof(item->title), title);
+    safe_strcpy(item->subdir, sizeof(item->subdir), subdir);
+    item->status = STATUS_PENDING;
+    int id = item->id;
+    pthread_mutex_unlock(&q->lock);
+    return id;
+}
+
+int queue_get(Queue *q, int id, QueueItem *out) {
+    pthread_mutex_lock(&q->lock);
+    int idx = find_index_locked(q, id);
+    int found = 0;
+    if (idx >= 0) {
+        *out = q->items[idx];
+        found = 1;
+    }
+    pthread_mutex_unlock(&q->lock);
+    return found;
+}
+
+void queue_set_status(Queue *q, int id, ItemStatus status, const char *error) {
+    pthread_mutex_lock(&q->lock);
+    int idx = find_index_locked(q, id);
+    if (idx >= 0) {
+        q->items[idx].status = status;
+        if (error) safe_strcpy(q->items[idx].error, sizeof(q->items[idx].error), error);
+        if (status == STATUS_DONE) q->items[idx].percent = 100.0f;
+    }
+    pthread_mutex_unlock(&q->lock);
+}
+
+void queue_set_progress(Queue *q, int id, float percent, const char *speed, const char *eta) {
+    pthread_mutex_lock(&q->lock);
+    int idx = find_index_locked(q, id);
+    if (idx >= 0) {
+        q->items[idx].percent = percent;
+        safe_strcpy(q->items[idx].speed, sizeof(q->items[idx].speed), speed);
+        safe_strcpy(q->items[idx].eta, sizeof(q->items[idx].eta), eta);
+    }
+    pthread_mutex_unlock(&q->lock);
+}
+
+void queue_reset_for_retry(Queue *q, int id) {
+    pthread_mutex_lock(&q->lock);
+    int idx = find_index_locked(q, id);
+    if (idx >= 0) {
+        q->items[idx].status = STATUS_PENDING;
+        q->items[idx].percent = 0.0f;
+        q->items[idx].error[0] = '\0';
+        q->items[idx].speed[0] = '\0';
+        q->items[idx].eta[0] = '\0';
+    }
+    pthread_mutex_unlock(&q->lock);
+}
+
+static void remove_index_locked(Queue *q, int idx) {
+    for (int i = idx; i < q->count - 1; i++) {
+        q->items[i] = q->items[i + 1];
+    }
+    q->count--;
+}
+
+void queue_remove(Queue *q, int id) {
+    pthread_mutex_lock(&q->lock);
+    int idx = find_index_locked(q, id);
+    if (idx >= 0 && q->items[idx].status != STATUS_DOWNLOADING) {
+        remove_index_locked(q, idx);
+    }
+    pthread_mutex_unlock(&q->lock);
+}
+
+int queue_clear_finished(Queue *q) {
+    pthread_mutex_lock(&q->lock);
+    int removed = 0;
+    for (int i = 0; i < q->count; /* no increment here */) {
+        /* Only successfully finished items are cleared automatically;
+         * errored ones stay visible so they can be inspected/retried. */
+        ItemStatus s = q->items[i].status;
+        if (s == STATUS_DONE) {
+            remove_index_locked(q, i);
+            removed++;
+        } else {
+            i++;
+        }
+    }
+    pthread_mutex_unlock(&q->lock);
+    return removed;
+}
diff --git a/c/src/queue.h b/c/src/queue.h
new file mode 100644
index 0000000..c97082d
--- /dev/null
+++ b/c/src/queue.h
@@ -0,0 +1,59 @@
+#ifndef YTMDL_QUEUE_H
+#define YTMDL_QUEUE_H
+
+#include <pthread.h>
+
+#define MAX_QUEUE_ITEMS 4096
+#define URL_CAP 2048
+#define TITLE_CAP 512
+#define SUBDIR_CAP 256
+#define SPEED_CAP 32
+#define ETA_CAP 16
+#define ERROR_CAP 256
+
+typedef enum {
+    STATUS_PENDING,
+    STATUS_DOWNLOADING,
+    STATUS_DONE,
+    STATUS_ERROR,
+} ItemStatus;
+
+typedef struct {
+    int id;          /* stable unique id; -1 means unused slot */
+    char url[URL_CAP];
+    char title[TITLE_CAP];
+    char subdir[SUBDIR_CAP];
+    ItemStatus status;
+    float percent;
+    char speed[SPEED_CAP];
+    char eta[ETA_CAP];
+    char error[ERROR_CAP];
+} QueueItem;
+
+typedef struct {
+    QueueItem items[MAX_QUEUE_ITEMS];
+    int count;      /* number of active slots, compacted */
+    int next_id;
+    pthread_mutex_t lock;
+} Queue;
+
+void queue_init(Queue *q);
+
+/* Adds a new pending item, returns its id, or -1 if the queue is full. */
+int queue_add(Queue *q, const char *url, const char *title, const char *subdir);
+
+/* Looks an item up by id and copies it into *out. Returns 1 if found, 0 otherwise. */
+int queue_get(Queue *q, int id, QueueItem *out);
+
+void queue_set_status(Queue *q, int id, ItemStatus status, const char *error);
+void queue_set_progress(Queue *q, int id, float percent, const char *speed, const char *eta);
+void queue_reset_for_retry(Queue *q, int id);
+
+/* Removes a single item by id. No-op if not found or currently downloading. */
+void queue_remove(Queue *q, int id);
+
+/* Removes every successfully DONE item (errored items are left so they can
+ * still be inspected/retried). Returns how many were removed. */
+int queue_clear_finished(Queue *q);
+
+#endif
diff --git a/c/src/ui.c b/c/src/ui.c
new file mode 100644
index 0000000..d5df8a8
--- /dev/null
+++ b/c/src/ui.c
@@ -0,0 +1,345 @@
+#include "ui.h"
+
+#include <string.h>
+
+#include "colors.h"
+#include "dirpicker.h"
+#include "icons.h"
+
+#define COL_ICON_X 2
+#define COL_TITLE_X 5
+#define COL_TITLE_W 26
+#define COL_FOLDER_W 14
+#define COL_PROGRESS_W 16
+#define COL_PROGRESS_BAR_W 10
+#define COL_SPEED_W 9
+#define COL_ETA_W 6
+
+#define COL_FOLDER_X (COL_TITLE_X + COL_TITLE_W + 1)
+#define COL_PROGRESS_X (COL_FOLDER_X + COL_FOLDER_W + 1)
+#define COL_SPEED_X (COL_PROGRESS_X + COL_PROGRESS_W + 1)
+#define COL_ETA_X (COL_SPEED_X + COL_SPEED_W + 1)
+
+#define URL_WIN_H 3
+#define SETTINGS_WIN_H 4
+#define MESSAGE_H 1
+
+void ui_init(UiState *ui) {
+    memset(ui, 0, sizeof(*ui));
+    input_field_init(&ui->url_field);
+    ui->focus = FOCUS_URL;
+    ui->selected_row = 0;
+    ui->quit_requested = 0;
+    colors_init();
+}
+
+void ui_destroy(UiState *ui) {
+    if (ui->url_win) delwin(ui->url_win);
+    if (ui->settings_win) delwin(ui->settings_win);
+    if (ui->table_win) delwin(ui->table_win);
+    ui->url_win = ui->settings_win = ui->table_win = NULL;
+}
+
+static const char *status_icon(ItemStatus s) {
+    switch (s) {
+        case STATUS_PENDING: return ICON_PENDING;
+        case STATUS_DOWNLOADING: return ICON_DOWNLOADING;
+        case STATUS_DONE: return ICON_DONE;
+        case STATUS_ERROR: return ICON_ERROR;
+        default: return "?";
+    }
+}
+
+static int status_pair(ItemStatus s) {
+    switch (s) {
+        case STATUS_PENDING: return PAIR_STATUS_PENDING;
+        case STATUS_DOWNLOADING: return PAIR_STATUS_DOWNLOADING;
+        case STATUS_DONE: return PAIR_STATUS_DONE;
+        case STATUS_ERROR: return PAIR_STATUS_ERROR;
+        default: return 0;
+    }
+}
+
+static void box_title(WINDOW *win, const char *title) {
+    wattron(win, A_BOLD);
+    mvwprintw(win, 0, 2, " %s ", title);
+    wattroff(win, A_BOLD);
+}
+
+static void ensure_layout(UiState *ui, int rows, int cols) {
+    if (ui->layout_rows == rows && ui->layout_cols == cols && ui->url_win) return;
+
+    if (ui->url_win) delwin(ui->url_win);
+    if (ui->settings_win) delwin(ui->settings_win);
+    if (ui->table_win) delwin(ui->table_win);
+
+    int x = 1;
+    int w = cols - 2;
+    if (w < 10) w = 10;
+
+    int y = 1;
+    ui->url_win = newwin(URL_WIN_H, w, y, x);
+    y += URL_WIN_H;
+
+    ui->settings_win = newwin(SETTINGS_WIN_H, w, y, x);
+    y += SETTINGS_WIN_H;
+
+    y += MESSAGE_H; /* leave a blank/message row between settings and table */
+
+    int table_h = rows - y - 1; /* leave the last row for footer hints */
+    if (table_h < 4) table_h = 4;
+    ui->table_win = newwin(table_h, w, y, x);
+
+    ui->layout_rows = rows;
+    ui->layout_cols = cols;
+}
+
+static void draw_progress_cell(WINDOW *win, int y, int x, const QueueItem *it) {
+    if (it->status == STATUS_ERROR) {
+        wattron(win, COLOR_PAIR(PAIR_STATUS_ERROR));
+        mvwprintw(win, y, x, "%-*.*s", COL_PROGRESS_W, COL_PROGRESS_W, it->error);
+        wattroff(win, COLOR_PAIR(PAIR_STATUS_ERROR));
+        return;
+    }
+
+    float percent = it->percent;
+    if (percent < 0) percent = 0;
+    if (percent > 100) percent = 100;
+    int filled = (int)(COL_PROGRESS_BAR_W * percent / 100.0f + 0.5f);
+    if (filled > COL_PROGRESS_BAR_W) filled = COL_PROGRESS_BAR_W;
+
+    wattron(win, COLOR_PAIR(PAIR_PROGRESS_FILLED));
+    for (int i = 0; i < filled; i++) mvwaddstr(win, y, x + i, BLOCK_FULL);
+    wattroff(win, COLOR_PAIR(PAIR_PROGRESS_FILLED));
+    wattron(win, A_DIM);
+    for (int i = filled; i < COL_PROGRESS_BAR_W; i++) mvwaddstr(win, y, x + i, BLOCK_LIGHT);
+    wattroff(win, A_DIM);
+
+    mvwprintw(win, y, x + COL_PROGRESS_BAR_W + 1, "%3.0f%%", percent);
+}
+
+void ui_draw(App *app, UiState *ui) {
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+    ensure_layout(ui, rows, cols);
+    erase();
+
+    attron(A_BOLD | COLOR_PAIR(PAIR_TITLE));
+    mvprintw(0, 2, "ytmdl - YouTube Music Downloader");
+    attroff(A_BOLD | COLOR_PAIR(PAIR_TITLE));
+
+    /* --- Message line ------------------------------------------------------- */
+    int msg_y = 1 + URL_WIN_H + SETTINGS_WIN_H;
+    char msg[MESSAGE_CAP];
+    int is_err = 0;
+    app_get_message(app, msg, sizeof(msg), &is_err);
+    if (msg[0]) {
+        int pair = is_err ? PAIR_MSG_ERROR : PAIR_MSG_INFO;
+        attron(A_BOLD | COLOR_PAIR(pair));
+        mvprintw(msg_y, 3, "%.*s", cols - 5, msg);
+        attroff(A_BOLD | COLOR_PAIR(pair));
+    }
+
+    attron(A_DIM);
+    mvprintw(rows - 1, 2,
+             "Tab: switch focus  Up/Down: select  d/Del: remove  r: retry  "
+             "Ctrl+N: clear finished  Ctrl+Q: quit");
+    attroff(A_DIM);
+
+    /* stdscr and the panel sub-windows all cover overlapping screen regions;
+     * whichever one calls wnoutrefresh() last "wins" those cells in
+     * ncurses' shared virtual screen. stdscr must go first so the panels
+     * (refreshed below) draw on top of it, not the other way around. */
+    wnoutrefresh(stdscr);
+
+    /* --- URL box -------------------------------------------------------- */
+    WINDOW *uw = ui->url_win;
+    werase(uw);
+    draw_rounded_box(uw, ui->focus == FOCUS_URL ? PAIR_BORDER_FOCUS : PAIR_BORDER);
+    box_title(uw, "URL");
+    int url_w = getmaxx(uw) - 4;
+    input_field_draw(uw, 1, 2, url_w, &ui->url_field, ui->focus == FOCUS_URL,
+                      "Paste a video, playlist, or album URL and press Enter");
+    int cursor_y = 0, cursor_x = -1;
+    if (ui->focus == FOCUS_URL) getyx(uw, cursor_y, cursor_x);
+    wnoutrefresh(uw);
+
+    /* --- Settings box ----------------------------------------------------- */
+    WINDOW *sw = ui->settings_win;
+    werase(sw);
+    draw_rounded_box(sw, PAIR_BORDER);
+    box_title(sw, "Settings");
+
+    char output_dir[OUTPUT_DIR_CAP];
+    app_get_output_dir(app, output_dir, sizeof(output_dir));
+    int sw_w = getmaxx(sw);
+    static const char *saving_to_prefix = "Saving to: ";
+    static const char *saving_to_hint = "  (Ctrl+O to change)";
+    int hint_len = (int)strlen(saving_to_hint);
+    int path_dir_len = (int)strlen(output_dir);
+    int avail = sw_w - 4 - (int)strlen(saving_to_prefix);
+    int show_hint = avail - path_dir_len >= hint_len;
+    int path_w = show_hint ? path_dir_len : avail;
+    if (path_w < 0) path_w = 0;
+    mvwprintw(sw, 1, 2, "%s%.*s", saving_to_prefix, path_w, output_dir);
+    int end_x = 2 + (int)strlen(saving_to_prefix) + path_w;
+    if (show_hint) {
+        wattron(sw, A_DIM);
+        mvwprintw(sw, 1, end_x, "%s", saving_to_hint);
+        wattroff(sw, A_DIM);
+    }
+
+    int pf = app_get_playlist_folders(app);
+    mvwprintw(sw, 2, 2, "Playlist folders: %s", pf ? "On" : "Off");
+    wattron(sw, A_DIM);
+    mvwprintw(sw, 2, 2 + 18 + (pf ? 3 : 4), "  (Ctrl+F to toggle)");
+    wattroff(sw, A_DIM);
+    wnoutrefresh(sw);
+
+    /* --- Queue table box ------------------------------------------------------ */
+    WINDOW *tw = ui->table_win;
+    werase(tw);
+    draw_rounded_box(tw, ui->focus == FOCUS_TABLE ? PAIR_BORDER_FOCUS : PAIR_BORDER);
+    box_title(tw, "Queue");
+
+    int tw_w = getmaxx(tw);
+    int tw_h = getmaxy(tw);
+
+    attron(A_UNDERLINE);
+    mvwprintw(tw, 1, COL_ICON_X, "St");
+    mvwprintw(tw, 1, COL_TITLE_X, "%-*.*s", COL_TITLE_W, COL_TITLE_W, "Title");
+    if (COL_FOLDER_X + COL_FOLDER_W < tw_w)
+        mvwprintw(tw, 1, COL_FOLDER_X, "%-*.*s", COL_FOLDER_W, COL_FOLDER_W, "Folder");
+    if (COL_PROGRESS_X + COL_PROGRESS_W < tw_w)
+        mvwprintw(tw, 1, COL_PROGRESS_X, "%-*.*s", COL_PROGRESS_W, COL_PROGRESS_W, "Progress");
+    if (COL_SPEED_X + COL_SPEED_W < tw_w)
+        mvwprintw(tw, 1, COL_SPEED_X, "%-*.*s", COL_SPEED_W, COL_SPEED_W, "Speed");
+    if (COL_ETA_X + COL_ETA_W < tw_w)
+        mvwprintw(tw, 1, COL_ETA_X, "%-*.*s", COL_ETA_W, COL_ETA_W, "ETA");
+    wattroff(tw, A_UNDERLINE);
+
+    pthread_mutex_lock(&app->queue.lock);
+    int count = app->queue.count;
+    if (ui->selected_row >= count) ui->selected_row = count > 0 ? count - 1 : 0;
+    if (ui->selected_row < 0) ui->selected_row = 0;
+
+    int table_h = tw_h - 3; /* border top, header row, border bottom */
+    if (table_h < 0) table_h = 0;
+    int start = 0;
+    if (ui->selected_row >= table_h) start = ui->selected_row - table_h + 1;
+
+    for (int row = 0; row < table_h && start + row < count; row++) {
+        int idx = start + row;
+        QueueItem *it = &app->queue.items[idx];
+        int ry = row + 2;
+        int hl = (ui->focus == FOCUS_TABLE && idx == ui->selected_row);
+        if (hl) wattron(tw, A_REVERSE);
+
+        int pair = status_pair(it->status);
+        if (pair && !hl) wattron(tw, COLOR_PAIR(pair));
+        mvwprintw(tw, ry, COL_ICON_X, "%s", status_icon(it->status));
+        if (pair && !hl) wattroff(tw, COLOR_PAIR(pair));
+
+        mvwprintw(tw, ry, COL_TITLE_X, "%-*.*s", COL_TITLE_W, COL_TITLE_W, it->title);
+        if (COL_FOLDER_X + COL_FOLDER_W < tw_w)
+            mvwprintw(tw, ry, COL_FOLDER_X, "%-*.*s", COL_FOLDER_W, COL_FOLDER_W, it->subdir);
+        if (COL_PROGRESS_X + COL_PROGRESS_W < tw_w && !hl)
+            draw_progress_cell(tw, ry, COL_PROGRESS_X, it);
+        else if (COL_PROGRESS_X + COL_PROGRESS_W < tw_w)
+            mvwprintw(tw, ry, COL_PROGRESS_X, "%*.0f%%", COL_PROGRESS_W - 1, it->percent);
+        if (COL_SPEED_X + COL_SPEED_W < tw_w)
+            mvwprintw(tw, ry, COL_SPEED_X, "%-*.*s", COL_SPEED_W, COL_SPEED_W, it->speed);
+        if (COL_ETA_X + COL_ETA_W < tw_w)
+            mvwprintw(tw, ry, COL_ETA_X, "%-*.*s", COL_ETA_W, COL_ETA_W, it->eta);
+
+        if (hl) wattroff(tw, A_REVERSE);
+    }
+    pthread_mutex_unlock(&app->queue.lock);
+    wnoutrefresh(tw);
+
+    if (ui->focus == FOCUS_URL && cursor_x >= 0) {
+        curs_set(1);
+        wmove(uw, cursor_y, cursor_x);
+        wnoutrefresh(uw);
+    } else {
+        curs_set(0);
+    }
+
+    doupdate();
+}
+
+static int selected_item_id(App *app, UiState *ui) {
+    pthread_mutex_lock(&app->queue.lock);
+    int id = -1;
+    if (ui->selected_row >= 0 && ui->selected_row < app->queue.count) {
+        id = app->queue.items[ui->selected_row].id;
+    }
+    pthread_mutex_unlock(&app->queue.lock);
+    return id;
+}
+
+void ui_handle_key(App *app, UiState *ui, int ch) {
+    /* Global shortcuts, always available regardless of focus. */
+    switch (ch) {
+        case 15: { /* Ctrl+O */
+            char output_dir[OUTPUT_DIR_CAP];
+            app_get_output_dir(app, output_dir, sizeof(output_dir));
+            char chosen[OUTPUT_DIR_CAP];
+            if (dirpicker_run(output_dir, chosen, sizeof(chosen))) {
+                app_set_output_dir(app, chosen);
+            }
+            return;
+        }
+        case 14: /* Ctrl+N */
+            app_clear_finished(app);
+            return;
+        case 6: /* Ctrl+F */
+            app_set_playlist_folders(app, !app_get_playlist_folders(app));
+            return;
+        case 17: /* Ctrl+Q */
+            ui->quit_requested = 1;
+            return;
+        case '\t':
+            ui->focus = (ui->focus == FOCUS_URL) ? FOCUS_TABLE : FOCUS_URL;
+            return;
+        default:
+            break;
+    }
+
+    if (ui->focus == FOCUS_URL) {
+        if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
+            if (ui->url_field.len > 0) {
+                app_submit_url(app, ui->url_field.buf);
+                input_field_clear(&ui->url_field);
+            }
+            return;
+        }
+        input_field_handle_key(&ui->url_field, ch);
+        return;
+    }
+
+    /* FOCUS_TABLE */
+    switch (ch) {
+        case KEY_UP:
+            if (ui->selected_row > 0) ui->selected_row--;
+            break;
+        case KEY_DOWN:
+            ui->selected_row++;
+            break;
+        case 'd':
+        case KEY_DC:
+        case 127:
+        case 8: {
+            int id = selected_item_id(app, ui);
+            if (id >= 0) app_remove_item(app, id);
+            break;
+        }
+        case 'r': {
+            int id = selected_item_id(app, ui);
+            if (id >= 0) app_retry_item(app, id);
+            break;
+        }
+        default:
+            break;
+    }
+}
diff --git a/c/src/ui.h b/c/src/ui.h
new file mode 100644
index 0000000..a0fdf3e
--- /dev/null
+++ b/c/src/ui.h
@@ -0,0 +1,32 @@
+#ifndef YTMDL_UI_H
+#define YTMDL_UI_H
+
+#include <ncurses.h>
+
+#include "app.h"
+#include "input_field.h"
+
+typedef enum {
+    FOCUS_URL,
+    FOCUS_TABLE,
+} Focus;
+
+typedef struct {
+    InputField url_field;
+    Focus focus;
+    int selected_row;
+    int quit_requested;
+
+    WINDOW *url_win;
+    WINDOW *settings_win;
+    WINDOW *table_win;
+    int layout_rows, layout_cols;
+} UiState;
+
+void ui_init(UiState *ui);
+void ui_destroy(UiState *ui);
+void ui_draw(App *app, UiState *ui);
+/* Handles one key. Returns 1 if it fully consumed the key. */
+void ui_handle_key(App *app, UiState *ui, int ch);
+
+#endif
diff --git a/c/src/util.c b/c/src/util.c
new file mode 100644
index 0000000..25ca58d
--- /dev/null
+++ b/c/src/util.c
@@ -0,0 +1,82 @@
+#include "util.h"
+
+#include <ctype.h>
+#include <errno.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+
+void safe_strcpy(char *dst, size_t dst_size, const char *src) {
+    if (dst_size == 0) return;
+    size_t n = strlen(src);
+    if (n >= dst_size) n = dst_size - 1;
+    memcpy(dst, src, n);
+    dst[n] = '\0';
+}
+
+static int is_illegal_dirname_char(unsigned char c) {
+    if (c < 0x20) return 1;
+    switch (c) {
+        case '<': case '>': case ':': case '"':
+        case '/': case '\\': case '|': case '?': case '*':
+            return 1;
+        default:
+            return 0;
+    }
+}
+
+void sanitize_dirname(const char *name, char *dst, size_t dst_size) {
+    if (dst_size == 0) return;
+
+    char tmp[1024];
+    size_t out = 0;
+    int last_was_space = 0;
+
+    for (const unsigned char *p = (const unsigned char *)name; *p && out + 1 < sizeof(tmp); p++) {
+        unsigned char c = *p;
+        if (is_illegal_dirname_char(c)) c = '_';
+        if (isspace(c)) {
+            if (last_was_space) continue;
+            last_was_space = 1;
+            tmp[out++] = ' ';
+        } else {
+            last_was_space = 0;
+            tmp[out++] = (char)c;
+        }
+    }
+    tmp[out] = '\0';
+
+    /* strip leading/trailing spaces and dots */
+    char *start = tmp;
+    while (*start == ' ' || *start == '.') start++;
+    char *end = start + strlen(start);
+    while (end > start && (end[-1] == ' ' || end[-1] == '.')) end--;
+    *end = '\0';
+
+    if (*start == '\0') {
+        safe_strcpy(dst, dst_size, "playlist");
+    } else {
+        safe_strcpy(dst, dst_size, start);
+    }
+}
+
+int mkdir_p(const char *path) {
+    char tmp[1024];
+    safe_strcpy(tmp, sizeof(tmp), path);
+    size_t len = strlen(tmp);
+    if (len == 0) {
+        errno = EINVAL;
+        return -1;
+    }
+    if (tmp[len - 1] == '/') tmp[len - 1] = '\0';
+
+    for (char *p = tmp + 1; *p; p++) {
+        if (*p == '/') {
+            *p = '\0';
+            if (mkdir(tmp, 0755) != 0 && errno != EEXIST) return -1;
+            *p = '/';
+        }
+    }
+    if (mkdir(tmp, 0755) != 0 && errno != EEXIST) return -1;
+    return 0;
+}
diff --git a/c/src/util.h b/c/src/util.h
new file mode 100644
index 0000000..0044631
--- /dev/null
+++ b/c/src/util.h
@@ -0,0 +1,17 @@
+#ifndef YTMDL_UTIL_H
+#define YTMDL_UTIL_H
+
+#include <stddef.h>
+
+/* Copies src into dst (size dst_size), always NUL-terminating. */
+void safe_strcpy(char *dst, size_t dst_size, const char *src);
+
+/* Replaces filesystem-illegal characters with '_', collapses whitespace,
+ * strips leading/trailing space and dots. Writes into dst (size dst_size).
+ * If the result would be empty, dst becomes "playlist". */
+void sanitize_dirname(const char *name, char *dst, size_t dst_size);
+
+/* mkdir -p equivalent. Returns 0 on success, -1 on failure (errno set). */
+int mkdir_p(const char *path);
+
+#endif
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..330dfec
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,19 @@
+[project]
+name = "ytmdl"
+version = "0.1.0"
+description = "TUI for downloading audio from YouTube / YouTube Music URLs and playlists"
+requires-python = ">=3.10"
+dependencies = [
+    "textual>=0.58",
+    "yt-dlp>=2024.1.1",
+]
+
+[project.scripts]
+ytmdl = "ytmdl.__main__:main"
+
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+where = ["src"]
diff --git a/src/ytmdl/__init__.py b/src/ytmdl/__init__.py
new file mode 100644
index 0000000..ad9a145
--- /dev/null
+++ b/src/ytmdl/__init__.py
@@ -0,0 +1,3 @@
+"""ytmdl: a TUI for downloading audio from YouTube / YouTube Music URLs and playlists."""
+
+__version__ = "0.1.0"
diff --git a/src/ytmdl/__main__.py b/src/ytmdl/__main__.py
new file mode 100644
index 0000000..fab89db
--- /dev/null
+++ b/src/ytmdl/__main__.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from .app import DEFAULT_OUTPUT_DIR, YtmdlApp
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        prog="ytmdl",
+        description="TUI for downloading audio from YouTube / YouTube Music URLs and playlists.",
+    )
+    parser.add_argument(
+        "-o",
+        "--output-dir",
+        type=Path,
+        default=DEFAULT_OUTPUT_DIR,
+        help=f"Directory to save downloaded audio files (default: {DEFAULT_OUTPUT_DIR})",
+    )
+    parser.add_argument(
+        "-f",
+        "--format",
+        dest="audio_format",
+        default="mp3",
+        choices=["mp3", "m4a", "flac", "opus", "wav", "vorbis"],
+        help="Audio format to convert downloads to (default: mp3)",
+    )
+    args = parser.parse_args()
+
+    app = YtmdlApp(output_dir=args.output_dir, audio_format=args.audio_format)
+    app.run()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/ytmdl/app.py b/src/ytmdl/app.py
new file mode 100644
index 0000000..9d02753
--- /dev/null
+++ b/src/ytmdl/app.py
@@ -0,0 +1,327 @@
+from __future__ import annotations
+
+import queue as sync_queue
+from pathlib import Path
+from typing import Iterable
+
+from textual.app import App, ComposeResult, SystemCommand
+from textual.binding import Binding
+from textual.containers import Horizontal, Vertical
+from textual.screen import Screen
+from textual.widgets import Button, DataTable, Footer, Header, Input, Label, Switch
+from textual.worker import get_current_worker
+
+from . import downloader
+from .queue import STATUS_GLYPH, QueueItem, Status
+from .screens import DirectoryPickerScreen, NerdCommandPalette
+
+DEFAULT_OUTPUT_DIR = Path.home() / "Music" / "ytmdl"
+CONCURRENT_DOWNLOADS = 2
+
+
+def _format_speed(bytes_per_sec: float | None) -> str:
+    if not bytes_per_sec:
+        return ""
+    value = float(bytes_per_sec)
+    for unit in ("B/s", "KiB/s", "MiB/s", "GiB/s"):
+        if value < 1024:
+            return f"{value:.1f}{unit}"
+        value /= 1024
+    return f"{value:.1f}TiB/s"
+
+
+def _format_eta(seconds: float | None) -> str:
+    if seconds is None:
+        return ""
+    seconds = int(seconds)
+    minutes, secs = divmod(seconds, 60)
+    hours, minutes = divmod(minutes, 60)
+    if hours:
+        return f"{hours:d}:{minutes:02d}:{secs:02d}"
+    return f"{minutes:d}:{secs:02d}"
+
+
+class YtmdlApp(App):
+    CSS = """
+    #url-input {
+        margin: 1 2 0 2;
+    }
+
+    #output-dir-row {
+        height: auto;
+        margin: 0 2 1 2;
+    }
+
+    #output-dir-label {
+        width: auto;
+        color: $text-muted;
+        padding: 1 1 0 0;
+    }
+
+    #output-dir-button {
+        min-width: 0;
+        height: 1;
+        margin-top: 1;
+        border: none;
+        background: transparent;
+        color: $text;
+        text-style: underline;
+    }
+
+    #output-dir-button:hover {
+        color: $accent;
+    }
+
+    #playlist-folder-row {
+        height: auto;
+        margin: 0 2 1 2;
+    }
+
+    #playlist-folder-row Label {
+        color: $text-muted;
+        padding: 1 0 0 1;
+    }
+
+    DataTable {
+        margin: 0 2 1 2;
+    }
+    """
+
+    BINDINGS = [
+        Binding("q", "quit", "Quit"),
+        Binding("d,delete", "remove_selected", "Remove"),
+        Binding("r", "retry_selected", "Retry"),
+        Binding("ctrl+o", "edit_output_dir", "Change save dir", priority=True),
+        Binding("ctrl+n", "clear_finished", "Clear finished", priority=True),
+    ]
+
+    def __init__(
+        self,
+        output_dir: Path = DEFAULT_OUTPUT_DIR,
+        audio_format: str = "mp3",
+    ) -> None:
+        super().__init__()
+        self.output_dir = output_dir
+        self.audio_format = audio_format
+        self.playlist_folders = True
+        self.items: dict[str, QueueItem] = {}
+        self.download_queue: sync_queue.Queue[QueueItem | None] = sync_queue.Queue()
+
+    def compose(self) -> ComposeResult:
+        yield Header()
+        yield Vertical(
+            Input(
+                placeholder="Paste a YouTube / YouTube Music video, playlist, or album URL and press Enter",
+                id="url-input",
+            ),
+            Horizontal(
+                Label("Saving to:", id="output-dir-label"),
+                Button(str(self.output_dir), id="output-dir-button"),
+                id="output-dir-row",
+            ),
+            Horizontal(
+                Switch(value=self.playlist_folders, id="playlist-folder-switch"),
+                Label("Create a folder per playlist/album (named after it)"),
+                id="playlist-folder-row",
+            ),
+        )
+        table = DataTable(id="queue-table")
+        table.cursor_type = "row"
+        table.zebra_stripes = True
+        yield table
+        yield Footer()
+
+    def on_mount(self) -> None:
+        table = self.query_one(DataTable)
+        self.columns = table.add_columns("Status", "Title", "Folder", "Progress", "Speed", "ETA")
+        self.query_one("#url-input", Input).focus()
+        for _ in range(CONCURRENT_DOWNLOADS):
+            self.run_worker(self._download_worker, thread=True, exclusive=False)
+
+    def on_input_submitted(self, event: Input.Submitted) -> None:
+        url = event.value.strip()
+        if not url:
+            return
+        event.input.value = ""
+        self.notify(f"Resolving {url}...", timeout=3)
+        self.run_worker(lambda: self._resolve_worker(url), thread=True, exclusive=False)
+
+    def on_button_pressed(self, event: Button.Pressed) -> None:
+        if event.button.id == "output-dir-button":
+            self.action_edit_output_dir()
+
+    def on_switch_changed(self, event: Switch.Changed) -> None:
+        if event.switch.id == "playlist-folder-switch":
+            self.playlist_folders = event.value
+
+    def action_command_palette(self) -> None:
+        if self.use_command_palette and not NerdCommandPalette.is_open(self):
+            self.push_screen(NerdCommandPalette(id="--command-palette"))
+
+    def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
+        yield from super().get_system_commands(screen)
+        yield SystemCommand(
+            "Clear finished",
+            "Remove completed/errored entries from the queue to start a new round",
+            self.action_clear_finished,
+        )
+
+    def action_edit_output_dir(self) -> None:
+        self.push_screen(DirectoryPickerScreen(self.output_dir), self._on_output_dir_picked)
+
+    def _on_output_dir_picked(self, path: Path | None) -> None:
+        if path is None:
+            return
+        self.output_dir = path
+        self.query_one("#output-dir-button", Button).label = str(path)
+        self.notify(f"Now saving to {path}", timeout=3)
+
+    # --- background workers -------------------------------------------------
+
+    def _resolve_worker(self, url: str) -> None:
+        try:
+            result = downloader.resolve(url)
+        except downloader.ResolveError as exc:
+            self.call_from_thread(self.notify, f"Failed to resolve: {exc}", severity="error", timeout=6)
+            return
+
+        subdir = ""
+        if result.playlist_title and self.playlist_folders:
+            subdir = downloader.sanitize_dirname(result.playlist_title)
+
+        for entry in result.entries:
+            item = QueueItem(url=entry["url"], title=entry["title"], subdir=subdir)
+            self.call_from_thread(self._enqueue_item, item)
+
+    def _download_worker(self) -> None:
+        worker = get_current_worker()
+        while not worker.is_cancelled:
+            try:
+                item = self.download_queue.get(timeout=0.5)
+            except sync_queue.Empty:
+                continue
+            if item is None:
+                break
+            self._process_item(item)
+
+    def _process_item(self, item: QueueItem) -> None:
+        if item.status is Status.DONE:
+            return
+        self.call_from_thread(self._update_item, item.id, status=Status.DOWNLOADING, error="")
+
+        def hook(d: dict) -> None:
+            if d.get("status") == "downloading":
+                total = d.get("total_bytes") or d.get("total_bytes_estimate")
+                downloaded = d.get("downloaded_bytes") or 0
+                percent = (downloaded / total * 100) if total else 0.0
+                speed = _format_speed(d.get("speed"))
+                eta = _format_eta(d.get("eta"))
+                self.call_from_thread(
+                    self._update_item, item.id, percent=percent, speed=speed, eta=eta
+                )
+
+        target_dir = self.output_dir / item.subdir if item.subdir else self.output_dir
+        try:
+            downloader.download(
+                item.url,
+                target_dir,
+                audio_format=self.audio_format,
+                progress_hook=hook,
+            )
+        except downloader.DownloadError as exc:
+            self.call_from_thread(
+                self._update_item, item.id, status=Status.ERROR, error=str(exc), speed="", eta=""
+            )
+        else:
+            self.call_from_thread(
+                self._update_item, item.id, status=Status.DONE, percent=100.0, speed="", eta=""
+            )
+
+    # --- UI-thread state mutation -------------------------------------------
+
+    def _enqueue_item(self, item: QueueItem) -> None:
+        self.items[item.id] = item
+        table = self.query_one(DataTable)
+        table.add_row(
+            STATUS_GLYPH[item.status],
+            item.title,
+            item.subdir,
+            "0%",
+            "",
+            "",
+            key=item.id,
+        )
+        self.download_queue.put(item)
+
+    def _update_item(self, item_id: str, **changes) -> None:
+        item = self.items.get(item_id)
+        if item is None:
+            return
+        for field, value in changes.items():
+            setattr(item, field, value)
+
+        table = self.query_one(DataTable)
+        if item_id not in table.rows:
+            return
+        status_col, title_col, folder_col, progress_col, speed_col, eta_col = self.columns
+        table.update_cell(item_id, status_col, STATUS_GLYPH[item.status])
+        if item.status is Status.ERROR:
+            table.update_cell(item_id, progress_col, item.error[:40])
+        else:
+            table.update_cell(item_id, progress_col, f"{item.percent:.0f}%")
+        table.update_cell(item_id, speed_col, item.speed)
+        table.update_cell(item_id, eta_col, item.eta)
+
+    # --- actions -------------------------------------------------------------
+
+    def _selected_item_id(self) -> str | None:
+        table = self.query_one(DataTable)
+        if table.row_count == 0:
+            return None
+        try:
+            row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
+        except Exception:
+            return None
+        return row_key.value
+
+    def action_clear_finished(self) -> None:
+        finished_ids = [
+            item.id for item in self.items.values() if item.status in (Status.DONE, Status.ERROR)
+        ]
+        table = self.query_one(DataTable)
+        for item_id in finished_ids:
+            table.remove_row(item_id)
+            del self.items[item_id]
+        if finished_ids:
+            self.notify(f"Cleared {len(finished_ids)} finished item(s)", timeout=3)
+        else:
+            self.notify("Nothing to clear", timeout=2)
+
+    def action_remove_selected(self) -> None:
+        item_id = self._selected_item_id()
+        if item_id is None:
+            return
+        item = self.items.get(item_id)
+        if item is None or item.status is Status.DOWNLOADING:
+            return
+        table = self.query_one(DataTable)
+        table.remove_row(item_id)
+        del self.items[item_id]
+
+    def action_retry_selected(self) -> None:
+        item_id = self._selected_item_id()
+        if item_id is None:
+            return
+        item = self.items.get(item_id)
+        if item is None or item.status is not Status.ERROR:
+            return
+        item.status = Status.PENDING
+        item.percent = 0.0
+        item.error = ""
+        self._update_item(item_id, status=Status.PENDING)
+        self.download_queue.put(item)
+
+    def action_quit(self) -> None:
+        for _ in range(CONCURRENT_DOWNLOADS):
+            self.download_queue.put(None)
+        self.exit()
diff --git a/src/ytmdl/downloader.py b/src/ytmdl/downloader.py
new file mode 100644
index 0000000..9cc8191
--- /dev/null
+++ b/src/ytmdl/downloader.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable
+
+import yt_dlp
+
+ProgressHook = Callable[[dict], None]
+
+_ILLEGAL_DIRNAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
+
+
+class ResolveError(Exception):
+    pass
+
+
+class DownloadError(Exception):
+    pass
+
+
+@dataclass
+class ResolveResult:
+    entries: list[dict]
+    playlist_title: str | None = None
+
+
+def sanitize_dirname(name: str, max_length: int = 150) -> str:
+    """Turn an arbitrary playlist/album title into a safe directory name."""
+    cleaned = _ILLEGAL_DIRNAME_CHARS.sub("_", name).strip(" .")
+    cleaned = re.sub(r"\s+", " ", cleaned)
+    return cleaned[:max_length] or "playlist"
+
+
+def resolve(url: str) -> ResolveResult:
+    """Expand a video/playlist/album URL into a flat list of {url, title} entries."""
+    ydl_opts = {
+        "extract_flat": "in_playlist",
+        "quiet": True,
+        "no_warnings": True,
+        "skip_download": True,
+    }
+    try:
+        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+            info = ydl.extract_info(url, download=False)
+    except yt_dlp.utils.DownloadError as exc:
+        raise ResolveError(str(exc)) from exc
+
+    if info is None:
+        raise ResolveError(f"No data returned for {url}")
+
+    is_playlist = info.get("entries") is not None
+    raw_entries = info.get("entries") if is_playlist else [info]
+    playlist_title = info.get("title") if is_playlist else None
+
+    entries: list[dict] = []
+    for entry in raw_entries:
+        if entry is None:
+            continue
+        entry_url = entry.get("url") or entry.get("webpage_url")
+        if entry_url and not entry_url.startswith("http"):
+            video_id = entry.get("id") or entry_url
+            entry_url = f"https://www.youtube.com/watch?v={video_id}"
+        if not entry_url:
+            continue
+        entries.append({"url": entry_url, "title": entry.get("title") or entry_url})
+
+    if not entries:
+        raise ResolveError(f"Nothing downloadable found at {url}")
+
+    return ResolveResult(entries=entries, playlist_title=playlist_title)
+
+
+def download(
+    url: str,
+    output_dir: Path,
+    audio_format: str = "mp3",
+    progress_hook: ProgressHook | None = None,
+) -> Path:
+    """Download a single URL as an audio file with embedded metadata/thumbnail."""
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    hooks = [progress_hook] if progress_hook else []
+
+    ydl_opts = {
+        "format": "bestaudio/best",
+        "outtmpl": str(output_dir / "%(title)s.%(ext)s"),
+        "postprocessors": [
+            {
+                "key": "FFmpegExtractAudio",
+                "preferredcodec": audio_format,
+                "preferredquality": "0",
+            },
+            {"key": "FFmpegThumbnailsConvertor", "format": "jpg"},
+            {"key": "EmbedThumbnail"},
+            {"key": "FFmpegMetadata", "add_metadata": True},
+        ],
+        "writethumbnail": True,
+        "progress_hooks": hooks,
+        "quiet": True,
+        "no_warnings": True,
+        "noprogress": True,
+        "restrictfilenames": False,
+    }
+
+    try:
+        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+            info = ydl.extract_info(url, download=True)
+    except yt_dlp.utils.DownloadError as exc:
+        raise DownloadError(str(exc)) from exc
+
+    title = info.get("title", url)
+    return output_dir / f"{title}.{audio_format}"
diff --git a/src/ytmdl/queue.py b/src/ytmdl/queue.py
new file mode 100644
index 0000000..cf2e3c6
--- /dev/null
+++ b/src/ytmdl/queue.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+import uuid
+from dataclasses import dataclass, field
+from enum import Enum
+
+
+class Status(str, Enum):
+    PENDING = "pending"
+    RESOLVING = "resolving"
+    DOWNLOADING = "downloading"
+    DONE = "done"
+    ERROR = "error"
+
+
+STATUS_GLYPH = {
+    Status.PENDING: "",  # nf-fa-clock_o
+    Status.RESOLVING: "",  # nf-fa-refresh
+    Status.DOWNLOADING: "",  # nf-fa-download
+    Status.DONE: "",  # nf-fa-check
+    Status.ERROR: "",  # nf-fa-times
+}
+
+
+@dataclass
+class QueueItem:
+    url: str
+    title: str = ""
+    subdir: str = ""
+    status: Status = Status.PENDING
+    percent: float = 0.0
+    speed: str = ""
+    eta: str = ""
+    error: str = ""
+    id: str = field(default_factory=lambda: uuid.uuid4().hex)
diff --git a/src/ytmdl/screens.py b/src/ytmdl/screens.py
new file mode 100644
index 0000000..19e24e9
--- /dev/null
+++ b/src/ytmdl/screens.py
@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Iterable
+
+from textual.command import CommandPalette
+from textual.containers import Horizontal, Vertical
+from textual.reactive import var
+from textual.screen import ModalScreen
+from textual.widgets import Button, DirectoryTree, Label
+
+
+class NerdCommandPalette(CommandPalette):
+    icon: var[str] = var(" ")  # nf-fa-search
+
+
+class DirsOnlyDirectoryTree(DirectoryTree):
+    ICON_NODE = " "  # nf-fa-folder
+    ICON_NODE_EXPANDED = " "  # nf-fa-folder_open
+
+    def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
+        return [p for p in paths if p.is_dir() and not p.name.startswith(".")]
+
+
+class DirectoryPickerScreen(ModalScreen[Path | None]):
+    CSS = """
+    DirectoryPickerScreen {
+        align: center middle;
+    }
+
+    #picker-dialog {
+        width: 80%;
+        height: 80%;
+        border: round $primary;
+        background: $surface;
+        padding: 1 2;
+    }
+
+    #picker-title {
+        height: auto;
+        text-style: bold;
+    }
+
+    #picker-current {
+        height: auto;
+        color: $text-muted;
+        margin-bottom: 1;
+    }
+
+    DirsOnlyDirectoryTree {
+        height: 1fr;
+        border: round $panel;
+    }
+
+    #picker-buttons {
+        height: auto;
+        margin-top: 1;
+        align: right middle;
+    }
+
+    #picker-buttons Button {
+        margin-left: 1;
+    }
+
+    #up-btn {
+        dock: left;
+        margin-left: 0;
+    }
+    """
+
+    BINDINGS = [
+        ("escape", "cancel", "Cancel"),
+        ("backspace", "go_up", "Up"),
+    ]
+
+    def __init__(self, start_path: Path) -> None:
+        super().__init__()
+        path = start_path
+        while not path.is_dir():
+            if path.parent == path:
+                path = Path.home()
+                break
+            path = path.parent
+        self.start_path = path
+        self.selected_path = path
+
+    def compose(self):
+        yield Vertical(
+            Label("Choose a folder to save downloads to", id="picker-title"),
+            Label(str(self.selected_path), id="picker-current"),
+            DirsOnlyDirectoryTree(self.start_path, id="picker-tree"),
+            Horizontal(
+                Button("Up", id="up-btn"),
+                Button("Cancel", id="cancel-btn"),
+                Button("Select", id="select-btn", variant="primary"),
+                id="picker-buttons",
+            ),
+            id="picker-dialog",
+        )
+
+    def on_mount(self) -> None:
+        self.query_one(DirsOnlyDirectoryTree).focus()
+
+    def on_directory_tree_directory_selected(
+        self, event: DirectoryTree.DirectorySelected
+    ) -> None:
+        self.selected_path = event.path
+        self.query_one("#picker-current", Label).update(str(self.selected_path))
+
+    def on_button_pressed(self, event: Button.Pressed) -> None:
+        if event.button.id == "select-btn":
+            self.dismiss(self.selected_path)
+        elif event.button.id == "cancel-btn":
+            self.dismiss(None)
+        elif event.button.id == "up-btn":
+            self.action_go_up()
+
+    def action_cancel(self) -> None:
+        self.dismiss(None)
+
+    def action_go_up(self) -> None:
+        tree = self.query_one(DirsOnlyDirectoryTree)
+        current_root = Path(tree.path)
+        parent = current_root.parent
+        if parent == current_root:
+            return
+        tree.path = parent
+        self.selected_path = parent
+        self.query_one("#picker-current", Label).update(str(parent))
diff --git a/start-python.sh b/start-python.sh
new file mode 100755
index 0000000..963a2a6
--- /dev/null
+++ b/start-python.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "${BASH_SOURCE[0]}")"
+
+if [ ! -x ".venv/bin/ytmdl" ]; then
+    echo "venv saknas eller är inte installerad, kör: python3 -m venv .venv && .venv/bin/pip install -e ." >&2
+    exit 1
+fi
+
+exec .venv/bin/ytmdl "$@"
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..40c0418
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$repo_root/c"
+
+if [ ! -x "ytmdl" ] || [ "src/main.c" -nt "ytmdl" ]; then
+    make
+fi
+
+# Prefer this repo's own yt-dlp (kept up to date via pip) over an older
+# system package, since an outdated yt-dlp fails to parse current YouTube
+# pages (e.g. "Unsupported lockup view model content type", playlists
+# resolving to zero entries).
+if [ -x "$repo_root/.venv/bin/yt-dlp" ]; then
+    export PATH="$repo_root/.venv/bin:$PATH"
+fi
+
+exec ./ytmdl "$@"