#+TITLE: Radio-browser station lookup + playlist creator — Spec #+AUTHOR: Craig Jennings #+DATE: 2026-07-06 #+TODO: TODO | DONE #+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED * IMPLEMENTED Radio-browser lookup :PROPERTIES: :ID: 4839b2c8-0552-4029-9e0f-4bf69b9a4dcd :END: - 2026-07-09 Thu @ 17:50:32 -0500 — Craig ran the queue-first VERIFY (n / t / m picks, save on v, file-playlist save unchanged, empty and offline searches) and found no issues. DOING -> IMPLEMENTED. - 2026-07-08 Wed @ 10:16:25 -0500 — model revision (Craig): queue-first, save-on-request. The lookup (and the manual m creator) no longer writes .m3u files at pick time; picked stations become url tracks in the queue (name/uuid/favicon as track properties) and play immediately. Saving is the normal playlist save (moved to w — the earlier S binding was shadowed by emms-stop): an all-stream queue saves into the MPD playlist dir, the plain station name pre-fills the prompt (the "-Radio" filename suffix is retired), and a custom .m3u emitter writes the station metadata back out so load round-trips names and cover art. First pick names a multi-station save. Still DOING. - 2026-07-06 Mon @ 14:04:53 -0500 — build UX changes (Craig, during Phase 2 verify): pulled tag search from vNext into v1; the feature is a Radio row in the playlist buffer (n: by name, t: by tag, m: enter manually) rather than one command on S; single mode moved off t to s and emms-stop off s to S; created filenames carry a "-Radio" suffix. Still DOING. - 2026-07-06 Mon @ 13:08:32 -0500 — spec-response Phase 6: decomposed into build tasks in todo.org (parent stamped :SPEC_ID:); READY -> DOING. Build under way. - 2026-07-06 Mon @ 13:01:55 -0500 — spec-response: all 7 findings dispositioned (6 accept/modify, 1 resolved via new Decision 5); decisions [5/5], findings [7/7]. No blocking finding remains; readiness rubric re-run on the expanded spec passes. DRAFT -> READY. Awaiting Craig's go to decompose into build tasks (spec-response Phase 6, flips READY -> DOING). - 2026-07-06 Mon @ 10:48:20 -0500 — spec-review: Not ready. 7 findings recorded (1 blocking: completing-read-multiple splits on commas in station names). Stays DRAFT pending disposition via spec-response. - 2026-07-06 Mon @ 10:12:00 -0500 — all four decisions resolved (Craig): url.el; multi-select one-file-per-station; save into the MPD playlist dir; create-and-play. Ready for spec-review. - 2026-07-06 Mon @ 10:01:27 -0500 — drafted. * Metadata | Status | implemented | |----------+-------------------------------------------------------------------| | Owner | Craig Jennings | |----------+-------------------------------------------------------------------| | Reviewer | Craig Jennings | |----------+-------------------------------------------------------------------| | Related | [[file:../../todo.org][todo.org: Music — create playlists from a radio.info lookup]] | |----------+-------------------------------------------------------------------| * Summary Add an in-Emacs command that searches the radio-browser.info directory for internet-radio stations and turns a selection into an M3U the music player can load and play. It closes the loop opened by the multi-directory sourcing work: the player already reads and plays radio .m3u from both ~/music/ and the MPD playlist directory, but every station still has to be found in a browser and hand-entered through cj/music-create-radio-station (name + URL typed by hand). This feature makes discovery native. * Problem / Context Craig listens to internet radio through the EMMS + mpv-subprocess player in music-config.el. A radio station is a one-line .m3u: an #EXTINF label and a stream URL. Today those come from two places, neither good. The 73 existing stations were built up over time in an MPD client and relocated into dotfiles; adding a new one means opening radio-browser.info in a browser, copying the stream URL, running cj/music-create-radio-station, and pasting name and URL by hand. There is no way to search, compare, or audition stations without leaving Emacs. radio-browser.info is a free community directory with an open JSON API: search by name, tag, country, language; each station carries a stable UUID, a resolved stream URL, codec, bitrate, tags, and popularity counts. That is exactly the metadata a good picker needs, and the existing radio .m3u already use radio-browser's UUID header on 15 of 73 files. The problem is purely that nothing in the config talks to the API. The verified API shape (checked live 2026-07-06): - Server list: GET https://all.api.radio-browser.info/json/servers -> [{ip, name}, ...]. A concrete host like de1.api.radio-browser.info answers directly. - Search: GET https:///json/stations/search?name=&limit=&hidebroken=true -> array of station objects. - Station fields used: stationuuid, name, url_resolved, codec, bitrate, tags (comma string), countrycode, votes, clickcount. * Goals and Non-Goals ** Goals - Search radio-browser.info from inside Emacs and pick from the results. - Turn the selection into a playable M3U in the format the existing player already reads (#EXTM3U / #EXTINF:1, / ), carrying the #RADIOBROWSERUUID header for provenance. - Reuse the existing writer and the multi-directory sourcing so a created station is immediately loadable and playable. - Be a good radio-browser client: a descriptive User-Agent and server rotation. ** Non-Goals - No replacement of cj/music-create-radio-station; the manual name+URL path stays for stations not in the directory. - No station editing, tagging, or curation UI beyond create. - No favorites/rating sync back to radio-browser (vote/click counting is vNext). - No dependence on MPD's stored-playlist mechanism; this writes .m3u files, consistent with the sourcing design. - No audio format work; mpv already plays whatever the stream serves. ** Scope tiers - v1: search by name and by tag, a result picker, and playlist creation from the selection, wired to the existing writer + sourcing. Surfaced as a Radio row in the playlist buffer (n: by name, t: by tag, m: enter manually). - Out of scope: transient dashboards, station browsing by curated category, in-buffer station management. - vNext (log to todo.org): click/vote counting (POST /json/url/), country faceted search, choosing among a station's codec/bitrate variants, an audition-before-save preview, a homepage/favicon-rich annotation. (Tag search moved into v1 2026-07-06.) * Design At a caller's altitude: Craig runs a search command (bound in the playlist keymap next to R), types a query, and gets a completion list of stations annotated with codec, bitrate, country, ♥votes, and top tags (Decision 5, Variant B). He picks one or more. The command writes an M3U and reports where it landed; the station is then visible in the load list and plays through mpv like any other radio .m3u. At the implementer's altitude, four pieces: 1. A thin API client. cj/music-radio--server returns a pinned default host (cj/music-radio-server). On a connection failure or timeout against it, the client fetches /json/servers once and retries against the first reachable host from that list, capped at one such retry so it never loops. cj/music-radio--search runs the GET, parses JSON with the built-in json-parse-string, and returns a list of plists with only the fields the picker and writer need. All network calls send a descriptive User-Agent and carry a timeout. A failed or empty response returns a clear user-error, never a stack trace. 2. A pure result-to-candidate layer. cj/music-radio--format-candidate turns a station plist into the completion string plus an annotation, and the reverse map recovers the chosen station. Keeping this pure keeps the picker testable without the network. 3. A pure M3U emitter. cj/music-radio--station-m3u takes a station plist and returns the exact file text: #EXTM3U, an optional #RADIOBROWSERUUID line, #EXTINF:1,, and the stream URL. It prefers url_resolved and falls back to url; a station with neither is not emitted (see the writer below). This mirrors the existing radio files byte-for-byte, so old and new stations are indistinguishable to the player. 4. The interactive command. cj/music-radio-search reads the query, calls the client, and drives a repeated single-select picker: completing-read is called in a loop, each pick removed from the pool and a "[done]" sentinel finishing selection, so several stations are chosen in one search. This avoids completing-read-multiple, whose comma separator would mis-split a station name that contains a comma; each pick maps straight from its display string to the station object, never through a delimiter. For each selected station the command hands the station to the writer, which builds the filename from cj/music--safe-filename (disambiguating a name that collides with an already-written pick this run by appending a short stationuuid fragment) and writes cj/music-radio--station-m3u into cj/music-radio-save-dir (default the MPD playlist directory, alongside the existing radio set). A station with no usable stream URL is skipped and named in the closing message. The command then enqueues the created stations and starts playback through mpv, so a search ends in radio audio (see Decision 4 for the interrupt-vs-append behavior). The feature reuses cj/music--safe-filename and the existing radio M3U format; it does not call cj/music-create-radio-station, which stays as-is on its own write target. The network client and the interactive command are the only impure pieces. The candidate formatting and the M3U emission are pure and carry the test weight, following the module's existing internal/interactive split. * Alternatives Considered ** HTTP via built-in url.el - Good, because no new dependency; ships with Emacs; url-retrieve-synchronously with a let-bound timeout is enough for a one-shot search. - Bad, because url.el's error handling and header ergonomics are clunky; async needs a callback dance. - Neutral, because JSON parsing is json-parse-string either way. ** HTTP via plz.el (or request.el) - Good, because a clean synchronous-or-async API, straightforward headers, better error surfacing. - Bad, because a new package dependency for one feature; another thing to keep installed and byte-clean. - Neutral, because both are actively maintained. ** Shell out to curl - Good, because trivial and already used ad hoc; robust header/timeout handling. - Bad, because process management and quoting in Elisp, and a hard curl dependency at runtime; less portable than staying in-process. - Neutral, because output still parses with json-parse-string. ** Playlist shape: one .m3u per station vs one multi-station .m3u - One-per-station is Good, because it matches all 73 existing files and MPD's per-station model; each station is independently loadable. - One multi-station .m3u is Good for a genre queue you skip through, but Bad because it diverges from the existing shape and complicates naming. - This is the load-bearing product question; see Decision 2. * Decisions [5/5] ** DONE HTTP client choice - Context: one JSON GET (plus an occasional server-list GET). No streaming, no auth. url.el ships with Emacs; plz is cleaner but a new dep. - Decision: We will use built-in url.el (url-retrieve-synchronously with a bound timeout) and json-parse-string, adding no dependency. - Consequences: easier install and byte-compile story, no new package to track; harder error ergonomics and any future async, which we accept for a one-shot search. ** DONE Playlist shape and selection - Context: "create playlists from a lookup" can mean one file per station (matches the existing 73) or one file holding several stations (a genre queue). Selection can be single or multi. - Decision: We will support multi-select and write one .m3u per selected station (matching the existing shape), so a search that returns several good stations creates several stations in one pass rather than a combined file. - Consequences: easier consistency with the existing library and independent loadability; harder to express "a single genre playlist of five streams" (deferred to vNext if wanted). ** DONE Save destination for created stations - Context: the multi-directory sourcing reads both ~/music/ and ~/.local/share/mpd/playlists/. The radio home is the MPD playlist directory, where the existing 73 stations live as dotfiles-tracked relative symlinks. Craig wants new stations to land with the rest of the radio set, not in ~/music/. - Decision: We will write new stations into ~/.local/share/mpd/playlists/ (Craig's call), so a created station sits alongside the existing radio playlists and both MPD and the multi-directory sourcing see it immediately. A defcustom cj/music-radio-save-dir (default that directory) keeps it configurable. - Consequences: easier — new stations are co-located with the radio set and instantly playable through either client; harder — a newly written file is a real file in a directory otherwise made of dotfiles-tracked symlinks, so it is not version-controlled until Craig stows it into ~/.dotfiles. The config does not automate that promotion. ** DONE Play-on-create behavior - Context: after creating a station Craig may want it to start playing, or just exist for later. Craig's call: play it. - Decision: We will create the station and immediately enqueue and play the selection through EMMS (mpv), so a search ends in audio. With several picks the created stations enqueue in order and the first starts playing right away, interrupting whatever was playing (the natural reading of "always play" — the search ends in the radio, not behind the current queue). A prefix argument suppressing playback (create-only) is a vNext nicety, not v1. - Consequences: easier — search-to-sound in one command, no separate load step; harder — creating always interrupts current playback, so a "just save it for later" flow means creating and then stopping. Acceptable given radio is play-oriented, and confirmable against a real listen. ** DONE Candidate annotation format - Context: the marginalia annotation on each station line can carry codec/bitrate/country plus either popularity or genre. The prototype (docs/specs/2026-07-06-radio-browser-lookup.prototype.html) shows both. - Decision: Variant B (Craig's call): codec, bitrate, country, ♥votes, and top tags. Drop the play count — Craig isn't interested in it, and the tags help tell same-named stations apart by what they play. - Consequences: the station line runs a little wider to fit the tags, and cj/music-radio--format-candidate emits the tag snippet (first few tags) rather than the play count. * Review findings [7/7] ** DONE Multi-select splits on commas in station names :blocking: Accepted. Design piece 4 now specifies a repeated single-select loop (completing-read with a "[done]" sentinel, each pick removed from the pool) instead of completing-read-multiple, so a comma in a station name can never mis-split the selection — each pick maps straight from its display string to the station object. This keeps Decision 2's "several picks in one search" without the delimiter hazard. ** DONE Empty or missing url_resolved writes a broken station Accepted. The emitter (piece 3) prefers url_resolved and falls back to url; the writer (piece 4) skips a station with neither and names it in the closing message. ** DONE Two picks with the same name collide on the filename Accepted. Piece 4's writer disambiguates a name that collides with an already-written pick this run by appending a short stationuuid fragment. ** DONE "Reuses the existing writer" is imprecise Accepted (modified). Rather than refactor cj/music-create-radio-station (scope creep + test churn on working code), piece 4 now states the feature reuses cj/music--safe-filename and the existing radio M3U format but ships its own emitter/writer, and cj/music-create-radio-station stays as-is on its own write target. Clarifies the imprecision without expanding scope. ** DONE Server-selection fallback underspecified Accepted. Piece 1 now pins a default host (cj/music-radio-server) and, on a connection failure or timeout, fetches /json/servers once and retries against the first reachable host, capped at one retry. ** DONE create-and-play behavior when music is already playing Accepted (modified). Decision 4's body now states create-and-play interrupts current playback (plays the first created station immediately), the natural reading of "always play." Proposed default surfaced to Craig for veto; a create-only prefix arg is logged as vNext. ** DONE Annotation format A vs B unresolved Resolved via new Decision 5: Craig picked Variant B (codec/bitrate/country/♥votes/tags, drop play count). Folded into the caller-altitude design description and Decision 5. * Implementation phases ** Phase 1 — API client + pure emitter (no UI) Add cj/music-radio--server, cj/music-radio--search (returns station plists), cj/music-radio--format-candidate, and cj/music-radio--station-m3u. Unit-test the pure pieces (candidate formatting, M3U emission) against fixture station plists; test the client with a stubbed url-retrieve or a recorded JSON fixture, never a live call. Leaves the tree working: helpers exist, nothing bound yet. ** Phase 2 — interactive command + binding Add cj/music-radio-search (query -> search -> completing-read multi-select -> writer), reusing cj/music--safe-filename and the save-dir defcustom. Bind it in the playlist keymap next to R. Handle empty results, network failure, and cancel with clear user-errors. Manual live verification (the network + picker + play can't be driven headless) filed as a VERIFY. * Acceptance criteria - [ ] A search for a known station name returns annotated candidates in a completion prompt. - [ ] Selecting a station writes a .m3u whose bytes match the existing radio format (#EXTM3U / #EXTINF:1, / ), with a #RADIOBROWSERUUID line. - [ ] Creating a station writes it into cj/music-radio-save-dir, then enqueues and starts it playing through mpv; it also appears in the player's load list (multi-directory sourcing). - [ ] An empty result set and a network failure each produce a clear message, not a stack trace. - [ ] Selecting several stations in one search creates one file per station, and a station name containing a comma is selected correctly (no mis-split). - [ ] A station with no usable stream URL is skipped and named in the closing message, not written as a broken file. - [ ] The pure emitter and candidate formatter have Normal/Boundary/Error tests that run without the network. * Readiness dimensions - Data model & ownership: a station plist (uuid, name, url, codec, bitrate, tags, country, votes, clickcount) derived from the API; the .m3u file is generated and owned by Craig once written. No local cache in v1. - Errors, empty states & failure: named user-errors — no server reachable, empty results, cancelled selection, write failure (naming the file). No silent data loss; overwrite reuses the existing cj/confirm-strong prompt from create-radio-station. - Security & privacy: no credentials. The only outbound data is the search query and a User-Agent to a public API. No sensitive data logged. - Observability: the command messages the server used, the result count, and each file written. Search is one short synchronous call; if it ever feels slow, a "Searching radio-browser…" message covers it. - Performance & scale: result sets bounded by an explicit limit (default ~30). One GET per search. No scaling concern. - Reuse & lost opportunities: reuses cj/music--safe-filename, the overwrite-confirm pattern, cj/music-m3u-root, and the whole multi-directory sourcing + mpv play path. json-parse-string and url.el are built in. Nothing new is invented that the platform already provides. - Architecture fit & weak points: integrates at music-config.el alongside cj/music-create-radio-station; the writer is the shared seam. Weak point: radio-browser server availability — mitigated by the server-list fallback and a timeout. - Config surface: cj/music-radio-save-dir (default ~/.local/share/mpd/playlists/, the radio home), cj/music-radio-server (default pinned host, with the /json/servers fallback), cj/music-radio-search-limit (default 30), cj/music-radio-user-agent (descriptive default). All with defaults and doc. - Documentation plan: a line in the module commentary and the keybinding list; no separate doc needed. - Dev tooling: existing make test / test-file targets cover the new unit tests; no new tooling. - Rollout, compatibility & rollback: additive — a new command and helpers, no change to existing behavior or files. Removing it is deleting the code; created .m3u files land in the MPD playlist directory as ordinary files (untracked until Craig stows them) and stay. - External APIs & deps: radio-browser /json/servers and /json/stations/search VERIFIED live 2026-07-06 (shape recorded in Problem/Context). The vote/click endpoint (vNext) is a research prerequisite if that feature is pursued. * Risks, Rabbit Holes, and Drawbacks - radio-browser etiquette: the project asks clients to identify via User-Agent and to rotate servers rather than hammer one. v1 honors both; skipping click-counting is polite-neutral (it slightly under-reports popularity but adds no load). - url.el error handling is the likeliest rabbit hole. Keep the client tiny: one GET, parse, or a single user-error. Do not build a general HTTP layer. - Stream URL choice: a station can list several codec/bitrate variants under one name. v1 takes url_resolved as-is; picking among a station's variants is a vNext refinement, not a v1 problem. * References / Appendix - UI prototype (open in a browser): [[file:2026-07-06-radio-browser-lookup.prototype.html][2026-07-06-radio-browser-lookup.prototype.html]] — faithful vertico + marginalia mockup of the four minibuffer screens (query, station list, multi-select, created+playing) with real jazz-search data. Shows the two candidate-annotation variants the design chooses between (A: codec/bitrate/country/votes/plays; B: codec/bitrate/country/votes/tags). - API shape verified live 2026-07-06 against de1.api.radio-browser.info (recorded in Problem / Context). * Review and iteration history ** 2026-07-06 Mon @ 13:01:55 -0500 — Claude (for Craig) — responder - What: dispositioned all 7 review findings. Six accepted or accepted-with-modification and folded into the Design, Decisions, config, and acceptance sections; the seventh (annotation format) resolved by adding Decision 5, which Craig answered Variant B (tags, drop play count). Re-ran the readiness rubric on the expanded spec and flipped DRAFT -> READY. - Why: the blocking CRM comma-split needed a selection-mechanism decision (now a loop-based single-select), and the empty-URL, filename-collision, writer-precision, and server-fallback gaps each needed a concrete rule before an implementer could build without inventing behavior. - Artifacts: * Review findings [7/7]; * Decisions [5/5]; the loop-select, skip-empty-URL, filename-dedup, and server-fallback rules in the Design; Decision 5 (Variant B). ** 2026-07-06 Mon @ 10:48:20 -0500 — Claude (for Craig) — reviewer - What: first review pass. Recorded 7 findings (1 blocking: CRM comma-splitting breaks multi-select for station names containing commas); rubric Not ready pending disposition. Confirmed the two Implementation phases decompose cleanly. - Why: verify implementation-readiness before build. The multi-select mechanism, empty-URL and duplicate-name robustness, the shared-writer seam, server fallback, and the create-and-play semantics were the real gaps an implementer would hit. - Artifacts: * Review findings [0/7]; grounded in music-config.el (writer, m3u reader, multi-dir sourcing, cj/music-create-radio-station) and the live spike; API verified live. ** 2026-07-06 Mon @ 10:12:00 -0500 — Claude (for Craig) — author - What: resolved all four open decisions from Craig's answers and folded them into the design, config, rollout, and acceptance sections. - Why: HTTP client, playlist shape, save destination, and play-on-create were the real product choices gating a build. - Artifacts: Decisions section now [4/4]; save dir is the MPD playlist directory; create-and-play is the default. ** 2026-07-06 Mon @ 10:01:27 -0500 — Claude (for Craig) — author - What: initial draft. - Why: the music player can source and play radio .m3u but has no native way to discover stations; radio-browser's API supplies exactly the needed metadata. - Artifacts: todo.org "Music — create playlists from a radio.info lookup"; API shape verified live against de1.api.radio-browser.info.