aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/specs/2026-07-06-radio-browser-lookup-spec.org5
-rw-r--r--modules/music-config.el88
-rw-r--r--tests/test-music-config--radio.el20
3 files changed, 77 insertions, 36 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 91653fa7..637ec4f0 100644
--- a/docs/specs/2026-07-06-radio-browser-lookup-spec.org
+++ b/docs/specs/2026-07-06-radio-browser-lookup-spec.org
@@ -8,6 +8,7 @@
:PROPERTIES:
:ID: 4839b2c8-0552-4029-9e0f-4bf69b9a4dcd
:END:
+- 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.
@@ -55,9 +56,9 @@ The verified API shape (checked live 2026-07-06):
- No audio format work; mpv already plays whatever the stream serves.
** Scope tiers
-- v1: a search command, a result picker, and playlist creation from the selection, wired to the existing writer + sourcing.
+- 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/<uuid>), tag/country faceted search, an audition-before-save preview, a homepage/favicon-rich annotation.
+- vNext (log to todo.org): click/vote counting (POST /json/url/<uuid>), 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
diff --git a/modules/music-config.el b/modules/music-config.el
index 1cb67b6e..22b8aa4f 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -859,7 +859,7 @@ For URL tracks: decoded URL."
(propertize " : " 'face 'cj/music-header-face)
(funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist))
" "
- (funcall mode-indicator "t" "single" (bound-and-true-p emms-repeat-track))
+ (funcall mode-indicator "s" "single" (bound-and-true-p emms-repeat-track))
" "
(funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist))
" "
@@ -867,7 +867,12 @@ For URL tracks: decoded URL."
"\n"
(propertize "Keys " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
- (propertize "a:add c:clear L:load S:save SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
+ (propertize "a:add c:clear L:load S:stop SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
+ 'face 'cj/music-keyhint-face)
+ "\n"
+ (propertize "Radio " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize "n:by name t:by tag m:enter manually"
'face 'cj/music-keyhint-face)
"\n\n")))
@@ -1025,10 +1030,8 @@ For URL tracks: decoded URL."
(insert content))
(message "Created radio station: %s" (file-name-nondirectory file))))
-;; Bound here rather than in the emms `:bind' so use-package does not emit a
-;; redundant autoload that collides with this same-file definition.
-(with-eval-after-load 'emms
- (keymap-set emms-playlist-mode-map "R" #'cj/music-create-radio-station))
+;; The manual name+URL creator is bound to m in the radio row below (see the
+;; with-eval-after-load block near the radio-browser lookup), not R.
;; --------------------------- Radio-browser Lookup ----------------------------
;; Search radio-browser.info and turn a selection into a playable radio .m3u.
@@ -1052,6 +1055,10 @@ The project asks clients to identify themselves.")
(defvar cj/music-radio-save-dir (expand-file-name "~/.local/share/mpd/playlists/")
"Directory new radio-browser stations are written to (the radio home).")
+(defvar cj/music-radio-filename-suffix "-Radio"
+ "Suffix appended to a created station's basename, before the .m3u extension.
+Marks a lookup-created station in the playlist directory.")
+
(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
@@ -1112,10 +1119,11 @@ same-named stations picked in one search from overwriting each other."
(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--search-url (server query &optional field)
+ "Build the radio-browser station-search URL for QUERY against SERVER.
+FIELD is the search field: \"name\" (default) or \"tag\"."
+ (format "https://%s/json/stations/search?%s=%s&limit=%d&hidebroken=true&order=votes&reverse=true"
+ server (or field "name") (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."
@@ -1127,22 +1135,23 @@ same-named stations picked in one search from overwriting each other."
(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."
+(defun cj/music-radio--search-fallback (query &optional field)
+ "Fetch an alternate radio-browser host and retry the QUERY/FIELD 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))))
+ (cj/music-radio--http-get (cj/music-radio--search-url host query field))))
-(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."
+(defun cj/music-radio--search (query &optional field)
+ "Search radio-browser for QUERY on FIELD; return a list of station plists.
+FIELD is \"name\" (default) or \"tag\". 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)))))
+ (cj/music-radio--search-url cj/music-radio-server query field)))
+ (ignore-errors (cj/music-radio--search-fallback query field)))))
(unless body (user-error "radio-browser: no response (network down?)"))
(cj/music-radio--parse-search body)))
@@ -1181,7 +1190,8 @@ NAMES). Creates DIR when absent."
(let* ((base (cj/music-radio--disambiguate-name
(or (plist-get st :name) "Radio")
(plist-get st :stationuuid) taken))
- (path (expand-file-name (concat base ".m3u") dir)))
+ (path (expand-file-name
+ (concat base cj/music-radio-filename-suffix ".m3u") dir)))
(push base taken)
(with-temp-file path (insert m3u))
(push path written)))))
@@ -1234,19 +1244,18 @@ selection order; each pick is removed from the pool so it can't be chosen twice.
(dolist (p (cdr paths))
(emms-add-playlist p))))
-(defun cj/music-radio-search (query)
- "Search radio-browser.info for QUERY, pick stations, then create and play them.
-Prompts for a query, lists matching stations (annotated with codec, bitrate,
-country, votes, and tags), and lets you pick several one at a time. Each pick is
-written as an .m3u into `cj/music-radio-save-dir', then the selection plays
-through mpv (interrupting whatever was playing)."
- (interactive "sRadio search: ")
+(defun cj/music-radio--search-and-play (query field)
+ "Search radio-browser for QUERY on FIELD, pick stations, then create and play.
+FIELD is \"name\" or \"tag\". Lists matching stations (annotated with codec,
+bitrate, country, votes, and tags), lets you pick several one at a time, writes
+each as an .m3u into `cj/music-radio-save-dir', then plays the selection through
+mpv (interrupting whatever was playing)."
(when (string-empty-p (string-trim query))
(user-error "Empty search"))
- (let* ((stations (cj/music-radio--search query))
+ (let* ((stations (cj/music-radio--search query field))
(candidates (cj/music-radio--candidates stations)))
(unless candidates
- (user-error "No stations found for %S" query))
+ (user-error "No stations found for %s %S" field query))
(let ((chosen (cj/music-radio--pick-loop candidates)))
(unless chosen
(user-error "No stations selected"))
@@ -1268,10 +1277,25 @@ through mpv (interrupting whatever was playing)."
(file-name-nondirectory (car written))))
""))))))
-;; Bound alongside the manual radio-station creator (R); S = search-and-create.
-;; ("s" stays emms-stop.)
+(defun cj/music-radio-search-by-name (query)
+ "Search radio-browser.info by station name, then create and play a selection."
+ (interactive "sRadio search (name): ")
+ (cj/music-radio--search-and-play query "name"))
+
+(defun cj/music-radio-search-by-tag (tag)
+ "Search radio-browser.info by tag/genre, then create and play a selection."
+ (interactive "sRadio search (tag): ")
+ (cj/music-radio--search-and-play tag "tag"))
+
+;; Radio row in the playlist buffer: n = search by name, t = search by tag,
+;; m = enter a station by hand. This moves the "single" mode toggle off t to s
+;; and emms-stop off s to S (see the header's Mode/Keys/Radio rows).
(with-eval-after-load 'emms
- (keymap-set emms-playlist-mode-map "S" #'cj/music-radio-search))
+ (keymap-set emms-playlist-mode-map "n" #'cj/music-radio-search-by-name)
+ (keymap-set emms-playlist-mode-map "t" #'cj/music-radio-search-by-tag)
+ (keymap-set emms-playlist-mode-map "m" #'cj/music-create-radio-station)
+ (keymap-set emms-playlist-mode-map "s" #'emms-toggle-repeat-track)
+ (keymap-set emms-playlist-mode-map "S" #'emms-stop))
(provide 'music-config)
;;; music-config.el ends here
diff --git a/tests/test-music-config--radio.el b/tests/test-music-config--radio.el
index b8ce5e30..98368d76 100644
--- a/tests/test-music-config--radio.el
+++ b/tests/test-music-config--radio.el
@@ -129,13 +129,19 @@
;;; --------------------------- 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."
+ "Normal: the search URL hex-encodes the query and carries the limit; name is the default field."
(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 "name=smooth%20jazz" u))
(should (string-match-p "limit=30" u))
(should (string-match-p "/json/stations/search" u))))
+(ert-deftest test-music-radio-search-url-tag-field ()
+ "Normal: field \"tag\" searches the tag= parameter instead of name=."
+ (let ((u (cj/music-radio--search-url "de1.api.radio-browser.info" "ambient" "tag")))
+ (should (string-match-p "tag=ambient" u))
+ (should-not (string-match-p "name=ambient" u))))
+
(declare-function cj/music-radio--candidates "music-config" (stations))
(declare-function cj/music-radio--write-stations "music-config" (stations dir))
@@ -173,6 +179,16 @@
(should (file-exists-p (car (plist-get result :written)))))
(delete-directory dir t))))
+(ert-deftest test-music-radio-write-stations-radio-suffix ()
+ "Normal: a written filename carries the -Radio suffix before .m3u."
+ (let ((dir (make-temp-file "radio-write-" t)))
+ (unwind-protect
+ (let* ((result (cj/music-radio--write-stations (list (test-music-radio--first)) dir))
+ (path (car (plist-get result :written))))
+ (should (string-suffix-p "-Radio.m3u" path))
+ (should (string-match-p "Adroit_Jazz_Underground-Radio\\.m3u\\'" path)))
+ (delete-directory dir t))))
+
(ert-deftest test-music-radio-write-stations-collision-writes-two-files ()
"Boundary: two same-named stations in one run write two distinct files, no overwrite."
(let ((dir (make-temp-file "radio-write-" t)))