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 "$@"