diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9b53e6b..5a6f38e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,18 +15,19 @@ jobs:
- uses: mlugg/setup-zig@v2
with:
- version: 0.16.0
-
- - name: install libmpv
- run: sudo apt-get update && sudo apt-get install -y libmpv-dev
+ version: 0.17.0
- name: unit tests
run: |
- zig build test -Dmpv-prefix=
- zig build test -Doptimize=ReleaseSafe -Dmpv-prefix=
+ zig build test
+ zig build test -Doptimize=ReleaseSafe
- # Type-checks freebsd/linux/macos in Debug and ReleaseSafe without
- # linking. The FreeBSD release leg has broken twice on translate-c; this
- # catches that on every push instead of at tag time inside a VM.
- name: cross-target check
run: zig build check
+
+ - name: libmpv runtime load
+ run: |
+ sudo apt-get update && sudo apt-get install -y libmpv2
+ zig build
+ ./zig-out/bin/hum doctor | tee doctor.txt
+ grep -E '^ libmpv +libmpv\.so' doctor.txt
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index be495d9..ea7089f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,46 +8,8 @@ permissions:
contents: write
jobs:
- build-linux:
- name: ${{ matrix.name }}
- runs-on: ${{ matrix.os }}
- timeout-minutes: 30
- strategy:
- fail-fast: false
- matrix:
- include:
- - { name: linux-arm64, os: ubuntu-24.04-arm }
- - { name: linux-x86_64, os: ubuntu-24.04 }
- steps:
- - uses: actions/checkout@v5
-
- - uses: mlugg/setup-zig@v2
- with:
- version: 0.16.0
-
- - name: install libmpv
- run: sudo apt-get update && sudo apt-get install -y libmpv-dev
-
- - name: build
- run: |
- zig build test -Dmpv-prefix=
- zig build -Doptimize=ReleaseSafe -Dmpv-prefix=
-
- - name: package
- run: tar -czf "hum-${{ matrix.name }}.tar.gz" -C zig-out/bin hum
-
- - uses: softprops/action-gh-release@v3
- with:
- files: hum-${{ matrix.name }}.tar.gz
-
- # Single universal (arm64 + x86_64) binary, cross-built on one Apple-silicon
- # runner and lipo'd — avoids the scarce macos-13 Intel runner entirely.
- # Apple-silicon only. The universal build died when Homebrew stopped
- # shipping x86_64 bottles for mpv (Tier 3 under Rosetta): the x86_64 slice
- # needs an x86_64 libmpv to link against, and building mpv from source in CI
- # means building ffmpeg too. Intel Macs build from source via the tap.
- build-macos:
- name: macos-arm64
+ build:
+ name: all targets
runs-on: macos-14
timeout-minutes: 30
steps:
@@ -55,74 +17,30 @@ jobs:
- uses: mlugg/setup-zig@v2
with:
- version: 0.16.0
+ version: 0.17.0
- - name: install libmpv
- run: brew install mpv
+ - name: test
+ run: zig build test
- name: build
run: |
- MPV="$(brew --prefix mpv)"
- zig build test -Dmpv-prefix="$MPV"
- zig build -Doptimize=ReleaseSafe -Dtarget=aarch64-macos -Dmpv-prefix="$MPV"
- file zig-out/bin/hum
+ set -e
+ for t in aarch64-macos x86_64-macos x86_64-linux-gnu aarch64-linux-gnu x86_64-freebsd; do
+ zig build -Doptimize=ReleaseSafe -Dtarget=$t --prefix out/$t
+ done
+ mkdir -p out/macos-universal/bin
+ lipo -create -output out/macos-universal/bin/hum out/aarch64-macos/bin/hum out/x86_64-macos/bin/hum
+ file out/*/bin/hum
- name: package
- run: tar -czf hum-macos-arm64.tar.gz -C zig-out/bin hum
-
- - uses: softprops/action-gh-release@v3
- with:
- files: hum-macos-arm64.tar.gz
-
- # Everything here runs inside an emulated FreeBSD VM, so every step is
- # 5-10x its native cost. The 30-minute cap was being hit; the work was
- # a 55MB in-VM toolchain download plus two full compiles.
- build-freebsd:
- name: freebsd-x86_64
- runs-on: ubuntu-latest
- timeout-minutes: 60
- continue-on-error: true
- steps:
- - uses: actions/checkout@v5
-
- # Pull the toolchain on the fast host (and cache it between releases)
- # instead of over the VM's emulated network. The VM sees the workspace.
- - name: cache zig tarball
- id: zigcache
- uses: actions/cache@v4
- with:
- path: zig-freebsd.tar.xz
- key: zig-freebsd-0.16.0
-
- - name: fetch zig (host)
- if: steps.zigcache.outputs.cache-hit != 'true'
- run: curl -fsSL -o zig-freebsd.tar.xz "https://ziglang.org/download/0.16.0/zig-x86_64-freebsd-0.16.0.tar.xz"
-
- - uses: vmactions/freebsd-vm@v1
- with:
- usesh: true
- prepare: |
- set -e
- # mpv drags in ffmpeg/X11/vulkan; we only ever link libmpv, but the
- # port is how FreeBSD ships it. Keep the set otherwise minimal.
- pkg install -y mpv pkgconf curl
- run: |
- set -e
- # Unpacked here rather than in `prepare`: this block is the one that
- # is guaranteed to run inside the synced workspace, where the
- # host-fetched tarball actually is.
- mkdir -p /opt/zig
- tar -xJf zig-freebsd.tar.xz -C /opt/zig --strip-components=1
- ln -sf /opt/zig/zig /usr/local/bin/zig
- zig version
- # Tests already run on the linux and macos legs of this same
- # workflow. Repeating them here bought a second full compile in the
- # slowest environment we have, for no extra coverage.
- zig build -Doptimize=ReleaseSafe -Dmpv-prefix=/usr/local
-
- - name: package
- run: tar -czf hum-freebsd-x86_64.tar.gz -C zig-out/bin hum
+ run: |
+ set -e
+ pack() { tar -czf "hum-$1.tar.gz" -C "out/$2/bin" hum; }
+ pack macos-universal macos-universal
+ pack linux-x86_64 x86_64-linux-gnu
+ pack linux-arm64 aarch64-linux-gnu
+ pack freebsd-x86_64 x86_64-freebsd
- uses: softprops/action-gh-release@v3
with:
- files: hum-freebsd-x86_64.tar.gz
+ files: hum-*.tar.gz
diff --git a/.zigversion b/.zigversion
index 04a373e..c5523bd 100644
--- a/.zigversion
+++ b/.zigversion
@@ -1 +1 @@
-0.16.0
+0.17.0
diff --git a/README.md b/README.md
index 50223bb..91cae89 100644
--- a/README.md
+++ b/README.md
@@ -10,15 +10,33 @@ your local music library, streamed rss/urls, and yt music, one zig binary that r
-zig 0.16, single binary
-- `libmpv` for audio; plays local files, streams, and youtube
+zig 0.17, single binary
+- `libmpv` for audio, loaded at runtime; plays local files, streams, and youtube
- `hum` now has it's own tag reader (id3, vorbis/flac, ogg/opus, mp4) | no taglib, no ffprobe
- shells out to `curl` and `yt-dlp` **for youtube obviously**; local playback needs neither
-- astats lavfi filter for visualizer via `ffmpeg`
+- astats lavfi filter for visualizer (ffmpeg inside mpv)
- outbound calls only: never listens on a socket
## version
-v0.1.7 dropping out the `ytcli` name; it's a media player now and youtube is only one source
+v0.1.8 visualizers, mouse, and installs anywhere mpv does
+- **libmpv loads at runtime** instead of being linked: one binary per platform, and intel macs get a release again (universal macOS binary)
+- no mpv? `hum` still starts, searches and browses, and tells you to install mpv instead of failing to launch
+- **mp3 durations**: local mp3s without a `TLEN` tag showed `00:00`; length now comes from the Xing/Info header, or bytes ÷ bitrate for CBR. existing library rows refresh on the next scan
+- **linux media keys / status bars** (MPRIS): install `mpv-mpris` and hum shows up in waybar, polybar, gnome, kde
+- now-playing widgets (macOS Now Playing, MPRIS) show the track title instead of a stream URL
+- `hum doctor` reports where libmpv was found and flags a stale `yt-dlp`; the TUI says so too when a youtube track fails because of it
+- **visualizer view** (`Ctrl+V`): the visualizer takes over the main box while now playing stays below, and the queue sits beside it on wide terminals. five built in: bars, cube, orbit, rings, plasma. `v`/`V` cycles them. they follow your theme and react to loudness, left/right balance and brightness (treble). resize all you want; it lays out fresh every frame
+- **your own visualizers**: drop a `.vis` text file (ascii frames + a few `key = value` lines) in `~/.config/hum/visualizers/` and it joins the cycle. see [`examples/visualizers/pulse.vis`](examples/visualizers/pulse.vis)
+- **mouse**: wheel scrolls lists, click selects, click again plays, click the progress bar to seek, click `both`/`youtube`/`library` and `all`/`songs`/… to switch scope and filter. in the visualizer: left/right click cycles, the queue panel plays on click and reorders by drag. `mouse=off` in the config turns it off (hold `shift`, or `option` in macOS Terminal, to select text while it's on)
+- **paste**: pasting into search goes in as text (bracketed paste), so a pasted newline or escape code can't fire keys
+- **zig 0.17**: builds with current zig (and homebrew's), no more C header imports anywhere
+- `brew install zblauser/tap/hum` installs the prebuilt binary instead of compiling: intel macs no longer build zig (and LLVM) from source first
+
+
+previous
+
+v0.1.7
+dropping out the `ytcli` name; it's a media player now and youtube is only one source
- **renamed**: the binary is `hum`. your existing history, playlists and config keep working; it does read the old `ytcli/` config/data dirs when the new `hum/` ones aren't present
- **local files**: `hum ~/Music/album/01.flac` plays a file; `http(s)://` URL streams
- **local library**: `hum library dir ~/Music`, then `Ctrl+L` in the TUI browses artists → albums → tracks
@@ -30,9 +48,6 @@ zig 0.16, single binary
- fix: search rows showed a duration where the artist belongs (top-result rows come from a card shelf that carries the artist in its header)
- dropped: cookie/SAPISIDHASH personalization. not worth the credential surface
-
-previous
-
v0.1.6
- fix crash on non-UTF-8 autocomplete (the suggest endpoint answers in latin-1 without `ie`/`oe=utf-8`)
- remote text is scrubbed of control bytes before it reaches your terminal or your history/playlist files
@@ -84,24 +99,28 @@ zig 0.16, single binary
+ handful of color themes
-## build/install
+## install
```sh
-zig build # → zig-out/bin/hum
-zig build install --prefix ~/.local # → ~/.local/bin/hum
+brew install zblauser/tap/hum # macOS / linuxbrew: prebuilt binary + mpv + yt-dlp, nothing to compile
```
+or grab a binary from [releases](../../releases): macOS (universal), Linux (arm64/x86_64), FreeBSD. windows: run under WSL (no native build just yet).
+
## dependencies
```sh
-brew install mpv ffmpeg # macOS (mpv pulls in yt-dlp)
-apt install libmpv-dev yt-dlp ffmpeg # Debian/Ubuntu
+brew install mpv # macOS (mpv pulls in yt-dlp)
+apt install libmpv2 yt-dlp # Debian/Ubuntu
+apt install mpv-mpris # optional: media keys + status bar widgets on linux
```
-> **[ ! ]** currently requires `mpv`, `ffmpeg` and `yt-dlp` particularly on PATH (try and keep your `yt-dlp` updated, youtube breaks old versions)
+> **[ ! ]** `libmpv` plays everything; `yt-dlp` is only for youtube (try and keep it updated, youtube breaks old versions). `hum doctor` checks both
-## releases
-
-macOS (universal: arm64 + x86_64), Linux (arm64/x86_64), and FreeBSD binaries are attached to each [release](../../releases). windows: run under WSL (no native build just yet).
+## build
+```sh
+zig build # → zig-out/bin/hum (needs only zig 0.17, not mpv)
+zig build install --prefix ~/.local # → ~/.local/bin/hum
+```
## run
```sh
@@ -171,6 +190,17 @@ made an effort to use commands that felt fairly intuitive
| | `m` | Mute |
| | `Ctrl+R` | Cycle repeat mode (off/track/queue) |
| | `Ctrl+Y` | Cycle theme |
+| | `Ctrl+V` | Visualizer view (`esc` or `Ctrl+V` again to leave) |
+| **Visualizer (`Ctrl+V`)** | `v` / `V` | Next / previous visualizer (remembered) |
+| | `↑` / `↓` | Volume up / down |
+| | `←` / `→` | Seek backward / forward 10 seconds |
+| **Mouse** | wheel | Move the selection (volume in the visualizer) |
+| | click | Select a row; click it again to play or open it |
+| | click the progress bar | Jump to that point in the track |
+| | left / right click the visualizer | Next / previous visualizer |
+| | click a queue-panel track | Play it |
+| | drag a track onto another | Move it there (queue panel and `Ctrl+Q`) |
+| | click a scope or filter label | Switch to it |
@@ -179,7 +209,8 @@ made an effort to use commands that felt fairly intuitive
- `$XDG_DATA_HOME/hum/log` - timestamped failures (search/album/stream) with the underlying error and any `curl`/`yt-dlp` stderr. check here first when something says `(see log)`.
- `$XDG_DATA_HOME/hum/playlists` - saved playlists: `[name]` header, then one `target⇥title⇥artist⇥kind⇥source⇥duration` line per track (`target` is a video id, or a path for local files). tab-separated, editable by hand. files written by older versions still load.
- `$XDG_CACHE_HOME/hum/library` - the local index. rebuildable. delete it and `hum library scan`. rows are reused when a file's mtime and size are unchanged, so a rescan only re-reads what actually changed.
-- `$XDG_CONFIG_HOME/hum/config` - `key=value` settings (theme, volume, `music_dir`).
+- `$XDG_CONFIG_HOME/hum/config` - `key=value` settings (theme, volume, `music_dir`, `visualizer`, `mouse=off`, `splash=off`).
+- `$XDG_CONFIG_HOME/hum/visualizers/*.vis` - your own visualizers. header lines `name`, `fps` (1-30), `speed` (`fixed`/`level`), `color` (`level`/`dim`/`accent`/`strong`), then frames separated by `---` lines. up to 32 files, 64KB each; a file that doesn't parse is skipped and noted in the log
- history is just newline delimited text - `grep`/`cat` it, or seed it so the TUI autocompletes your favorites from the first keystroke:
```sh
printf '%s\n' "elephant gym" "autechre" "john zorn" >> ~/.local/share/hum/history
@@ -192,8 +223,15 @@ grep -i jazz ~/.local/share/hum/history
> [ ! ] requests written to `/tmp/hum_body*` (mkstemp, 0600, unlinked after) per call + audio streamed/buffered via mpv; not written to disk.
## visualizer
-simple spectrum bars via `astats` lavfi filter
-dB to linear/modulated per bar | *not a true per-band FFT*
+
+
+
+
+full screen the visualizer using `Ctrl+V` while something plays. `v`/`V` cycles bars, cube, orbit, rings, plasma and any `.vis` files you've added; left/right click on the visualizer does the same. the queue on the right is clickable (play) and draggable (reorder)
+
+mpv's `astats` lavfi filter reports loudness, per-channel level and the zero-crossing rate (a stand-in for brightness/treble) every 50ms; the visualizers are driven by those
+
+*not a true per-band FFT*: the bars are modulated by loudness, not split by frequency
## contribution
please feel free to contribute, not a guarantee it will be merged, but a guarantee a human will look at it
diff --git a/assets/hum.gif b/assets/hum.gif
index 0997264..7267dc3 100644
Binary files a/assets/hum.gif and b/assets/hum.gif differ
diff --git a/assets/visualizers.gif b/assets/visualizers.gif
new file mode 100644
index 0000000..52ec744
Binary files /dev/null and b/assets/visualizers.gif differ
diff --git a/build.zig b/build.zig
index dbc2f29..673fe8a 100644
--- a/build.zig
+++ b/build.zig
@@ -6,7 +6,7 @@ pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
- const mpv_prefix = b.option([]const u8, "mpv-prefix", "libmpv install prefix (default /usr/local)") orelse "/usr/local";
+ const mpv_prefix = b.option([]const u8, "mpv-prefix", "libmpv prefix searched first at runtime") orelse "";
const mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
@@ -14,10 +14,10 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
.link_libc = true,
});
- linkMpv(b, mod, mpv_prefix);
const options = b.addOptions();
options.addOption([]const u8, "version", version);
+ options.addOption([]const u8, "mpv_prefix", mpv_prefix);
mod.addOptions("build_options", options);
const exe = b.addExecutable(.{
@@ -29,17 +29,20 @@ pub fn build(b: *std.Build) void {
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
- if (b.args) |args| run_cmd.addArgs(args);
+ run_cmd.addPassthruArgs();
const run_step = b.step("run", "Run hum");
run_step.dependOn(&run_cmd.step);
+ const clean_cmd = b.addSystemCommand(&.{ "rm", "-rf", "zig-out", ".zig-cache" });
+ const clean_step = b.step("clean", "Remove zig-out and the local build cache");
+ clean_step.dependOn(&clean_cmd.step);
+
const test_mod = b.createModule(.{
.root_source_file = b.path("src/tests.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
});
- linkMpv(b, test_mod, mpv_prefix);
test_mod.addOptions("build_options", options);
const unit_tests = b.addTest(.{ .root_module = test_mod });
@@ -47,28 +50,12 @@ pub fn build(b: *std.Build) void {
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_unit_tests.step);
- addCheckStep(b, mpv_prefix, options);
+ addCheckStep(b, options);
}
-/// `zig build check` type-checks every release target without linking, so a
-/// platform-specific break (FreeBSD translate-c, most often) surfaces in seconds
-/// on any machine instead of at tag time in a VM.
-fn addCheckStep(b: *std.Build, mpv_prefix: []const u8, options: *std.Build.Step.Options) void {
+fn addCheckStep(b: *std.Build, options: *std.Build.Step.Options) void {
const check = b.step("check", "Type-check all release targets (no link)");
- // Zig ships libc headers for every target below, so the ONLY header this
- // needs from the host is mpv/client.h. Copy that one file into a generated
- // directory rather than putting a system include path on the command line:
- // -I /usr/include would feed the host's glibc headers to a macOS or FreeBSD
- // cross-compile, which fails on glibc internals.
- const header = findMpvHeader(b, mpv_prefix) orelse {
- std.log.warn("check: mpv/client.h not found; skipping (pass -Dmpv-prefix)", .{});
- return;
- };
- const shim = b.addWriteFiles();
- _ = shim.addCopyFile(.{ .cwd_relative = header }, "mpv/client.h");
- const include_dir = shim.getDirectory();
-
const targets = [_][]const u8{
"x86_64-freebsd",
"x86_64-linux-gnu",
@@ -76,8 +63,6 @@ fn addCheckStep(b: *std.Build, mpv_prefix: []const u8, options: *std.Build.Step.
"x86_64-macos",
"aarch64-macos",
};
- // ReleaseSafe as well as Debug: FreeBSD's __ssp fortify wrappers only appear
- // in optimized builds.
const modes = [_]std.builtin.OptimizeMode{ .Debug, .ReleaseSafe };
for (targets) |triple| {
@@ -89,7 +74,6 @@ fn addCheckStep(b: *std.Build, mpv_prefix: []const u8, options: *std.Build.Step.
.optimize = mode,
.link_libc = true,
});
- mod.addIncludePath(include_dir);
mod.addOptions("build_options", options);
const obj = b.addObject(.{
.name = b.fmt("check-{s}-{s}", .{ triple, @tagName(mode) }),
@@ -99,34 +83,3 @@ fn addCheckStep(b: *std.Build, mpv_prefix: []const u8, options: *std.Build.Step.
}
}
}
-
-/// Locate mpv/client.h: the configured prefix, then the usual system locations.
-fn findMpvHeader(b: *std.Build, prefix: []const u8) ?[]const u8 {
- const dirs = [_][]const u8{
- if (prefix.len > 0) b.fmt("{s}/include", .{prefix}) else "",
- "/usr/include",
- "/usr/local/include",
- "/opt/homebrew/include",
- };
- for (dirs) |dir| {
- if (dir.len == 0) continue;
- const path = b.fmt("{s}/mpv/client.h", .{dir});
- std.Io.Dir.cwd().access(b.graph.io, path, .{}) catch continue;
- return path;
- }
- return null;
-}
-
-fn linkMpv(b: *std.Build, mod: *std.Build.Module, prefix: []const u8) void {
- if (prefix.len > 0) {
- // Explicit prefix wins: skip pkg-config, which resolves to the HOST
- // arch's mpv and breaks cross-compiles (e.g. the x86_64 slice of the
- // universal macOS build linking the arm libmpv). Empty prefix (Linux)
- // still uses pkg-config to find the system libmpv.
- mod.addIncludePath(.{ .cwd_relative = b.fmt("{s}/include", .{prefix}) });
- mod.addLibraryPath(.{ .cwd_relative = b.fmt("{s}/lib", .{prefix}) });
- mod.linkSystemLibrary("mpv", .{ .use_pkg_config = .no });
- } else {
- mod.linkSystemLibrary("mpv", .{});
- }
-}
diff --git a/build.zig.zon b/build.zig.zon
index b337bf0..b0f6e54 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -9,7 +9,7 @@
.name = .hum,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
- .version = "0.1.7",
+ .version = "0.1.8",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
@@ -25,7 +25,7 @@
.fingerprint = 0xd8ced3dc97af74f5, // Changing this has security and trust implications.
// Tracks the earliest Zig version that the package considers to be a
// supported use case.
- .minimum_zig_version = "0.16.0",
+ .minimum_zig_version = "0.17.0",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
diff --git a/examples/visualizers/pulse.vis b/examples/visualizers/pulse.vis
new file mode 100644
index 0000000..fd26385
--- /dev/null
+++ b/examples/visualizers/pulse.vis
@@ -0,0 +1,49 @@
+# copy into ~/.config/hum/visualizers/ and press v in the visualizer view (^V)
+#
+# name shown in the header; defaults to the file name
+# fps 1-30 frames per second
+# speed fixed | level (level: louder music plays the frames faster)
+# color level | dim | accent | strong (level: dim when quiet, bright when loud)
+#
+# frames follow, each starting with a line that is just ---
+
+name = pulse
+fps = 6
+speed = level
+color = level
+---
+
+
+ ·
+
+
+---
+
+ ╭─╮
+ │·│
+ ╰─╯
+
+---
+ ╭───╮
+ ╭╯ ╰╮
+ │ · │
+ ╰╮ ╭╯
+ ╰───╯
+---
+ ╭───────╮
+ ╭─╯ ╰─╮
+ │ · │
+ ╰─╮ ╭─╯
+ ╰───────╯
+---
+ ╭───╮
+ ╭╯ ╰╮
+ │ · │
+ ╰╮ ╭╯
+ ╰───╯
+---
+
+ ╭─╮
+ │·│
+ ╰─╯
+
diff --git a/src/api.zig b/src/api.zig
index 7e62f77..fa98a49 100644
--- a/src/api.zig
+++ b/src/api.zig
@@ -4,19 +4,16 @@ const txt = @import("text.zig");
const fsutil = @import("fsutil.zig");
const track_mod = @import("track.zig");
-// Public WEB_REMIX client key, served in every YouTube Music page. Not a secret; secret scanners flag it by pattern.
const INNERTUBE_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30";
const SEARCH_URL = "https://music.youtube.com/youtubei/v1/search?key=" ++ INNERTUBE_KEY ++ "&prettyPrint=false";
-const TMP_TEMPLATE = "/tmp/hum_bodyXXXXXX";
-// Embedded verbatim into request bodies, so format strings add only the surrounding object.
const CLIENT_CONTEXT =
\\"context":{"client":{"clientName":"WEB_REMIX","clientVersion":"1.20240101.00.00","hl":"en","gl":"US"},"user":{}}
;
const c = fsutil.c;
+const SEARCH_TIMEOUT_S = "20";
-// Track is not an api type; re-exported so `api.Track` references keep working.
pub const Track = track_mod.Track;
pub const Source = track_mod.Source;
@@ -78,7 +75,6 @@ pub fn searchFiltered(
return out.toOwnedSlice(arena);
}
-/// Rows inside a card shelf drop the artist from their subtitle because the card header carries it.
fn collectTracks(
arena: std.mem.Allocator,
v: std.json.Value,
@@ -114,7 +110,6 @@ fn collectTracks(
}
}
-/// The card's artist is the first UC-linked run in its title, else in its subtitle.
fn cardArtist(card: std.json.Value) ?[]const u8 {
if (card != .object) return null;
inline for (.{ "title", "subtitle" }) |key| {
@@ -218,7 +213,6 @@ pub fn browseAlbum(arena: std.mem.Allocator, gpa: std.mem.Allocator, io: std.Io,
return out.toOwnedSlice(arena);
}
-/// Songs put the runtime in a fixedColumn, videos in the subtitle; an unparseable one means unknown.
fn durationOf(obj: std.json.ObjectMap) u32 {
if (obj.get("fixedColumns")) |fixed| {
if (fixed == .array) {
@@ -249,7 +243,6 @@ fn durationOf(obj: std.json.ObjectMap) u32 {
return 0;
}
-/// Album browse ids are the MPRE namespace; matching that beats guessing by run position.
fn albumOfRuns(runs: []std.json.Value) ?[]const u8 {
for (runs) |run| {
const bid = browseIdOfRun(run) orelse continue;
@@ -290,7 +283,6 @@ fn firstLinkedRunText(runs: []std.json.Value) ?[]const u8 {
return null;
}
-/// Positional fallback that skips kind words, separators and runtimes — top-result rows have no artist run at all.
fn positionalArtist(runs: []std.json.Value) ?[]const u8 {
for (runs) |run| {
const t = textOfRun(run) orelse continue;
@@ -302,7 +294,6 @@ fn positionalArtist(runs: []std.json.Value) ?[]const u8 {
return null;
}
-/// Requires the colon: "1979" is a plausible name, only m:ss is unambiguously a runtime.
fn looksLikeDuration(t: []const u8) bool {
return std.mem.indexOfScalar(u8, t, ':') != null and track_mod.parseDuration(t) > 0;
}
@@ -394,15 +385,14 @@ fn buildBodyFiltered(arena: std.mem.Allocator, query: []const u8, filter: Filter
return std.fmt.allocPrint(arena, "{{{s},\"query\":{s},\"params\":\"{s}\"}}", .{ CLIENT_CONTEXT, escaped, params });
}
-/// Body goes through a 0600 temp file, never argv: queries are user text and argv is world-readable.
fn postJson(arena: std.mem.Allocator, gpa: std.mem.Allocator, io: std.Io, url: []const u8, body: []const u8) ![]u8 {
- const body_path = try writeTempFile(arena, body);
+ const body_path = try fsutil.makeTemp(arena, "hum_body", body);
defer _ = c.unlink(body_path.ptr);
const data_arg = try std.fmt.allocPrint(arena, "@{s}", .{body_path});
return proc.runCapture(gpa, io, &.{
"curl", "-sS",
- "--max-time", "20",
+ "--max-time", SEARCH_TIMEOUT_S,
"-H", "Content-Type: application/json",
"-H", "User-Agent: Mozilla/5.0",
"-X", "POST",
@@ -411,20 +401,6 @@ fn postJson(arena: std.mem.Allocator, gpa: std.mem.Allocator, io: std.Io, url: [
});
}
-fn writeTempFile(arena: std.mem.Allocator, body: []const u8) ![:0]const u8 {
- const path = try arena.dupeZ(u8, TMP_TEMPLATE);
- const fd = c.mkstemp(path.ptr);
- if (fd < 0) return error.TempFileOpen;
- defer _ = c.close(fd);
- var off: usize = 0;
- while (off < body.len) {
- const n = c.write(fd, body[off..].ptr, body.len - off);
- if (n <= 0) return error.TempFileWrite;
- off += @intCast(n);
- }
- return path;
-}
-
const testing = std.testing;
test "Track.isPlayable, Filter labels/params, isKindWord" {
@@ -531,7 +507,6 @@ test "findAlbumArtist reads legacy musicDetailHeaderRenderer subtitle" {
}
test "a subtitle of `Song • 5:38` yields no artist and a duration" {
- // top-result song rows carry no artist run at all
const json =
\\{"musicResponsiveListItemRenderer":{
\\ "playlistItemData":{"videoId":"abc123"},
@@ -591,7 +566,6 @@ test "extractTrack keeps a linked artist and picks up album and duration" {
}
test "card-shelf rows inherit the artist the card header names" {
- // the card header carries the artist its rows omit
const json =
\\{"musicCardShelfRenderer":{
\\ "title":{"runs":[{"text":"Daft Punk","navigationEndpoint":{"browseEndpoint":{"browseId":"UCRr1"}}}]},
diff --git a/src/config.zig b/src/config.zig
index dd142d3..b9ad07b 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -5,22 +5,16 @@ pub fn path(arena: std.mem.Allocator, env: *std.process.Environ.Map) ![]const u8
return fsutil.xdgPath(arena, env, "XDG_CONFIG_HOME", ".config", "config");
}
-pub fn loadTheme(arena: std.mem.Allocator, file_path: []const u8) ?[]const u8 {
- return loadKey(arena, file_path, "theme");
-}
-
-pub fn saveTheme(arena: std.mem.Allocator, file_path: []const u8, name: []const u8) void {
- saveKey(arena, file_path, "theme", name) catch {};
-}
+pub const Key = enum { theme, volume, scope, splash, music_dir, visualizer, mouse };
-pub fn loadKey(arena: std.mem.Allocator, file_path: []const u8, key: []const u8) ?[]const u8 {
+pub fn loadKey(arena: std.mem.Allocator, file_path: []const u8, key: Key) ?[]const u8 {
const bytes = fsutil.readFileAlloc(arena, file_path) orelse return null;
var it = std.mem.splitScalar(u8, bytes, '\n');
while (it.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r");
const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue;
- if (std.mem.eql(u8, trimmed[0..eq], key)) {
+ if (std.mem.eql(u8, trimmed[0..eq], @tagName(key))) {
const val = std.mem.trim(u8, trimmed[eq + 1 ..], " \t\r");
if (val.len == 0) return null;
return arena.dupe(u8, val) catch null;
@@ -29,7 +23,7 @@ pub fn loadKey(arena: std.mem.Allocator, file_path: []const u8, key: []const u8)
return null;
}
-pub fn saveKey(arena: std.mem.Allocator, file_path: []const u8, key: []const u8, value: []const u8) !void {
+pub fn saveKey(arena: std.mem.Allocator, file_path: []const u8, key: Key, value: []const u8) !void {
var keys: std.ArrayList([]const u8) = .empty;
var vals: std.ArrayList([]const u8) = .empty;
@@ -39,12 +33,12 @@ pub fn saveKey(arena: std.mem.Allocator, file_path: []const u8, key: []const u8,
const trimmed = std.mem.trim(u8, line, " \t\r");
const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue;
const k = trimmed[0..eq];
- if (std.mem.eql(u8, k, key)) continue;
+ if (std.mem.eql(u8, k, @tagName(key))) continue;
try keys.append(arena, k);
try vals.append(arena, std.mem.trim(u8, trimmed[eq + 1 ..], " \t\r"));
}
}
- try keys.append(arena, key);
+ try keys.append(arena, @tagName(key));
try vals.append(arena, value);
var buf: std.ArrayList(u8) = .empty;
diff --git a/src/feed.zig b/src/feed.zig
index 6182f80..b00631c 100644
--- a/src/feed.zig
+++ b/src/feed.zig
@@ -4,10 +4,8 @@ const track_mod = @import("track.zig");
const fsutil = @import("fsutil.zig");
const proc = @import("proc.zig");
-/// One playable episode from a feed.
pub const Episode = struct {
title: []const u8,
- /// The enclosure. Always http(s) — see `validUrl`.
url: []const u8,
published: []const u8 = "",
duration_s: u32 = 0,
@@ -18,14 +16,13 @@ pub const Feed = struct {
episodes: []Episode = &.{},
};
-// A feed is a stranger's XML; these are the ceilings it cannot exceed.
pub const MAX_BYTES: usize = 16 * 1024 * 1024;
const MAX_ITEMS: usize = 500;
const MAX_TEXT: usize = 512;
const MAX_URL: usize = 2048;
const MAX_ENTITY: usize = 12;
+const FETCH_TIMEOUT_S = "25";
-/// Deliberately not a general XML parser: it scans for the few elements a feed must have and ignores the rest. No DTDs, entity definitions, namespaces, recursion or external references, so expansion bombs have nothing to expand.
pub fn parse(arena: std.mem.Allocator, bytes: []const u8) ?Feed {
if (bytes.len == 0 or bytes.len > MAX_BYTES) return null;
const is_rss = std.mem.indexOf(u8, bytes, "- ; Atom uses a link with rel="enclosure" or an audio type.
fn enclosureUrl(chunk: []const u8) ?[]const u8 {
if (findTag(chunk, " element, CDATA unwrapped. Never crosses into a nested element.
fn tagText(hay: []const u8, name: []const u8) ?[]const u8 {
var buf: [64]u8 = undefined;
if (name.len + 2 > buf.len) return null;
@@ -146,10 +138,9 @@ fn tagText(hay: []const u8, name: []const u8) ?[]const u8 {
var from: usize = 0;
while (findTagPos(hay, open, from)) |found| {
from = found.end;
- // " 0 and after[0] != ' ' and after[0] != '\t' and after[0] != '/') continue;
- if (std.mem.endsWith(u8, found.tag, "/")) continue; // self-closing: no text
+ if (std.mem.endsWith(u8, found.tag, "/")) continue;
var close_buf: [64]u8 = undefined;
const close = std.fmt.bufPrint(&close_buf, "{s}>", .{name}) catch return null;
@@ -167,7 +158,6 @@ fn tagText(hay: []const u8, name: []const u8) ?[]const u8 {
return null;
}
-/// Only the five predefined entities and bounded numeric refs; definitions are never looked up.
fn decodeEntities(arena: std.mem.Allocator, s: []const u8) ?[]const u8 {
if (std.mem.indexOfScalar(u8, s, '&') == null) return s;
var out: std.ArrayList(u8) = .empty;
@@ -221,14 +211,12 @@ fn decodeEntities(arena: std.mem.Allocator, s: []const u8) ?[]const u8 {
} else |_| {}
} else |_| {}
}
- // unknown entity: keep the text, expand nothing
out.append(arena, s[i]) catch return null;
i += 1;
}
return out.items;
}
-/// Decode, sanitize, cap: feed text is remote text like any other.
fn clean(arena: std.mem.Allocator, s: []const u8) ?[]const u8 {
const decoded = decodeEntities(arena, s) orelse return null;
const safe = txt.sanitize(arena, decoded) catch return null;
@@ -240,7 +228,6 @@ fn clean(arena: std.mem.Allocator, s: []const u8) ?[]const u8 {
return trimmed[0..end];
}
-/// Handed to mpv, so http(s) only — never a path, a scheme, or anything quotable.
pub fn validUrl(u: []const u8) bool {
if (u.len == 0 or u.len > MAX_URL) return false;
if (!std.mem.startsWith(u8, u, "http://") and !std.mem.startsWith(u8, u, "https://")) return false;
@@ -251,13 +238,11 @@ pub fn validUrl(u: []const u8) bool {
return true;
}
-/// `` is seconds, or mm:ss, or hh:mm:ss.
fn parseDuration(s: []const u8) u32 {
if (std.mem.indexOfScalar(u8, s, ':') != null) return track_mod.parseDuration(s);
return std.fmt.parseInt(u32, s, 10) catch 0;
}
-/// Episode → queue row.
pub fn toTrack(ep: Episode, feed_title: []const u8) track_mod.Track {
return .{
.source = .url,
@@ -271,10 +256,8 @@ pub fn toTrack(ep: Episode, feed_title: []const u8) track_mod.Track {
// --------------------------------------------------------- subscriptions
-/// One subscribed feed: the URL we fetch and the title it reported.
pub const Sub = struct { url: []const u8, title: []const u8 = "" };
-/// $XDG_DATA_HOME/hum/feeds — data worth keeping, so not the cache.
pub fn path(arena: std.mem.Allocator, env: *std.process.Environ.Map) ![:0]const u8 {
return fsutil.xdgPath(arena, env, "XDG_DATA_HOME", ".local/share", "feeds");
}
@@ -288,7 +271,7 @@ pub fn loadSubs(arena: std.mem.Allocator, file_path: []const u8) []Sub {
if (line.len == 0 or line[0] == '#') continue;
var f = std.mem.splitScalar(u8, line, '\t');
const url = f.next() orelse continue;
- if (!validUrl(url)) continue; // a hand-edited file is still input
+ if (!validUrl(url)) continue;
const title = f.next() orelse "";
out.append(arena, .{ .url = url, .title = title }) catch return out.items;
}
@@ -304,15 +287,14 @@ pub fn saveSubs(arena: std.mem.Allocator, file_path: []const u8, subs: []const S
return fsutil.writeFile(arena, file_path, buf.items);
}
-/// Redirects followed (feed hosts move), time-boxed, size-capped, URL validated before argv.
pub fn fetch(gpa: std.mem.Allocator, io: std.Io, url: []const u8) ![]u8 {
if (!validUrl(url)) return error.BadUrl;
return proc.runCapture(gpa, io, &.{
- "curl", "-sSL",
- "--max-time", "25",
- "--max-filesize", "16777216",
- "-H", "User-Agent: hum/podcast",
- "--", url,
+ "curl", "-sSL",
+ "--max-time", FETCH_TIMEOUT_S,
+ "--max-filesize", std.fmt.comptimePrint("{d}", .{MAX_BYTES}),
+ "-H", "User-Agent: hum/podcast",
+ "--", url,
});
}
@@ -351,7 +333,7 @@ test "rss: channel title, episodes, entities, CDATA, durations" {
try testing.expect(f.episodes[0].published.len > 0);
try testing.expectEqualStrings("Episode Two markup", f.episodes[1].title);
- try testing.expectEqual(@as(u32, 95), f.episodes[1].duration_s); // bare seconds
+ try testing.expectEqual(@as(u32, 95), f.episodes[1].duration_s);
}
test "atom entries with an audio link" {
@@ -397,7 +379,7 @@ test "hostile URLs never reach the player" {
try testing.expect(!validUrl("javascript:alert(1)"));
try testing.expect(!validUrl("file:///etc/passwd"));
try testing.expect(!validUrl("/etc/passwd"));
- try testing.expect(!validUrl("https://example.com/a b.mp3")); // space
+ try testing.expect(!validUrl("https://example.com/a b.mp3"));
try testing.expect(!validUrl("https://example.com/\"quoted\".mp3"));
try testing.expect(!validUrl("https://example.com/\x00.mp3"));
try testing.expect(!validUrl(""));
@@ -420,7 +402,6 @@ test "entity bombs expand to nothing because definitions are never read" {
defer arena.deinit();
const a = arena.allocator();
- // billion-laughs shape plus an external-entity attempt
const feed =
\\
\\nope