aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/specs/2026-07-06-radio-browser-lookup-spec.org1
-rw-r--r--modules/music-config.el333
-rw-r--r--tests/test-music-config--art-cache-key.el15
-rw-r--r--tests/test-music-config--art-favicon-url.el17
-rw-r--r--tests/test-music-config--m3u-text.el93
-rw-r--r--tests/test-music-config--radio-station-track.el135
-rw-r--r--tests/test-music-config--radio.el102
-rw-r--r--tests/test-music-config--safe-filename.el97
-rw-r--r--tests/test-music-config--save-helpers.el90
-rw-r--r--tests/test-music-config-create-radio-station.el197
-rw-r--r--tests/test-music-config-more-commands.el149
11 files changed, 729 insertions, 500 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 637ec4f0..3118881b 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-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).
diff --git a/modules/music-config.el b/modules/music-config.el
index 0699be4f..cd0d9d19 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -38,7 +38,6 @@
;; compiles as a dynamic bind, not a dead lexical local -- otherwise emms /
;; orderless never see the binding (the lexical-binding foreign-special-var trap).
(defvar orderless-smart-case)
-(defvar emms-source-playlist-ask-before-overwrite)
(defvar emms-playlist-buffer-p)
(defvar emms-playlist-buffer)
(defvar emms-random-playlist)
@@ -109,11 +108,15 @@ falls back to the plain text player (names, a dim glyph, a thin status line)."
(declare-function emms-track-name "emms")
(declare-function emms-track-type "emms")
(declare-function emms-track-get "emms")
+(declare-function emms-track "emms")
+(declare-function emms-track-set "emms")
(declare-function emms-track-simple-description "emms")
(declare-function emms-playlist-current-selected-track "emms")
(declare-function emms-playlist-select "emms")
+(declare-function emms-playlist-selected-track "emms")
(declare-function emms-playlist-clear "emms")
-(declare-function emms-playlist-save "emms-source-playlist")
+(declare-function emms-playlist-insert-track "emms")
+(declare-function emms-stop "emms")
(declare-function emms-start "emms")
(declare-function emms-random "emms")
(declare-function emms-next "emms")
@@ -349,18 +352,22 @@ modification date so marginalia can show them."
tracks))))
(nreverse tracks)))))
-(defun cj/music--playlist-tracks ()
- "Return list of track names from current EMMS playlist buffer."
+(defun cj/music--playlist-track-objects ()
+ "Return the track objects from the current EMMS playlist buffer, in order."
(let ((tracks '()))
(with-current-buffer (cj/music--ensure-playlist-buffer)
(save-excursion
(goto-char (point-min))
(while (not (eobp))
(when-let ((track (emms-playlist-track-at (point))))
- (push (emms-track-name track) tracks))
+ (push track tracks))
(forward-line 1))))
(nreverse tracks)))
+(defun cj/music--playlist-tracks ()
+ "Return list of track names from current EMMS playlist buffer."
+ (mapcar #'emms-track-name (cj/music--playlist-track-objects)))
+
(defun cj/music--dedup-m3u-files (paths)
"Return (BASENAME . PATH) conses for PATHS, first occurrence of a basename winning.
Pure helper: since `cj/music--get-m3u-files' scans `cj/music-m3u-roots' in order,
@@ -387,10 +394,6 @@ collision the earlier directory wins."
(mapcar (lambda (pair) (file-name-sans-extension (car pair)))
(cj/music--get-m3u-files)))
-(defun cj/music--safe-filename (name)
- "Return NAME made filesystem-safe by replacing bad chars with underscores."
- (replace-regexp-in-string "[^a-zA-Z0-9_-]" "_" name))
-
(defun cj/music--playlist-modified-p ()
"Return non-nil if current playlist differs from its associated M3U file."
(and cj/music-playlist-file
@@ -537,23 +540,103 @@ Replaces current playlist."
(message "Loaded playlist: %s" choice-name)))
+(defun cj/music--m3u-track-lines (track entries)
+ "The .m3u lines for TRACK.
+A file track is its bare absolute path. A url track carries its station
+metadata — an #EXTINF label plus #RADIOBROWSERUUID / #RADIOBROWSERFAVICON when
+known — read from the track's properties first, then its ENTRIES metadata (a
+loaded legacy playlist has entries but no properties), so a saved station
+keeps its display name and cover art on reload."
+ (let ((name (emms-track-name track)))
+ (if (not (eq (emms-track-type track) 'url))
+ (concat name "\n")
+ (let* ((meta (cdr (assoc name entries)))
+ (label (or (emms-track-get track 'info-title)
+ (plist-get meta :name)
+ (cj/music--tidy-host name)))
+ (uuid (or (emms-track-get track 'radio-uuid)
+ (plist-get meta :uuid)))
+ (favicon (or (emms-track-get track 'radio-favicon)
+ (plist-get meta :favicon))))
+ (concat
+ (if (and (stringp uuid) (not (string-empty-p uuid)))
+ (format "#RADIOBROWSERUUID:%s\n" uuid)
+ "")
+ (if (and (stringp favicon) (not (string-empty-p favicon)))
+ (format "#RADIOBROWSERFAVICON:%s\n"
+ (replace-regexp-in-string "[\r\n]+" " " favicon))
+ "")
+ (format "#EXTINF:-1,%s\n"
+ (replace-regexp-in-string "[\r\n]+" " " label))
+ name "\n")))))
+
+(defun cj/music--m3u-text (tracks entries)
+ "The full .m3u file text for TRACKS, station metadata from ENTRIES.
+The stock EMMS m3u writer emits bare URLs; this emitter writes the comment
+lines `cj/music--m3u-entries' parses, so save -> load round-trips."
+ (concat "#EXTM3U\n"
+ (mapconcat (lambda (tr) (cj/music--m3u-track-lines tr entries))
+ tracks "")))
+
+(defun cj/music--write-playlist-file (path tracks entries)
+ "Write TRACKS to PATH as .m3u text, station metadata from ENTRIES.
+Refreshes the radio metadata cache since a new .m3u just landed."
+ (with-temp-file path
+ (insert (cj/music--m3u-text tracks entries)))
+ (cj/music--refresh-radio-name-map))
+
+(defun cj/music--save-default-name (tracks file entries)
+ "The name the save prompt should offer.
+FILE (the playlist's associated .m3u) wins when present. Otherwise the first
+url track's station name — its title property, else its #EXTINF label from
+ENTRIES. Nil when neither applies (the caller falls back to a timestamp)."
+ (if file
+ (file-name-sans-extension (file-name-nondirectory file))
+ (cl-loop for tr in tracks
+ when (eq (emms-track-type tr) 'url)
+ thereis (or (emms-track-get tr 'info-title)
+ (plist-get (cdr (assoc (emms-track-name tr) entries))
+ :name)))))
+
+(defun cj/music--save-directory (tracks)
+ "Directory a saved playlist targets.
+An all-stream queue is a radio playlist and saves into
+`cj/music-radio-save-dir'; anything else saves into `cj/music-m3u-root'."
+ (if (and tracks
+ (cl-every (lambda (tr) (eq (emms-track-type tr) 'url)) tracks))
+ cj/music-radio-save-dir
+ cj/music-m3u-root))
+
(defun cj/music-playlist-save ()
- "Save current EMMS playlist to a file in cj/music-m3u-root.
-Offers completion over existing names but allows new names."
+ "Save the current EMMS playlist to an .m3u file.
+An all-stream queue saves into `cj/music-radio-save-dir' (the radio playlist
+home); anything else saves into `cj/music-m3u-root'. A queue of freshly
+looked-up stations pre-fills the first station's name in the prompt; a
+playlist with an associated file keeps that file's name as the default.
+Station metadata (name, uuid, favicon) is written with each stream so a
+reloaded playlist keeps its display name and cover art."
(interactive)
- (let* ((existing (cj/music--get-m3u-basenames))
- (default-name (if cj/music-playlist-file
- (file-name-sans-extension (file-name-nondirectory cj/music-playlist-file))
- (format-time-string "playlist-%Y%m%d-%H%M%S")))
- (chosen (completing-read "Save playlist as: " existing nil nil nil nil default-name))
+ (let* ((tracks (cj/music--playlist-track-objects))
+ (entries (cj/music--radio-metadata))
+ (existing (cj/music--get-m3u-basenames))
+ (assoc-file (with-current-buffer (cj/music--ensure-playlist-buffer)
+ cj/music-playlist-file))
+ (prefill (and (null assoc-file)
+ (cj/music--save-default-name tracks nil entries)))
+ (default-name (or (cj/music--save-default-name tracks assoc-file entries)
+ (format-time-string "playlist-%Y%m%d-%H%M%S")))
+ (chosen (completing-read "Save playlist as: " existing nil nil
+ prefill nil default-name))
(filename (if (string-suffix-p ".m3u" chosen) chosen (concat chosen ".m3u")))
- (full (expand-file-name filename cj/music-m3u-root)))
+ (dir (cj/music--save-directory tracks))
+ (full (expand-file-name filename dir)))
+ (when (string-empty-p (string-trim chosen))
+ (user-error "Playlist name cannot be empty"))
(when (and (file-exists-p full)
(not (cj/confirm-strong (format "Overwrite %s? " filename))))
(user-error "Aborted saving playlist"))
- (with-current-buffer (cj/music--ensure-playlist-buffer)
- (let ((emms-source-playlist-ask-before-overwrite nil))
- (emms-playlist-save 'm3u full)))
+ (make-directory dir t)
+ (cj/music--write-playlist-file full tracks entries)
(cj/music--sync-playlist-file full)
(message "Saved playlist: %s" filename)))
@@ -590,8 +673,9 @@ Offers completion over existing names but allows new names."
(let ((path cj/music-playlist-file))
(when (cj/music--playlist-modified-p)
(when (yes-or-no-p "Playlist modified. Save before editing? ")
- (let ((emms-source-playlist-ask-before-overwrite nil))
- (emms-playlist-save 'm3u path))))
+ (cj/music--write-playlist-file path
+ (cj/music--playlist-track-objects)
+ (cj/music--radio-metadata))))
;; Re-validate existence before opening
(if (file-exists-p path)
(find-file-other-window path)
@@ -838,7 +922,7 @@ Dirs added recursively."
"C-; m m" "toggle playlist"
"C-; m M" "show playlist"
"C-; m a" "add music"
- "C-; m R" "create radio"
+ "C-; m R" "queue radio station"
"C-; m SPC" "pause"
"C-; m s" "stop"
"C-; m n" "next track"
@@ -1104,7 +1188,7 @@ The rule uses a resize-safe :align-to span, not a hardcoded character count."
(funcall mode-indicator "x" "consume" cj/music-consume-mode) "\n"
(propertize "Keys " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
- (propertize "a:add c:clear L:load S:stop SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
+ (propertize "a:add c:clear L:load w:save 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)
@@ -1320,7 +1404,7 @@ unless fancy."
("L" . cj/music-playlist-load)
("E" . cj/music-playlist-edit)
("g" . cj/music-playlist-reload)
- ("S" . cj/music-playlist-save)
+ ("w" . cj/music-playlist-save)
;; Track reordering
("S-<up>" . emms-playlist-mode-shift-track-up)
("S-<down>" . emms-playlist-mode-shift-track-down)
@@ -1336,7 +1420,10 @@ unless fancy."
;;; Radio station creation
(defun cj/music-create-radio-station (name url)
- "Create a radio station M3U playlist with NAME and URL in cj/music-m3u-root."
+ "Queue and play a radio station from a hand-entered NAME and URL.
+The station becomes a url track in the playlist (NAME as its title) and
+playback starts. Nothing is written to disk — save the queue with the normal
+playlist save, where NAME pre-fills the prompt."
(interactive
(list (read-string "Radio station name: ")
(read-string "Stream URL: ")))
@@ -1344,25 +1431,21 @@ unless fancy."
(user-error "Radio station name cannot be empty"))
(when (string-empty-p url)
(user-error "Stream URL cannot be empty"))
- (let* ((safe (cj/music--safe-filename name))
- (file (expand-file-name (concat safe "_Radio.m3u") cj/music-m3u-root))
- (content (format "#EXTM3U\n#EXTINF:-1,%s\n%s\n" name url)))
- (when (and (file-exists-p file)
- (not (cj/confirm-strong (format "Overwrite %s? " (file-name-nondirectory file)))))
- (user-error "Aborted creating radio station"))
- (with-temp-file file
- (insert content))
- (cj/music--refresh-radio-name-map)
- (message "Created radio station: %s" (file-name-nondirectory file))))
+ (cj/emms--setup)
+ (cj/music-radio--enqueue-and-play
+ (list (cj/music-radio--station-track (list :name name :url url))))
+ (message "Queued radio station: %s" name))
;; 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.
-;; 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.
+;; Search radio-browser.info and queue a selection as playing url tracks, each
+;; carrying its station metadata as track properties. Nothing is written at
+;; pick time; the playlist save writes the metadata back out as .m3u comment
+;; lines. Spec: docs/specs/2026-07-06-radio-browser-lookup-spec.org. The
+;; pure pieces (parse / track-build / format) carry the tests; the network GET
+;; and the interactive command are exercised live.
(require 'url)
@@ -1378,11 +1461,8 @@ The project asks clients to identify themselves.")
"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).")
-
-(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.")
+ "Directory radio playlists are saved to (the radio home).
+The playlist save targets it when every track in the queue is a stream.")
(defun cj/music-radio--parse-search (json-text)
"Parse a radio-browser JSON-TEXT array into a list of station plists.
@@ -1400,29 +1480,43 @@ clear message instead of a stack trace."
(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))
- (favicon (plist-get st :favicon)))
- (when url
- (concat "#EXTM3U\n"
- (if (and (stringp uuid) (not (string-empty-p uuid)))
- (format "#RADIOBROWSERUUID:%s\n" uuid)
- "")
- ;; Capture the favicon at creation so the cover-art layer needs
- ;; no byuuid lookup later; same newline-strip guard as the name.
- (if (and (stringp favicon) (not (string-empty-p favicon)))
- (format "#RADIOBROWSERFAVICON:%s\n"
- (replace-regexp-in-string "[\r\n]+" " " favicon))
- "")
- (format "#EXTINF:1,%s\n%s\n" name url)))))
+(defun cj/music-radio--station-track (st)
+ "Return an EMMS url track for station ST, or nil when it has no stream URL.
+The track carries the station name as `info-title' plus `radio-uuid' and
+`radio-favicon' properties, so display names and cover art need no .m3u on
+disk; the playlist save writes the same metadata back out as comment lines.
+Newlines in the external name/favicon are flattened so they can't inject
+extra .m3u lines at save time."
+ (when-let ((url (cj/music-radio--station-url st)))
+ (let ((track (emms-track 'url url))
+ (name (replace-regexp-in-string "[\r\n]+" " "
+ (or (plist-get st :name) "Radio")))
+ (uuid (plist-get st :stationuuid))
+ (favicon (plist-get st :favicon)))
+ (emms-track-set track 'info-title name)
+ (when (and (stringp uuid) (not (string-empty-p uuid)))
+ (emms-track-set track 'radio-uuid uuid))
+ (when (and (stringp favicon) (not (string-empty-p favicon)))
+ (emms-track-set track 'radio-favicon
+ (replace-regexp-in-string "[\r\n]+" " " favicon)))
+ track)))
+
+(defun cj/music-radio--enqueue-and-play (tracks)
+ "Append TRACKS to the playlist buffer and play the first of them.
+Interrupts whatever is playing; the rest of the queue is left in place. A nil
+TRACKS is a no-op."
+ (when tracks
+ (cj/emms--setup)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (let ((first-pos nil))
+ (save-excursion
+ (dolist (tr tracks)
+ (goto-char (point-max))
+ (unless first-pos (setq first-pos (point)))
+ (emms-playlist-insert-track tr)))
+ (emms-playlist-select first-pos)))
+ (when emms-player-playing-p (emms-stop))
+ (emms-start)))
(defun cj/music-radio--tags-snippet (tags n)
"Return the first N comma-separated TAGS as a trimmed display string.
@@ -1442,15 +1536,6 @@ Variant B: codec, bitrate, country, votes, and the first few tags."
(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 &optional field)
"Build the radio-browser station-search URL for QUERY against SERVER.
FIELD is the search field: \"name\" (default) or \"tag\"."
@@ -1508,29 +1593,6 @@ to one station. Pure helper."
(puthash disp t seen)
(push (cons disp st) out)))))
-(defun cj/music-radio--write-stations (stations dir)
- "Write each station in STATIONS as an .m3u into DIR.
-Skips a station with no stream URL and disambiguates a filename that collides
-with an already-written one this run. Returns a plist (:written PATHS :skipped
-NAMES). Creates DIR when absent."
- (make-directory dir t)
- (let ((taken '()) (written '()) (skipped '()))
- (dolist (st stations)
- (let ((m3u (cj/music-radio--station-m3u st)))
- (if (not m3u)
- (push (or (plist-get st :name) "(unnamed)") skipped)
- (let* ((base (cj/music-radio--disambiguate-name
- (or (plist-get st :name) "Radio")
- (plist-get st :stationuuid) taken))
- (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)))))
- ;; New .m3u files landed, so the cached url->label map is stale.
- (when written (cj/music--refresh-radio-name-map))
- (list :written (nreverse written) :skipped (nreverse skipped))))
-
(defun cj/music-radio--completion-table (candidates)
"Completion table over CANDIDATES carrying the Variant-B marginalia affix."
(lambda (string pred action)
@@ -1571,21 +1633,17 @@ selection order; each pick is removed from the pool so it can't be chosen twice.
(setq pool (delq cell pool))))))
(nreverse chosen)))
-(defun cj/music-radio--play (paths)
- "Play the first .m3u in PATHS immediately (interrupting), enqueue the rest."
- (when paths
- (emms-play-playlist (car paths))
- (dolist (p (cdr paths))
- (emms-add-playlist p))))
-
(defun cj/music-radio--search-and-play (query field)
- "Search radio-browser for QUERY on FIELD, pick stations, then create and play.
+ "Search radio-browser for QUERY on FIELD, pick stations, then queue 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)."
+bitrate, country, votes, and tags), lets you pick several one at a time, adds
+each to the playlist as a url track carrying its station metadata, and plays
+the first pick (interrupting whatever was playing). Nothing is written to
+disk; save the queue with the normal playlist save, where the station name
+pre-fills the prompt."
(when (string-empty-p (string-trim query))
(user-error "Empty search"))
+ (cj/emms--setup)
(let* ((stations (cj/music-radio--search query field))
(candidates (cj/music-radio--candidates stations)))
(unless candidates
@@ -1593,31 +1651,30 @@ mpv (interrupting whatever was playing)."
(let ((chosen (cj/music-radio--pick-loop candidates)))
(unless chosen
(user-error "No stations selected"))
- (let* ((result (cj/music-radio--write-stations chosen cj/music-radio-save-dir))
- (written (plist-get result :written))
- (skipped (plist-get result :skipped)))
- (when written
- (cj/music-radio--play written))
- (message "Created %d station%s%s%s"
- (length written)
- (if (= (length written) 1) "" "s")
+ (let ((tracks (delq nil (mapcar #'cj/music-radio--station-track chosen)))
+ (skipped (cl-loop for st in chosen
+ unless (cj/music-radio--station-url st)
+ collect (or (plist-get st :name) "(unnamed)"))))
+ (cj/music-radio--enqueue-and-play tracks)
+ (message "Queued %d station%s%s%s"
+ (length tracks)
+ (if (= (length tracks) 1) "" "s")
(if skipped
(format ", skipped %d with no URL (%s)"
(length skipped) (string-join skipped ", "))
"")
- (if written
+ (if tracks
(format " — playing %s"
- (file-name-sans-extension
- (file-name-nondirectory (car written))))
+ (emms-track-get (car tracks) 'info-title))
""))))))
(defun cj/music-radio-search-by-name (query)
- "Search radio-browser.info by station name, then create and play a selection."
+ "Search radio-browser.info by station name, then queue 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."
+ "Search radio-browser.info by tag/genre, then queue and play a selection."
(interactive "sRadio search (tag): ")
(cj/music-radio--search-and-play tag "tag"))
@@ -1643,23 +1700,28 @@ file hash. Gitignored runtime state; `cj/music-clear-art-cache' empties it.")
(defun cj/music-art--cache-key (track &optional entries)
"Stable cache-file basename (no extension) for TRACK.
-A url with a #RADIOBROWSERUUID in ENTRIES keys on the uuid so a station shares
-one cached logo; any other url keys on a hash of its address; a file keys on a
-hash of its path."
+A url with a station uuid — the track's `radio-uuid' property, else a
+#RADIOBROWSERUUID in ENTRIES — keys on the uuid so a station shares one cached
+logo; any other url keys on a hash of its address; a file keys on a hash of
+its path."
(let ((name (emms-track-name track)))
(if (eq (emms-track-type track) 'url)
- (let ((uuid (plist-get (cdr (assoc name entries)) :uuid)))
+ (let ((uuid (or (emms-track-get track 'radio-uuid)
+ (plist-get (cdr (assoc name entries)) :uuid))))
(if (and (stringp uuid) (not (string-empty-p uuid)))
uuid
(concat "url-" (sha1 name))))
(concat "file-" (sha1 name)))))
(defun cj/music-art--favicon-url (track &optional entries)
- "Direct favicon image URL for a url TRACK from its captured
-#RADIOBROWSERFAVICON, or nil. A station with only a uuid resolves via a byuuid
-lookup elsewhere; a file track has no favicon URL."
+ "Direct favicon image URL for a url TRACK, or nil.
+The track's `radio-favicon' property wins, then its captured
+#RADIOBROWSERFAVICON from ENTRIES. A station with only a uuid resolves via a
+byuuid lookup elsewhere; a file track has no favicon URL."
(when (eq (emms-track-type track) 'url)
- (let ((fav (plist-get (cdr (assoc (emms-track-name track) entries)) :favicon)))
+ (let ((fav (or (emms-track-get track 'radio-favicon)
+ (plist-get (cdr (assoc (emms-track-name track) entries))
+ :favicon))))
(and (stringp fav) (not (string-empty-p fav)) fav))))
(defun cj/music-art--valid-image-p (data)
@@ -1733,9 +1795,10 @@ when there is nothing to fetch."
(unless (cj/music-art--cached-file key)
(when (eq (emms-track-type track) 'url)
(let ((fav (or (cj/music-art--favicon-url track entries)
- (let ((uuid (plist-get (cdr (assoc (emms-track-name track)
- entries))
- :uuid)))
+ (let ((uuid (or (emms-track-get track 'radio-uuid)
+ (plist-get (cdr (assoc (emms-track-name track)
+ entries))
+ :uuid))))
(and (stringp uuid) (not (string-empty-p uuid))
(cj/music-art--byuuid-favicon uuid))))))
(and fav (cj/music-art--fetch-to-cache fav key)))))))
diff --git a/tests/test-music-config--art-cache-key.el b/tests/test-music-config--art-cache-key.el
index 0ef82a21..bc869808 100644
--- a/tests/test-music-config--art-cache-key.el
+++ b/tests/test-music-config--art-cache-key.el
@@ -50,5 +50,20 @@
(should (string= (cj/music-art--cache-key track entries)
(concat "url-" (sha1 url))))))
+(ert-deftest test-music-config--art-cache-key-normal-track-property ()
+ "Normal: a queued lookup station carries its uuid as a track property and
+needs no entries at all."
+ (let ((track (emms-track 'url "https://ck4.example.net/live")))
+ (emms-track-set track 'radio-uuid "prop-uuid")
+ (should (string= (cj/music-art--cache-key track nil) "prop-uuid"))))
+
+(ert-deftest test-music-config--art-cache-key-normal-property-beats-entries ()
+ "Normal: the track property wins over a conflicting entries uuid."
+ (let* ((url "https://ck5.example.net/live")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid "entries-uuid" :favicon nil))))
+ (emms-track-set track 'radio-uuid "prop-uuid")
+ (should (string= (cj/music-art--cache-key track entries) "prop-uuid"))))
+
(provide 'test-music-config--art-cache-key)
;;; test-music-config--art-cache-key.el ends here
diff --git a/tests/test-music-config--art-favicon-url.el b/tests/test-music-config--art-favicon-url.el
index d9759ab3..9e7b92f8 100644
--- a/tests/test-music-config--art-favicon-url.el
+++ b/tests/test-music-config--art-favicon-url.el
@@ -48,5 +48,22 @@
(let ((track (emms-track 'file "/music/x.flac")))
(should (null (cj/music-art--favicon-url track nil)))))
+(ert-deftest test-music-config--art-favicon-url-normal-track-property ()
+ "Normal: a queued lookup station carries its favicon as a track property."
+ (let ((track (emms-track 'url "https://fp.example.net/live")))
+ (emms-track-set track 'radio-favicon "https://fp.example.net/icon.png")
+ (should (string= (cj/music-art--favicon-url track nil)
+ "https://fp.example.net/icon.png"))))
+
+(ert-deftest test-music-config--art-favicon-url-normal-property-beats-entries ()
+ "Normal: the track property wins over a conflicting entries favicon."
+ (let* ((url "https://fp2.example.net/live")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid nil
+ :favicon "https://entries.example/e.png"))))
+ (emms-track-set track 'radio-favicon "https://prop.example/p.png")
+ (should (string= (cj/music-art--favicon-url track entries)
+ "https://prop.example/p.png"))))
+
(provide 'test-music-config--art-favicon-url)
;;; test-music-config--art-favicon-url.el ends here
diff --git a/tests/test-music-config--m3u-text.el b/tests/test-music-config--m3u-text.el
new file mode 100644
index 00000000..14c9f2bf
--- /dev/null
+++ b/tests/test-music-config--m3u-text.el
@@ -0,0 +1,93 @@
+;;; test-music-config--m3u-text.el --- playlist .m3u emitter tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The custom .m3u emitter behind playlist save. The stock EMMS m3u writer
+;; emits bare URLs, which would throw away a station's name/uuid/favicon on
+;; save; this emitter writes the same #EXTINF / #RADIOBROWSERUUID /
+;; #RADIOBROWSERFAVICON lines the parser (`cj/music--m3u-entries') reads, so
+;; save -> load round-trips a station's display name and cover-art metadata.
+;; Metadata comes from track properties first, the m3u-scan entries as
+;; fallback (a loaded legacy playlist has entries but no properties).
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(declare-function cj/music--m3u-text "music-config" (tracks entries))
+(declare-function cj/music-radio--station-track "music-config" (st))
+
+(ert-deftest test-music-m3u-text-normal-file-track-bare-path ()
+ "Normal: a file track is a bare absolute path under the #EXTM3U header."
+ (let ((text (cj/music--m3u-text (list (emms-track 'file "/music/a.flac")) nil)))
+ (should (string-prefix-p "#EXTM3U\n" text))
+ (should (string-match-p "^/music/a\\.flac$" text))))
+
+(ert-deftest test-music-m3u-text-normal-url-track-from-properties ()
+ "Normal: a url track with properties emits uuid, favicon, EXTINF, and url."
+ (let* ((track (cj/music-radio--station-track
+ '(:name "Groove Salad" :url "https://ck.somafm.com/gs"
+ :stationuuid "uuid-1" :favicon "https://somafm.com/i.png")))
+ (text (cj/music--m3u-text (list track) nil)))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-1$" text))
+ (should (string-match-p "^#RADIOBROWSERFAVICON:https://somafm\\.com/i\\.png$" text))
+ (should (string-match-p "^#EXTINF:-1,Groove Salad$" text))
+ (should (string-match-p "^https://ck\\.somafm\\.com/gs$" text))))
+
+(ert-deftest test-music-m3u-text-normal-url-track-from-entries-fallback ()
+ "Normal: a propertyless url track (a loaded legacy playlist) resolves its
+metadata from the ENTRIES alist."
+ (let* ((url "https://legacy.example/stream")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "Legacy FM" :uuid "uuid-9"
+ :favicon "https://legacy.example/f.ico")))
+ (text (cj/music--m3u-text (list track) entries)))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-9$" text))
+ (should (string-match-p "^#EXTINF:-1,Legacy FM$" text))))
+
+(ert-deftest test-music-m3u-text-boundary-url-track-no-metadata ()
+ "Boundary: a url track with neither properties nor entries still gets an
+EXTINF label (the tidied host) and no uuid/favicon lines."
+ (let ((text (cj/music--m3u-text
+ (list (emms-track 'url "https://ice6.somafm.com/live")) nil)))
+ (should (string-match-p "^#EXTINF:-1,somafm\\.com$" text))
+ (should-not (string-match-p "RADIOBROWSERUUID" text))
+ (should-not (string-match-p "RADIOBROWSERFAVICON" text))))
+
+(ert-deftest test-music-m3u-text-normal-mixed-order-preserved ()
+ "Normal: a mixed queue keeps its track order in the file."
+ (let* ((f (emms-track 'file "/music/b.mp3"))
+ (u (cj/music-radio--station-track '(:name "S" :url "https://s.example/x")))
+ (text (cj/music--m3u-text (list f u) nil)))
+ (should (< (string-match "^/music/b\\.mp3$" text)
+ (string-match "^https://s\\.example/x$" text)))))
+
+(ert-deftest test-music-m3u-text-round-trip-through-parser ()
+ "Normal: the parser recovers name, uuid, and favicon from emitted text."
+ (let* ((track (cj/music-radio--station-track
+ '(:name "Round Trip" :url "https://rt.example/s"
+ :stationuuid "uuid-rt" :favicon "https://rt.example/f.png")))
+ (entries (cj/music--m3u-entries (cj/music--m3u-text (list track) nil)))
+ (meta (cdr (assoc "https://rt.example/s" entries))))
+ (should (equal (plist-get meta :name) "Round Trip"))
+ (should (equal (plist-get meta :uuid) "uuid-rt"))
+ (should (equal (plist-get meta :favicon) "https://rt.example/f.png"))))
+
+(ert-deftest test-music-m3u-text-boundary-empty-playlist-header-only ()
+ "Boundary: an empty track list is just the #EXTM3U header."
+ (should (equal (cj/music--m3u-text nil nil) "#EXTM3U\n")))
+
+(provide 'test-music-config--m3u-text)
+;;; test-music-config--m3u-text.el ends here
diff --git a/tests/test-music-config--radio-station-track.el b/tests/test-music-config--radio-station-track.el
new file mode 100644
index 00000000..5816c776
--- /dev/null
+++ b/tests/test-music-config--radio-station-track.el
@@ -0,0 +1,135 @@
+;;; test-music-config--radio-station-track.el --- station->track + enqueue tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The queue-first radio model: a picked station becomes an EMMS url track
+;; carrying its metadata as track properties (info-title, radio-uuid,
+;; radio-favicon) instead of being written to an .m3u. Covers the pure
+;; station->track builder and the enqueue-and-play buffer mechanics (with
+;; playback mocked at the EMMS boundary).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(declare-function cj/music-radio--station-track "music-config" (st))
+(declare-function cj/music-radio--enqueue-and-play "music-config" (tracks))
+
+;;; --------------------------- station-track -----------------------------------
+
+(ert-deftest test-music-radio-station-track-normal ()
+ "Normal: a full station plist yields a url track with all three properties."
+ (let ((track (cj/music-radio--station-track
+ '(:name "Adroit Jazz Underground"
+ :url_resolved "https://icecast.walmradio.com:8443/jazz"
+ :stationuuid "ea8059be-d119"
+ :favicon "https://walmradio.com/icon.png"))))
+ (should (eq (emms-track-type track) 'url))
+ (should (equal (emms-track-name track) "https://icecast.walmradio.com:8443/jazz"))
+ (should (equal (emms-track-get track 'info-title) "Adroit Jazz Underground"))
+ (should (equal (emms-track-get track 'radio-uuid) "ea8059be-d119"))
+ (should (equal (emms-track-get track 'radio-favicon) "https://walmradio.com/icon.png"))))
+
+(ert-deftest test-music-radio-station-track-no-url-is-nil ()
+ "Error: a station with no stream URL yields nil, not a broken track."
+ (should-not (cj/music-radio--station-track '(:name "No URL" :url_resolved "" :url ""))))
+
+(ert-deftest test-music-radio-station-track-boundary-no-name ()
+ "Boundary: a nameless station falls back to \"Radio\" for its title."
+ (let ((track (cj/music-radio--station-track '(:url "https://s.example/live"))))
+ (should (equal (emms-track-get track 'info-title) "Radio"))))
+
+(ert-deftest test-music-radio-station-track-boundary-newline-name-stripped ()
+ "Boundary: newlines in an external station name are flattened to spaces."
+ (let ((track (cj/music-radio--station-track
+ '(:name "Line\nBreak" :url "https://s.example/live"))))
+ (should (equal (emms-track-get track 'info-title) "Line Break"))))
+
+(ert-deftest test-music-radio-station-track-boundary-empty-uuid-favicon-absent ()
+ "Boundary: empty-string uuid/favicon are treated as absent, not stored."
+ (let ((track (cj/music-radio--station-track
+ '(:name "X" :url "https://s.example/live" :stationuuid "" :favicon ""))))
+ (should-not (emms-track-get track 'radio-uuid))
+ (should-not (emms-track-get track 'radio-favicon))))
+
+;;; ------------------------- enqueue-and-play ----------------------------------
+
+(defmacro test-music-radio--with-playlist (&rest body)
+ "Run BODY with a fresh, uniquely named playlist buffer and playback mocked."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-radio-enqueue*"))
+ (emms-player-playing-p nil)
+ (started 0) (stopped 0))
+ (unwind-protect
+ (cl-letf (((symbol-function 'emms-start)
+ (lambda () (setq started (1+ started))))
+ ((symbol-function 'emms-stop)
+ (lambda () (setq stopped (1+ stopped)))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-normal-appends-and-plays ()
+ "Normal: tracks land in the playlist buffer in order; the first is selected
+and playback starts."
+ (test-music-radio--with-playlist
+ (let ((t1 (cj/music-radio--station-track '(:name "One" :url "https://one.example/a")))
+ (t2 (cj/music-radio--station-track '(:name "Two" :url "https://two.example/b"))))
+ (cj/music-radio--enqueue-and-play (list t1 t2))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (let ((names '()))
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((tr (emms-playlist-track-at (point))))
+ (push (emms-track-name tr) names))
+ (forward-line 1)))
+ (should (equal (nreverse names)
+ '("https://one.example/a" "https://two.example/b"))))
+ (should (equal (emms-track-name (emms-playlist-selected-track))
+ "https://one.example/a")))
+ (should (= started 1)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-boundary-appends-after-existing ()
+ "Boundary: an existing queue is kept; new tracks append and the first NEW
+track is the one selected."
+ (test-music-radio--with-playlist
+ (let ((old (cj/music-radio--station-track '(:name "Old" :url "https://old.example/x")))
+ (new (cj/music-radio--station-track '(:name "New" :url "https://new.example/y"))))
+ (cj/music-radio--enqueue-and-play (list old))
+ (cj/music-radio--enqueue-and-play (list new))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (should (equal (emms-track-name (emms-playlist-selected-track))
+ "https://new.example/y"))))))
+
+(ert-deftest test-music-radio-enqueue-and-play-interrupts-current-playback ()
+ "Normal: when something is playing, enqueue stops it before starting."
+ (test-music-radio--with-playlist
+ (let ((emms-player-playing-p t)
+ (tr (cj/music-radio--station-track '(:name "Z" :url "https://z.example/s"))))
+ (cj/music-radio--enqueue-and-play (list tr))
+ (should (= stopped 1))
+ (should (= started 1)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-boundary-nil-is-no-op ()
+ "Boundary: an empty track list does nothing — no buffer churn, no playback."
+ (test-music-radio--with-playlist
+ (cj/music-radio--enqueue-and-play nil)
+ (should (= started 0))
+ (should (= stopped 0))))
+
+(provide 'test-music-config--radio-station-track)
+;;; test-music-config--radio-station-track.el ends here
diff --git a/tests/test-music-config--radio.el b/tests/test-music-config--radio.el
index 56a9c2dc..3923a599 100644
--- a/tests/test-music-config--radio.el
+++ b/tests/test-music-config--radio.el
@@ -3,11 +3,12 @@
;; 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
+;; The pure pieces behind the radio-browser search command (spec
+;; docs/specs/2026-07-06-radio-browser-lookup-spec.org): parsing a recorded
+;; JSON response, picking a station's stream URL, formatting the marginalia
+;; annotation (Variant B: codec/bitrate/country/votes/tags), and building the
+;; search URL. The station->track builder and the queue mechanics live in
+;; test-music-config--radio-station-track.el; the network GET and the
;; interactive command are exercised in the daemon, not here.
;;; Code:
@@ -22,10 +23,8 @@
(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
@@ -62,49 +61,20 @@
;;; --------------------------- station-url ------------------------------------
(ert-deftest test-music-radio-station-url-resolved ()
- "Normal: url_resolved is preferred."
+ "Normal: url_resolved wins when present."
(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."
+ "Boundary: an 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")))
+ '(:url_resolved "" :url "http://fallback.test/stream"))
+ "http://fallback.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 ""))))
-
-(ert-deftest test-music-radio-station-m3u-captures-favicon ()
- "Normal: a station with a favicon writes a #RADIOBROWSERFAVICON line so the
-cover-art layer needs no lookup later."
- (let ((m3u (cj/music-radio--station-m3u
- '(:name "Art Radio" :url_resolved "https://art.example/live"
- :stationuuid "u-art" :favicon "https://cdn.example/art.png"))))
- (should (string-match-p "#RADIOBROWSERFAVICON:https://cdn.example/art.png" m3u))))
-
-(ert-deftest test-music-radio-station-m3u-no-favicon-omits-line ()
- "Boundary: a station with an empty favicon writes no #RADIOBROWSERFAVICON line."
- (let ((m3u (cj/music-radio--station-m3u
- '(:name "Plain" :url_resolved "https://plain.example/live"
- :stationuuid "u-plain" :favicon ""))))
- (should-not (string-match-p "#RADIOBROWSERFAVICON" m3u))))
-
;;; --------------------------- tags-snippet -----------------------------------
(ert-deftest test-music-radio-tags-snippet-takes-first-n ()
@@ -126,21 +96,6 @@ cover-art layer needs no lookup later."
(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 ()
@@ -158,7 +113,6 @@ cover-art layer needs no lookup later."
(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))
;;; --------------------------- candidates (dedup) -----------------------------
@@ -180,41 +134,5 @@ cover-art layer needs no lookup later."
(should (= (length cands) 2))
(should (= (length (delete-dups (copy-sequence keys))) 2))))
-;;; --------------------------- write-stations ---------------------------------
-
-(ert-deftest test-music-radio-write-stations-writes-and-skips ()
- "Normal + Error: a station with a URL is written; one with no URL is skipped and named."
- (let ((dir (make-temp-file "radio-write-" t)))
- (unwind-protect
- (let* ((stations (list (test-music-radio--first)
- '(:name "No URL Here" :url_resolved "" :url "")))
- (result (cj/music-radio--write-stations stations dir)))
- (should (= (length (plist-get result :written)) 1))
- (should (member "No URL Here" (plist-get result :skipped)))
- (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)))
- (unwind-protect
- (let* ((stations '((:name "Same Name" :url_resolved "http://a.test/s" :stationuuid "aaaa1111")
- (:name "Same Name" :url_resolved "http://b.test/s" :stationuuid "bbbb2222")))
- (result (cj/music-radio--write-stations stations dir))
- (written (plist-get result :written)))
- (should (= (length written) 2))
- (should-not (equal (nth 0 written) (nth 1 written))))
- (delete-directory dir t))))
-
(provide 'test-music-config--radio)
;;; test-music-config--radio.el ends here
diff --git a/tests/test-music-config--safe-filename.el b/tests/test-music-config--safe-filename.el
deleted file mode 100644
index 8105ee15..00000000
--- a/tests/test-music-config--safe-filename.el
+++ /dev/null
@@ -1,97 +0,0 @@
-;;; test-music-config--safe-filename.el --- Tests for filename sanitization -*- coding: utf-8; lexical-binding: t; -*-
-;;
-;; Author: Craig Jennings <c@cjennings.net>
-;;
-;;; Commentary:
-;; Unit tests for cj/music--safe-filename function.
-;; Tests the pure helper that sanitizes filenames by replacing invalid chars.
-;;
-;; Test organization:
-;; - Normal Cases: Valid filenames unchanged, spaces replaced
-;; - Boundary Cases: Special chars, unicode, slashes, consecutive invalid chars
-;; - Error Cases: Nil input
-;;
-;;; Code:
-
-(require 'ert)
-
-;; Stub missing dependencies before loading music-config
-(defvar-keymap cj/custom-keymap
- :doc "Stub keymap for testing")
-
-;; Load production code
-(require 'music-config)
-
-;;; Normal Cases
-
-(ert-deftest test-music-config--safe-filename-normal-alphanumeric-unchanged ()
- "Validate alphanumeric filename remains unchanged."
- (should (string= (cj/music--safe-filename "MyPlaylist123")
- "MyPlaylist123")))
-
-(ert-deftest test-music-config--safe-filename-normal-with-hyphens-unchanged ()
- "Validate filename with hyphens remains unchanged."
- (should (string= (cj/music--safe-filename "my-playlist-name")
- "my-playlist-name")))
-
-(ert-deftest test-music-config--safe-filename-normal-with-underscores-unchanged ()
- "Validate filename with underscores remains unchanged."
- (should (string= (cj/music--safe-filename "my_playlist_name")
- "my_playlist_name")))
-
-(ert-deftest test-music-config--safe-filename-normal-spaces-replaced ()
- "Validate spaces are replaced with underscores."
- (should (string= (cj/music--safe-filename "My Favorite Songs")
- "My_Favorite_Songs")))
-
-;;; Boundary Cases
-
-(ert-deftest test-music-config--safe-filename-boundary-special-chars-replaced ()
- "Validate special characters are replaced with underscores."
- (should (string= (cj/music--safe-filename "playlist@#$%^&*()")
- "playlist_________")))
-
-(ert-deftest test-music-config--safe-filename-boundary-unicode-replaced ()
- "Validate unicode characters are replaced with underscores."
- (should (string= (cj/music--safe-filename "中文歌曲")
- "____")))
-
-(ert-deftest test-music-config--safe-filename-boundary-mixed-valid-invalid ()
- "Validate mixed valid and invalid characters."
- (should (string= (cj/music--safe-filename "Rock & Roll")
- "Rock___Roll")))
-
-(ert-deftest test-music-config--safe-filename-boundary-dots-replaced ()
- "Validate dots are replaced with underscores."
- (should (string= (cj/music--safe-filename "my.playlist.name")
- "my_playlist_name")))
-
-(ert-deftest test-music-config--safe-filename-boundary-slashes-replaced ()
- "Validate slashes are replaced with underscores."
- (should (string= (cj/music--safe-filename "folder/file")
- "folder_file")))
-
-(ert-deftest test-music-config--safe-filename-boundary-consecutive-invalid-chars ()
- "Validate consecutive invalid characters each become underscores."
- (should (string= (cj/music--safe-filename "test!!!name")
- "test___name")))
-
-(ert-deftest test-music-config--safe-filename-boundary-empty-string-unchanged ()
- "Validate empty string remains unchanged."
- (should (string= (cj/music--safe-filename "")
- "")))
-
-(ert-deftest test-music-config--safe-filename-boundary-only-invalid-chars ()
- "Validate string with only invalid characters becomes all underscores."
- (should (string= (cj/music--safe-filename "!@#$%")
- "_____")))
-
-;;; Error Cases
-
-(ert-deftest test-music-config--safe-filename-error-nil-input-signals-error ()
- "Validate nil input signals error."
- (should-error (cj/music--safe-filename nil)
- :type 'wrong-type-argument))
-
-(provide 'test-music-config--safe-filename)
-;;; test-music-config--safe-filename.el ends here
diff --git a/tests/test-music-config--save-helpers.el b/tests/test-music-config--save-helpers.el
new file mode 100644
index 00000000..ffb0a477
--- /dev/null
+++ b/tests/test-music-config--save-helpers.el
@@ -0,0 +1,90 @@
+;;; test-music-config--save-helpers.el --- save default-name + directory tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The pure helpers behind the queue-first save flow: which name the save
+;; prompt pre-fills (`cj/music--save-default-name') and which directory the
+;; file lands in (`cj/music--save-directory'). An all-stream queue saves into
+;; the radio playlist home (`cj/music-radio-save-dir'); anything else saves
+;; into `cj/music-m3u-root'.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(declare-function cj/music--save-default-name "music-config" (tracks file entries))
+(declare-function cj/music--save-directory "music-config" (tracks))
+(declare-function cj/music-radio--station-track "music-config" (st))
+
+;;; --------------------------- save-default-name -------------------------------
+
+(ert-deftest test-music-save-default-name-normal-associated-file-wins ()
+ "Normal: an associated playlist file names the save, station or not."
+ (let ((tr (cj/music-radio--station-track '(:name "S" :url "https://s.example/x"))))
+ (should (equal (cj/music--save-default-name (list tr) "/pl/jazz.m3u" nil)
+ "jazz"))))
+
+(ert-deftest test-music-save-default-name-normal-station-title ()
+ "Normal: with no file, the first url track's station name pre-fills."
+ (let ((tr (cj/music-radio--station-track
+ '(:name "Groove Salad" :url "https://gs.example/x"))))
+ (should (equal (cj/music--save-default-name (list tr) nil nil)
+ "Groove Salad"))))
+
+(ert-deftest test-music-save-default-name-normal-first-url-track-wins ()
+ "Normal: a file track ahead of the station doesn't block the station name;
+the first URL track with a name wins."
+ (let ((f (emms-track 'file "/music/a.flac"))
+ (tr (cj/music-radio--station-track
+ '(:name "Second Pick" :url "https://sp.example/x"))))
+ (should (equal (cj/music--save-default-name (list f tr) nil nil)
+ "Second Pick"))))
+
+(ert-deftest test-music-save-default-name-normal-entries-fallback ()
+ "Normal: a propertyless url track resolves its name from ENTRIES."
+ (let* ((url "https://legacy.example/s")
+ (tr (emms-track 'url url))
+ (entries (list (list url :name "Legacy FM" :uuid nil :favicon nil))))
+ (should (equal (cj/music--save-default-name (list tr) nil entries)
+ "Legacy FM"))))
+
+(ert-deftest test-music-save-default-name-boundary-no-candidates-nil ()
+ "Boundary: no file, no url tracks -> nil (caller falls back to a timestamp)."
+ (should-not (cj/music--save-default-name
+ (list (emms-track 'file "/music/a.flac")) nil nil))
+ (should-not (cj/music--save-default-name nil nil nil)))
+
+;;; ---------------------------- save-directory ---------------------------------
+
+(ert-deftest test-music-save-directory-normal-all-streams-radio-dir ()
+ "Normal: an all-stream queue saves into the radio playlist dir."
+ (let ((u1 (emms-track 'url "https://a.example/1"))
+ (u2 (emms-track 'url "https://b.example/2")))
+ (should (equal (cj/music--save-directory (list u1 u2))
+ cj/music-radio-save-dir))))
+
+(ert-deftest test-music-save-directory-normal-mixed-goes-to-m3u-root ()
+ "Normal: any non-stream track routes the save to the music playlist root."
+ (let ((u (emms-track 'url "https://a.example/1"))
+ (f (emms-track 'file "/music/a.flac")))
+ (should (equal (cj/music--save-directory (list u f))
+ cj/music-m3u-root))))
+
+(ert-deftest test-music-save-directory-boundary-empty-goes-to-m3u-root ()
+ "Boundary: an empty queue defaults to the music playlist root."
+ (should (equal (cj/music--save-directory nil) cj/music-m3u-root)))
+
+(provide 'test-music-config--save-helpers)
+;;; test-music-config--save-helpers.el ends here
diff --git a/tests/test-music-config-create-radio-station.el b/tests/test-music-config-create-radio-station.el
index 1f4365a4..21241965 100644
--- a/tests/test-music-config-create-radio-station.el
+++ b/tests/test-music-config-create-radio-station.el
@@ -1,153 +1,108 @@
-;;; test-music-config-create-radio-station.el --- Tests for radio station creation -*- coding: utf-8; lexical-binding: t; -*-
+;;; test-music-config-create-radio-station.el --- Tests for manual radio-station entry -*- coding: utf-8; lexical-binding: t; -*-
;;
;; Author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
-;; Unit tests for cj/music-create-radio-station function.
-;; Tests M3U file creation for radio stations with stream URLs.
+;; Unit tests for cj/music-create-radio-station under the queue-first model:
+;; a hand-entered name + URL becomes a url track in the playlist queue (with
+;; the name as its title property) and playback starts. Nothing is written
+;; to disk — saving is the normal playlist-save flow.
;;
;; Test organization:
-;; - Normal Cases: Standard creation, EXTM3U format, safe filename
-;; - Boundary Cases: Unicode name, complex URL, overwrite confirmed
-;; - Error Cases: Empty name, empty URL, overwrite declined
-;;
+;; - Normal Cases: track queued with title, playback started, no file written
+;; - Boundary Cases: unicode name preserved verbatim
+;; - Error Cases: empty name, empty URL
+
;;; Code:
(require 'ert)
-(require 'testutil-general)
+(require 'cl-lib)
;; Stub missing dependencies before loading music-config
(defvar-keymap cj/custom-keymap
:doc "Stub keymap for testing")
-;; Load production code
-(require 'music-config)
-
-;;; Setup & Teardown
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
-(defun test-music-config-create-radio-station-setup ()
- "Setup test environment with temp directory for M3U output."
- (cj/create-test-base-dir)
- (cj/create-test-subdirectory "radio-playlists"))
+(require 'emms)
+(require 'music-config)
-(defun test-music-config-create-radio-station-teardown ()
- "Clean up test environment."
- (cj/delete-test-base-dir))
+(defmacro test-music-create-radio--with-env (&rest body)
+ "Run BODY with a fresh playlist buffer, playback mocked, messages captured."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-create-radio*"))
+ (emms-player-playing-p nil)
+ (started 0) (msg nil))
+ (ignore started msg)
+ (unwind-protect
+ (cl-letf (((symbol-function 'emms-start)
+ (lambda () (setq started (1+ started))))
+ ((symbol-function 'emms-stop) #'ignore)
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (when fmt (setq msg (apply #'format fmt args))))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name)))))
+
+(defun test-music-create-radio--queued-tracks ()
+ "Track objects currently in the test playlist buffer."
+ (let ((tracks '()))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((tr (emms-playlist-track-at (point))))
+ (push tr tracks))
+ (forward-line 1))))
+ (nreverse tracks)))
;;; Normal Cases
-(ert-deftest test-music-config-create-radio-station-normal-creates-m3u-file ()
- "Creating a radio station produces an M3U file in the music root."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
- (let ((expected-file (expand-file-name "Jazz_FM_Radio.m3u" test-dir)))
- (should (file-exists-p expected-file))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-normal-extm3u-format ()
- "Created file contains EXTM3U header, EXTINF with station name, and URL."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
+(ert-deftest test-music-config-create-radio-station-normal-queues-track ()
+ "Normal: name+url queues a url track carrying the name, and playback starts."
+ (test-music-create-radio--with-env
+ (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
+ (let ((tracks (test-music-create-radio--queued-tracks)))
+ (should (= (length tracks) 1))
+ (should (eq (emms-track-type (car tracks)) 'url))
+ (should (equal (emms-track-name (car tracks)) "http://stream.jazzfm.com/radio"))
+ (should (equal (emms-track-get (car tracks) 'info-title) "Jazz FM")))
+ (should (= started 1))
+ (should (string-match-p "Jazz FM" msg))))
+
+(ert-deftest test-music-config-create-radio-station-normal-writes-no-file ()
+ "Normal: nothing lands on disk — saving is the playlist-save flow's job."
+ (let ((tmp (file-name-as-directory (make-temp-file "cj-radio-nofile-" t))))
(unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
- (let ((content (with-temp-buffer
- (insert-file-contents
- (expand-file-name "Jazz_FM_Radio.m3u" test-dir))
- (buffer-string))))
- (should (string-match-p "^#EXTM3U" content))
- (should (string-match-p "#EXTINF:-1,Jazz FM" content))
- (should (string-match-p "http://stream.jazzfm.com/radio" content))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-normal-safe-filename ()
- "Station name with special characters produces filesystem-safe filename."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Rock & Roll 101.5" "http://example.com/stream")
- ;; Spaces and special chars replaced with underscores
- (let ((expected-file (expand-file-name "Rock___Roll_101_5_Radio.m3u" test-dir)))
- (should (file-exists-p expected-file))))
- (test-music-config-create-radio-station-teardown))))
+ (test-music-create-radio--with-env
+ (let ((cj/music-m3u-root tmp)
+ (cj/music-radio-save-dir tmp))
+ (cj/music-create-radio-station "NPR" "https://example.test/stream")
+ (should-not (directory-files tmp nil "\\.m3u\\'"))))
+ (delete-directory tmp t))))
;;; Boundary Cases
-(ert-deftest test-music-config-create-radio-station-boundary-unicode-name-safe-filename ()
- "Unicode station name produces safe filename while preserving name in EXTINF."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Klassik Radio" "http://example.com/stream")
- ;; Name is all ASCII-safe, so filename uses it directly
- (should (file-exists-p (expand-file-name "Klassik_Radio_Radio.m3u" test-dir)))
- ;; Original name preserved in EXTINF inside the file
- (let ((content (with-temp-buffer
- (insert-file-contents
- (expand-file-name "Klassik_Radio_Radio.m3u" test-dir))
- (buffer-string))))
- (should (string-match-p "Klassik Radio" content))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-boundary-url-with-query-params ()
- "Complex URL with query parameters preserved in file content."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir)
- (url "https://stream.example.com/radio?format=mp3&quality=320&token=abc123"))
- (cj/music-create-radio-station "Test Radio" url)
- (let ((content (with-temp-buffer
- (insert-file-contents
- (expand-file-name "Test_Radio_Radio.m3u" test-dir))
- (buffer-string))))
- (should (string-match-p (regexp-quote url) content))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-boundary-overwrite-confirmed ()
- "Overwriting existing file when user confirms succeeds."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- ;; Create initial file
- (cj/music-create-radio-station "MyRadio" "http://old.url/stream")
- (let ((file (expand-file-name "MyRadio_Radio.m3u" test-dir)))
- (should (file-exists-p file))
- ;; Overwrite with user confirming
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) t)))
- (cj/music-create-radio-station "MyRadio" "http://new.url/stream"))
- ;; File should now contain new URL
- (let ((content (with-temp-buffer
- (insert-file-contents file)
- (buffer-string))))
- (should (string-match-p "http://new.url/stream" content))
- (should-not (string-match-p "http://old.url/stream" content)))))
- (test-music-config-create-radio-station-teardown))))
+(ert-deftest test-music-config-create-radio-station-boundary-unicode-name ()
+ "Boundary: a unicode name is kept verbatim on the track (no filename munging)."
+ (test-music-create-radio--with-env
+ (cj/music-create-radio-station "Café Del Mar ☕" "https://cafe.example/stream")
+ (should (equal (emms-track-get (car (test-music-create-radio--queued-tracks))
+ 'info-title)
+ "Café Del Mar ☕"))))
;;; Error Cases
(ert-deftest test-music-config-create-radio-station-error-empty-name-signals-user-error ()
- "Empty station name signals user-error."
- (should-error (cj/music-create-radio-station "" "http://example.com/stream")
- :type 'user-error))
+ "Error: empty name signals user-error."
+ (should-error (cj/music-create-radio-station "" "https://x") :type 'user-error))
(ert-deftest test-music-config-create-radio-station-error-empty-url-signals-user-error ()
- "Empty URL signals user-error."
- (should-error (cj/music-create-radio-station "Test Radio" "")
- :type 'user-error))
-
-(ert-deftest test-music-config-create-radio-station-error-overwrite-declined-signals-user-error ()
- "Declining overwrite signals user-error."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- ;; Create initial file
- (cj/music-create-radio-station "MyRadio" "http://old.url/stream")
- ;; Decline overwrite
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) nil)))
- (should-error (cj/music-create-radio-station "MyRadio" "http://new.url/stream")
- :type 'user-error)))
- (test-music-config-create-radio-station-teardown))))
+ "Error: empty URL signals user-error."
+ (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error))
(provide 'test-music-config-create-radio-station)
;;; test-music-config-create-radio-station.el ends here
diff --git a/tests/test-music-config-more-commands.el b/tests/test-music-config-more-commands.el
index c351c1f1..530aa379 100644
--- a/tests/test-music-config-more-commands.el
+++ b/tests/test-music-config-more-commands.el
@@ -9,7 +9,9 @@
;; cj/music-playlist-edit
;; cj/music-playlist-toggle
;; cj/music-playlist-show
-;; cj/music-create-radio-station
+;;
+;; cj/music-create-radio-station lives in
+;; test-music-config-create-radio-station.el.
;;; Code:
@@ -17,12 +19,21 @@
(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
(require 'music-config)
;; Top-level defvars so let-binds reach the dynamic var under lexical
;; scope.
(defvar cj/music-playlist-file nil)
-(defvar emms-source-playlist-ask-before-overwrite t)
;;; cj/music-playlist-load
@@ -56,28 +67,87 @@
;;; cj/music-playlist-save
-(ert-deftest test-music-playlist-save-writes-fresh-name ()
- "Normal: save with a fresh name writes via emms-playlist-save."
- (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-save-" t)))
- (cj/music-m3u-root tmp)
- saved-args msg)
- (unwind-protect
- (cl-letf (((symbol-function 'cj/music--get-m3u-basenames)
- (lambda () nil))
- ((symbol-function 'completing-read)
- (lambda (&rest _) "fresh"))
- ((symbol-function 'cj/music--ensure-playlist-buffer)
- (lambda () (current-buffer)))
- ((symbol-function 'emms-playlist-save)
- (lambda (fmt path) (setq saved-args (list fmt path))))
- ((symbol-function 'cj/music--sync-playlist-file) #'ignore)
- ((symbol-function 'message)
- (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
- (cj/music-playlist-save))
- (delete-directory tmp t))
- (should (equal (car saved-args) 'm3u))
- (should (string-match-p "fresh\\.m3u\\'" (cadr saved-args)))
- (should (string-match-p "Saved playlist" msg))))
+(defmacro test-music-save--with-env (&rest body)
+ "Run BODY with a fresh playlist buffer and temp save directories.
+Binds TMP-M3U and TMP-RADIO (both cleaned up) and captures completing-read's
+INITIAL argument in CR-INITIAL."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-save*"))
+ (tmp-m3u (file-name-as-directory (make-temp-file "cj-save-m3u-" t)))
+ (tmp-radio (file-name-as-directory (make-temp-file "cj-save-radio-" t)))
+ (cj/music-m3u-root tmp-m3u)
+ (cj/music-radio-save-dir tmp-radio)
+ (cr-initial 'unset))
+ (ignore cr-initial)
+ (unwind-protect
+ (cl-letf (((symbol-function 'message) #'ignore)
+ ((symbol-function 'completing-read)
+ (lambda (_prompt _coll &optional _pred _req initial _hist def &rest _)
+ (setq cr-initial initial)
+ (or initial def "fallback"))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name))
+ (delete-directory tmp-m3u t)
+ (delete-directory tmp-radio t))))
+
+(defun test-music-save--queue (tracks)
+ "Put TRACKS into the (fresh) test playlist buffer."
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (save-excursion
+ (goto-char (point-max))
+ (dolist (tr tracks)
+ (emms-playlist-insert-track tr)))))
+
+(ert-deftest test-music-playlist-save-station-queue-prefills-and-targets-radio-dir ()
+ "Normal: an all-stream queue pre-fills the station name and saves into the
+radio dir with the station metadata written."
+ (test-music-save--with-env
+ (let ((tr (emms-track 'url "https://gs.example/stream")))
+ (emms-track-set tr 'info-title "Groove Salad")
+ (emms-track-set tr 'radio-uuid "uuid-gs")
+ (test-music-save--queue (list tr)))
+ (cj/music-playlist-save)
+ (should (equal cr-initial "Groove Salad"))
+ (let ((file (expand-file-name "Groove Salad.m3u" tmp-radio)))
+ (should (file-exists-p file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (let ((text (buffer-string)))
+ (should (string-match-p "^#EXTINF:-1,Groove Salad$" text))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-gs$" text))
+ (should (string-match-p "^https://gs\\.example/stream$" text)))))
+ (should-not (directory-files tmp-m3u nil "\\.m3u\\'"))))
+
+(ert-deftest test-music-playlist-save-file-queue-targets-m3u-root-no-prefill ()
+ "Normal: a file queue saves into the music root with no station pre-fill."
+ (test-music-save--with-env
+ (test-music-save--queue (list (emms-track 'file "/music/a.flac")))
+ (cj/music-playlist-save)
+ (should-not cr-initial)
+ (should (= (length (directory-files tmp-m3u nil "\\.m3u\\'")) 1))
+ (should-not (directory-files tmp-radio nil "\\.m3u\\'"))))
+
+(ert-deftest test-music-playlist-save-associated-file-name-wins-over-station ()
+ "Normal: a queue with an associated playlist file defaults to that name,
+even when it contains stations."
+ (test-music-save--with-env
+ (let ((tr (emms-track 'url "https://gs.example/stream")))
+ (emms-track-set tr 'info-title "Groove Salad")
+ (test-music-save--queue (list tr)))
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (setq cj/music-playlist-file (expand-file-name "morning.m3u" tmp-radio)))
+ (cj/music-playlist-save)
+ (should-not cr-initial)
+ (should (file-exists-p (expand-file-name "morning.m3u" tmp-radio)))))
+
+(ert-deftest test-music-playlist-save-error-empty-name ()
+ "Error: an empty name at the prompt signals user-error, not a hidden .m3u."
+ (test-music-save--with-env
+ (test-music-save--queue (list (emms-track 'url "https://x.example/s")))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "")))
+ (should-error (cj/music-playlist-save) :type 'user-error))
+ (should-not (directory-files tmp-radio nil "m3u"))))
;;; cj/music-playlist-edit
@@ -138,36 +208,5 @@
(should (eq switched buf))
(should msg)))
-;;; cj/music-create-radio-station
-
-(ert-deftest test-music-create-radio-station-writes-m3u ()
- "Normal: with name+url, an EXTM3U-style file is written into music-m3u-root."
- (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-radio-" t)))
- (cj/music-m3u-root tmp)
- msg)
- (unwind-protect
- (progn
- (cl-letf (((symbol-function 'message)
- (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
- (cj/music-create-radio-station "NPR" "https://example.test/stream"))
- (let ((file (expand-file-name "NPR_Radio.m3u" tmp)))
- (should (file-exists-p file))
- (with-temp-buffer
- (insert-file-contents file)
- (let ((text (buffer-string)))
- (should (string-match-p "#EXTM3U" text))
- (should (string-match-p "NPR" text))
- (should (string-match-p "https://example.test/stream" text))))))
- (delete-directory tmp t))
- (should (string-match-p "Created radio station" msg))))
-
-(ert-deftest test-music-create-radio-station-rejects-empty-name ()
- "Error: an empty name is rejected with user-error."
- (should-error (cj/music-create-radio-station "" "https://x") :type 'user-error))
-
-(ert-deftest test-music-create-radio-station-rejects-empty-url ()
- "Error: an empty URL is rejected with user-error."
- (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error))
-
(provide 'test-music-config-more-commands)
;;; test-music-config-more-commands.el ends here