aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-06 13:20:52 -0500
committerCraig Jennings <c@cjennings.net>2026-07-06 13:20:52 -0500
commitc60c8edf2e0e4dcdaab451d630b05d7df583bad6 (patch)
treeb1b88aee50663119986e19d1ae29d928e536ca77
parentba1c418428f9d09f57736d66e61ae28974a91117 (diff)
downloaddotemacs-c60c8edf2e0e4dcdaab451d630b05d7df583bad6.tar.gz
dotemacs-c60c8edf2e0e4dcdaab451d630b05d7df583bad6.zip
feat(music): radio-browser search client + emitter (phase 1)
I built the first phase of the radio-browser lookup: the pure pieces and the network client behind the search command, with no UI yet. It lives in modules/music-config.el, alongside the existing radio-station creator. The client is url.el plus json-parse-string, trying a pinned host and falling back to one from /json/servers. The pure layer parses a response into station plists, and a non-JSON body signals a clear user-error rather than a stack trace. It picks a station's stream URL (url_resolved, then url), emits the .m3u in the existing radio format with the radio-browser UUID, formats the completion annotation (codec, bitrate, country, votes, tags), and disambiguates a colliding filename by UUID. Names are newline-stripped so an odd one can't inject extra m3u lines. The pure pieces are unit-tested (14). The network client is smoke-tested against the live API.
-rw-r--r--docs/specs/2026-07-06-radio-browser-lookup-spec.org5
-rw-r--r--modules/music-config.el116
-rw-r--r--tests/test-music-config--radio.el140
3 files changed, 259 insertions, 2 deletions
diff --git a/docs/specs/2026-07-06-radio-browser-lookup-spec.org b/docs/specs/2026-07-06-radio-browser-lookup-spec.org
index 9d362914..91653fa7 100644
--- a/docs/specs/2026-07-06-radio-browser-lookup-spec.org
+++ b/docs/specs/2026-07-06-radio-browser-lookup-spec.org
@@ -4,17 +4,18 @@
#+TODO: TODO | DONE
#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
-* READY Radio-browser lookup
+* DOING Radio-browser lookup
:PROPERTIES:
:ID: 4839b2c8-0552-4029-9e0f-4bf69b9a4dcd
:END:
+- 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 | ready |
+| Status | doing |
|----------+-------------------------------------------------------------------|
| Owner | Craig Jennings |
|----------+-------------------------------------------------------------------|
diff --git a/modules/music-config.el b/modules/music-config.el
index d5791eba..88e2e23c 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -1030,5 +1030,121 @@ For URL tracks: decoded URL."
(with-eval-after-load 'emms
(keymap-set emms-playlist-mode-map "R" #'cj/music-create-radio-station))
+;; --------------------------- Radio-browser Lookup ----------------------------
+;; Search radio-browser.info and turn a selection into a playable radio .m3u.
+;; Spec: docs/specs/2026-07-06-radio-browser-lookup-spec.org. The pure pieces
+;; (parse / emit / format / filename) carry the tests; the network GET and the
+;; interactive command are exercised live.
+
+(require 'url)
+
+(defvar cj/music-radio-server "de1.api.radio-browser.info"
+ "Default radio-browser API host.
+On a connection failure the client falls back to a host from /json/servers.")
+
+(defvar cj/music-radio-user-agent "cj-emacs-music/1.0 (radio-browser lookup)"
+ "User-Agent sent with radio-browser requests.
+The project asks clients to identify themselves.")
+
+(defvar cj/music-radio-search-limit 30
+ "Maximum number of stations a radio-browser search returns.")
+
+(defvar cj/music-radio-save-dir (expand-file-name "~/.local/share/mpd/playlists/")
+ "Directory new radio-browser stations are written to (the radio home).")
+
+(defun cj/music-radio--parse-search (json-text)
+ "Parse a radio-browser JSON-TEXT array into a list of station plists.
+Signals a `user-error' rather than a raw parse error when JSON-TEXT is not
+JSON (a gateway HTML page or a rate-limit notice), so a bad response reads as a
+clear message instead of a stack trace."
+ (condition-case nil
+ (json-parse-string json-text :object-type 'plist :array-type 'list :null-object nil)
+ (error (user-error "radio-browser returned an unreadable response"))))
+
+(defun cj/music-radio--station-url (st)
+ "Best stream URL for station ST: url_resolved, then url, then nil."
+ (let ((r (plist-get st :url_resolved))
+ (u (plist-get st :url)))
+ (cond ((and (stringp r) (not (string-empty-p r))) r)
+ ((and (stringp u) (not (string-empty-p u))) u))))
+
+(defun cj/music-radio--station-m3u (st)
+ "Return the .m3u file text for station ST, or nil when it has no stream URL.
+Matches the existing radio files: #EXTM3U, an optional #RADIOBROWSERUUID line,
+#EXTINF:1,<name>, and the stream URL."
+ (let* ((url (cj/music-radio--station-url st))
+ ;; Strip newlines so an unexpected multi-line name can't inject extra
+ ;; m3u lines; the API returns single-line names, but it's external data.
+ (name (replace-regexp-in-string "[\r\n]+" " "
+ (or (plist-get st :name) "Radio")))
+ (uuid (plist-get st :stationuuid)))
+ (when url
+ (concat "#EXTM3U\n"
+ (if (and (stringp uuid) (not (string-empty-p uuid)))
+ (format "#RADIOBROWSERUUID:%s\n" uuid)
+ "")
+ (format "#EXTINF:1,%s\n%s\n" name url)))))
+
+(defun cj/music-radio--tags-snippet (tags n)
+ "Return the first N comma-separated TAGS as a trimmed display string.
+TAGS is a comma-separated string or nil; nil or empty yields the empty string."
+ (if (and (stringp tags) (not (string-empty-p tags)))
+ (string-join (seq-take (split-string tags "," t "[ \t]*") n) ", ")
+ ""))
+
+(defun cj/music-radio--format-candidate (st)
+ "Marginalia annotation for station ST.
+Variant B: codec, bitrate, country, votes, and the first few tags."
+ (let ((codec (or (plist-get st :codec) ""))
+ (bitrate (let ((b (plist-get st :bitrate)))
+ (if (and (integerp b) (> b 0)) (format "%dk" b) "")))
+ (cc (or (plist-get st :countrycode) ""))
+ (votes (or (plist-get st :votes) 0))
+ (tags (cj/music-radio--tags-snippet (plist-get st :tags) 3)))
+ (format "%-4s %-5s %-2s ♥%d %s" codec bitrate cc votes tags)))
+
+(defun cj/music-radio--disambiguate-name (name uuid taken)
+ "Return a filesystem-safe basename for NAME, unique against the TAKEN list.
+On a collision, append a short fragment of UUID. Pure helper: keeps two
+same-named stations picked in one search from overwriting each other."
+ (let ((base (cj/music--safe-filename name)))
+ (if (member base taken)
+ (concat base "_" (substring (or uuid "x") 0 (min 8 (length (or uuid "x")))))
+ base)))
+
+(defun cj/music-radio--search-url (server query)
+ "Build the radio-browser station-search URL for QUERY against SERVER."
+ (format "https://%s/json/stations/search?name=%s&limit=%d&hidebroken=true&order=votes&reverse=true"
+ server (url-hexify-string query) cj/music-radio-search-limit))
+
+(defun cj/music-radio--http-get (url)
+ "GET URL with the radio-browser User-Agent; return the response body or nil."
+ (let ((url-request-extra-headers `(("User-Agent" . ,cj/music-radio-user-agent))))
+ (when-let* ((buf (url-retrieve-synchronously url t t 15)))
+ (with-current-buffer buf
+ (goto-char (point-min))
+ (prog1 (when (re-search-forward "\n\n" nil t)
+ (buffer-substring-no-properties (point) (point-max)))
+ (kill-buffer buf))))))
+
+(defun cj/music-radio--search-fallback (query)
+ "Fetch an alternate radio-browser host and retry the QUERY search once."
+ (when-let* ((body (cj/music-radio--http-get
+ "https://all.api.radio-browser.info/json/servers"))
+ (servers (cj/music-radio--parse-search body))
+ (host (plist-get (car servers) :name)))
+ (cj/music-radio--http-get (cj/music-radio--search-url host query))))
+
+(defun cj/music-radio--search (query)
+ "Search radio-browser for QUERY; return a list of station plists.
+Tries `cj/music-radio-server' first, then falls back to a host from
+/json/servers once. Signals a `user-error' when nothing responds."
+ (let ((body (or (ignore-errors
+ (cj/music-radio--http-get
+ (cj/music-radio--search-url cj/music-radio-server query)))
+ (ignore-errors (cj/music-radio--search-fallback query)))))
+ (unless body (user-error "radio-browser: no response (network down?)"))
+ (cj/music-radio--parse-search body)))
+
(provide 'music-config)
;;; music-config.el ends here
diff --git a/tests/test-music-config--radio.el b/tests/test-music-config--radio.el
new file mode 100644
index 00000000..651e2cb7
--- /dev/null
+++ b/tests/test-music-config--radio.el
@@ -0,0 +1,140 @@
+;;; test-music-config--radio.el --- radio-browser lookup pure-logic tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Phase 1 of the radio-browser lookup (spec docs/specs/2026-07-06-radio-browser-lookup-spec.org):
+;; the pure pieces behind the search command. Parsing a recorded JSON response,
+;; picking a station's stream URL, emitting the .m3u, formatting the marginalia
+;; annotation (Variant B: codec/bitrate/country/votes/tags), disambiguating a
+;; colliding filename, and building the search URL. The network GET and the
+;; interactive command are exercised in the daemon, not here.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'music-config)
+
+(declare-function cj/music-radio--parse-search "music-config" (json-text))
+(declare-function cj/music-radio--station-url "music-config" (st))
+(declare-function cj/music-radio--station-m3u "music-config" (st))
+(declare-function cj/music-radio--tags-snippet "music-config" (tags n))
+(declare-function cj/music-radio--format-candidate "music-config" (st))
+(declare-function cj/music-radio--disambiguate-name "music-config" (name uuid taken))
+(declare-function cj/music-radio--search-url "music-config" (server query))
+
+(defconst test-music-radio--fixture
+ (concat "[{\"stationuuid\":\"ea8059be-d119-4de3-b27b-0d9bd6aedb17\","
+ "\"name\":\"Adroit Jazz Underground\",\"url_resolved\":\"https://icecast.walmradio.com:8443/jazz\","
+ "\"codec\":\"MP3\",\"bitrate\":320,\"countrycode\":\"US\",\"votes\":174208,\"tags\":\"bebop,hard bop,cool\"},"
+ "{\"stationuuid\":\"00000000-no-url\",\"name\":\"No URL Station\",\"url_resolved\":\"\",\"url\":\"\","
+ "\"codec\":\"AAC\",\"bitrate\":0,\"countrycode\":\"FR\",\"votes\":5,\"tags\":\"\"}]")
+ "A two-station recorded radio-browser search response.")
+
+(defun test-music-radio--first ()
+ "First station plist from the fixture."
+ (car (cj/music-radio--parse-search test-music-radio--fixture)))
+
+;;; --------------------------- parse-search -----------------------------------
+
+(ert-deftest test-music-radio-parse-search-normal ()
+ "Normal: a recorded response parses to station plists with the expected fields."
+ (let ((stations (cj/music-radio--parse-search test-music-radio--fixture)))
+ (should (= (length stations) 2))
+ (should (equal (plist-get (car stations) :name) "Adroit Jazz Underground"))
+ (should (equal (plist-get (car stations) :stationuuid)
+ "ea8059be-d119-4de3-b27b-0d9bd6aedb17"))))
+
+(ert-deftest test-music-radio-parse-search-empty ()
+ "Boundary: an empty result array parses to nil."
+ (should-not (cj/music-radio--parse-search "[]")))
+
+(ert-deftest test-music-radio-parse-search-malformed-user-errors ()
+ "Error: a non-JSON body (a gateway page) signals user-error, not a raw parse error."
+ (should-error (cj/music-radio--parse-search "<html>502 Bad Gateway</html>")
+ :type 'user-error))
+
+;;; --------------------------- station-url ------------------------------------
+
+(ert-deftest test-music-radio-station-url-resolved ()
+ "Normal: url_resolved is preferred."
+ (should (equal (cj/music-radio--station-url (test-music-radio--first))
+ "https://icecast.walmradio.com:8443/jazz")))
+
+(ert-deftest test-music-radio-station-url-fallback-to-url ()
+ "Boundary: empty url_resolved falls back to url."
+ (should (equal (cj/music-radio--station-url
+ '(:url_resolved "" :url "http://example.test/stream"))
+ "http://example.test/stream")))
+
+(ert-deftest test-music-radio-station-url-none ()
+ "Error: neither url_resolved nor url yields nil."
+ (should-not (cj/music-radio--station-url '(:url_resolved "" :url ""))))
+
+;;; --------------------------- station-m3u ------------------------------------
+
+(ert-deftest test-music-radio-station-m3u-shape ()
+ "Normal: the .m3u carries #EXTM3U, the UUID, #EXTINF, and the stream URL."
+ (let ((m3u (cj/music-radio--station-m3u (test-music-radio--first))))
+ (should (string-prefix-p "#EXTM3U\n" m3u))
+ (should (string-match-p "#RADIOBROWSERUUID:ea8059be-d119-4de3-b27b-0d9bd6aedb17" m3u))
+ (should (string-match-p "#EXTINF:1,Adroit Jazz Underground" m3u))
+ (should (string-match-p "https://icecast.walmradio.com:8443/jazz" m3u))))
+
+(ert-deftest test-music-radio-station-m3u-no-url-is-nil ()
+ "Error: a station with no usable stream URL emits nil (not a broken file)."
+ (should-not (cj/music-radio--station-m3u '(:name "Broken" :url_resolved "" :url ""))))
+
+;;; --------------------------- tags-snippet -----------------------------------
+
+(ert-deftest test-music-radio-tags-snippet-takes-first-n ()
+ "Normal: the first N comma-separated tags render trimmed."
+ (should (equal (cj/music-radio--tags-snippet "bebop, hard bop, cool, free jazz" 3)
+ "bebop, hard bop, cool")))
+
+(ert-deftest test-music-radio-tags-snippet-empty ()
+ "Boundary: empty or nil tags render as the empty string."
+ (should (equal (cj/music-radio--tags-snippet "" 3) ""))
+ (should (equal (cj/music-radio--tags-snippet nil 3) "")))
+
+;;; --------------------------- format-candidate (Variant B) -------------------
+
+(ert-deftest test-music-radio-format-candidate-variant-b ()
+ "Normal: the annotation carries codec, votes, and tags (Variant B)."
+ (let ((ann (cj/music-radio--format-candidate (test-music-radio--first))))
+ (should (string-match-p "MP3" ann))
+ (should (string-match-p "174208" ann))
+ (should (string-match-p "bebop" ann))))
+
+;;; --------------------------- disambiguate-name ------------------------------
+
+(ert-deftest test-music-radio-disambiguate-name-unique ()
+ "Normal: a name not already taken keeps its safe basename."
+ (should (equal (cj/music-radio--disambiguate-name "Jazz Radio" "uuid1234" '())
+ "Jazz_Radio")))
+
+(ert-deftest test-music-radio-disambiguate-name-collision ()
+ "Boundary: a colliding name gets a UUID fragment appended, making it unique."
+ (let ((first (cj/music-radio--disambiguate-name "Jazz Radio" "aaaabbbbcccc" '())))
+ (should (equal first "Jazz_Radio"))
+ (should-not (equal (cj/music-radio--disambiguate-name "Jazz Radio" "aaaabbbbcccc"
+ (list first))
+ first))))
+
+;;; --------------------------- search-url -------------------------------------
+
+(ert-deftest test-music-radio-search-url-encodes-query-and-limit ()
+ "Normal: the search URL hex-encodes the query and carries the limit."
+ (let* ((cj/music-radio-search-limit 30)
+ (u (cj/music-radio--search-url "de1.api.radio-browser.info" "smooth jazz")))
+ (should (string-match-p "smooth%20jazz" u))
+ (should (string-match-p "limit=30" u))
+ (should (string-match-p "/json/stations/search" u))))
+
+(provide 'test-music-config--radio)
+;;; test-music-config--radio.el ends here