diff options
| -rw-r--r-- | docs/specs/2026-07-06-radio-browser-lookup-spec.org | 5 | ||||
| -rw-r--r-- | modules/music-config.el | 88 | ||||
| -rw-r--r-- | tests/test-music-config--radio.el | 20 |
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))) |
