aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--assets/vinyl-placeholder.svg20
-rw-r--r--modules/music-config.el197
-rw-r--r--tests/test-music-config--art-cache-key.el54
-rw-r--r--tests/test-music-config--art-favicon-url.el52
-rw-r--r--tests/test-music-config--art-valid-image.el51
-rw-r--r--tests/test-music-config--m3u-entries.el67
-rw-r--r--tests/test-music-config--radio.el15
7 files changed, 434 insertions, 22 deletions
diff --git a/assets/vinyl-placeholder.svg b/assets/vinyl-placeholder.svg
new file mode 100644
index 00000000..cf01519f
--- /dev/null
+++ b/assets/vinyl-placeholder.svg
@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300" role="img" aria-label="Vinyl record placeholder">
+ <defs>
+ <radialGradient id="disc" cx="50%" cy="50%" r="50%">
+ <stop offset="0%" stop-color="#1a1a1a"/>
+ <stop offset="100%" stop-color="#000000"/>
+ </radialGradient>
+ </defs>
+ <circle cx="150" cy="150" r="148" fill="url(#disc)"/>
+ <g fill="none" stroke="#2b2b2b" stroke-width="1">
+ <circle cx="150" cy="150" r="138"/>
+ <circle cx="150" cy="150" r="126"/>
+ <circle cx="150" cy="150" r="114"/>
+ <circle cx="150" cy="150" r="102"/>
+ <circle cx="150" cy="150" r="90"/>
+ <circle cx="150" cy="150" r="78"/>
+ </g>
+ <circle cx="150" cy="150" r="52" fill="#d99a2b"/>
+ <circle cx="150" cy="150" r="52" fill="none" stroke="#a8741a" stroke-width="2"/>
+ <circle cx="150" cy="150" r="7" fill="#111111"/>
+</svg>
diff --git a/modules/music-config.el b/modules/music-config.el
index 109f496c..ea66df8e 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -816,40 +816,59 @@ unchanged, so a non-URL name shows as-is instead of erroring."
host))
url))
-(defun cj/music--m3u-labels (text)
- "Parse M3U TEXT into an alist of (STREAM-URL . #EXTINF-LABEL).
-A url line takes the label from the most recent #EXTINF; a url with no
-preceding #EXTINF is skipped, and other comment lines (a #RADIOBROWSERUUID)
-between the two are ignored."
- (let ((pending nil) (pairs '()))
- (dolist (line (split-string text "[\r\n]+" t) (nreverse pairs))
+(defun cj/music--m3u-entries (text)
+ "Parse M3U TEXT into an alist of (STREAM-URL . PLIST).
+Each PLIST carries :name (the #EXTINF label), :uuid (#RADIOBROWSERUUID), and
+:favicon (#RADIOBROWSERFAVICON), read from the comment lines preceding the url.
+A url with no #EXTINF is skipped; fields reset after each url so nothing leaks
+between stations."
+ (let ((name nil) (uuid nil) (favicon nil) (entries '()))
+ (dolist (line (split-string text "[\r\n]+" t) (nreverse entries))
(cond
((string-match "\\`#EXTINF:[^,]*,\\(.*\\)\\'" line)
- (setq pending (match-string 1 line)))
- ((string-prefix-p "#" line)) ; other comment — keep PENDING
- (t (when pending (push (cons line pending) pairs))
- (setq pending nil))))))
+ (setq name (match-string 1 line)))
+ ((string-match "\\`#RADIOBROWSERUUID:\\(.*\\)\\'" line)
+ (setq uuid (match-string 1 line)))
+ ((string-match "\\`#RADIOBROWSERFAVICON:\\(.*\\)\\'" line)
+ (setq favicon (match-string 1 line)))
+ ((string-prefix-p "#" line)) ; other comment
+ (t (when name
+ (push (cons line (list :name name :uuid uuid :favicon favicon))
+ entries))
+ (setq name nil uuid nil favicon nil))))))
+
+(defun cj/music--m3u-labels (text)
+ "Alist of (STREAM-URL . #EXTINF-LABEL) parsed from M3U TEXT.
+A thin projection of `cj/music--m3u-entries' onto the label field."
+ (mapcar (lambda (e) (cons (car e) (plist-get (cdr e) :name)))
+ (cj/music--m3u-entries text)))
-(defvar cj/music--radio-name-map-cache nil
- "Cached url->#EXTINF-label alist across `cj/music-m3u-roots'.
+(defvar cj/music--radio-metadata-cache nil
+ "Cached url->plist metadata (:name :uuid :favicon) across `cj/music-m3u-roots'.
Built lazily so a row render never re-scans disk; cleared by
`cj/music--refresh-radio-name-map' when a station is created or a playlist
loads.")
-(defun cj/music--radio-name-map ()
- "Alist of stream-url -> station label, unioned across all playlist roots.
-Reads each .m3u once and caches the result."
- (or cj/music--radio-name-map-cache
- (setq cj/music--radio-name-map-cache
+(defun cj/music--radio-metadata ()
+ "Alist of stream-url -> plist metadata, unioned across all playlist roots.
+Reads each .m3u once and caches the result; both name resolution and the
+cover-art layer read from it."
+ (or cj/music--radio-metadata-cache
+ (setq cj/music--radio-metadata-cache
(cl-loop for (_base . path) in (cj/music--get-m3u-files)
- append (cj/music--m3u-labels
+ append (cj/music--m3u-entries
(with-temp-buffer
(insert-file-contents path)
(buffer-string)))))))
+(defun cj/music--radio-name-map ()
+ "Alist of stream-url -> station label, derived from the cached metadata."
+ (mapcar (lambda (e) (cons (car e) (plist-get (cdr e) :name)))
+ (cj/music--radio-metadata)))
+
(defun cj/music--refresh-radio-name-map ()
- "Clear the cached radio name-map so the next render rebuilds it."
- (setq cj/music--radio-name-map-cache nil))
+ "Clear the cached radio metadata so the next render rebuilds it."
+ (setq cj/music--radio-metadata-cache nil))
(defun cj/music--display-name (track &optional name-map)
"Human display name for TRACK, name only (no duration).
@@ -1183,12 +1202,19 @@ Matches the existing radio files: #EXTM3U, an optional #RADIOBROWSERUUID line,
;; 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)))
+ (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--tags-snippet (tags n)
@@ -1388,6 +1414,133 @@ mpv (interrupting whatever was playing)."
(interactive "sRadio search (tag): ")
(cj/music-radio--search-and-play tag "tag"))
+;; ------------------------------- Cover art -----------------------------------
+;; A track maps to a local cover-image path: a cached favicon/album art, or a
+;; shipped vinyl placeholder. `cj/music-art--for-track' is non-blocking (it
+;; reads only the cache) so the row renderer can call it during redisplay;
+;; `cj/music-art--ensure' does the network fetch off the render path. The pure
+;; pieces (cache key, favicon URL, image validation) carry the tests; the fetch
+;; is a live smoke test. Consumed by the Phase 3 fancy render.
+
+(require 'image)
+
+(defvar cj/music-art-cache-dir
+ (expand-file-name "music-art/" (expand-file-name "data/" user-emacs-directory))
+ "Directory holding fetched or extracted cover art, keyed by station UUID or a
+file hash. Gitignored runtime state; `cj/music-clear-art-cache' empties it.")
+
+(defvar cj/music-art-placeholder
+ (expand-file-name "vinyl-placeholder.svg"
+ (expand-file-name "assets/" user-emacs-directory))
+ "Shipped vinyl-record placeholder shown when a track has no cover art.")
+
+(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."
+ (let ((name (emms-track-name track)))
+ (if (eq (emms-track-type track) 'url)
+ (let ((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."
+ (when (eq (emms-track-type track) 'url)
+ (let ((fav (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)
+ "Non-nil when DATA looks like a displayable image (a recognizable image
+header), so an empty body, an HTML error page, or a text response is rejected
+before it is cached."
+ (and (stringp data) (not (string-empty-p data))
+ (image-type-from-data data) t))
+
+(defun cj/music-art--cached-file (key)
+ "Return an existing cached art file for KEY (any extension), or nil."
+ (car (file-expand-wildcards
+ (expand-file-name (concat key ".*") cj/music-art-cache-dir))))
+
+(defun cj/music-art--file-cover (track)
+ "Return a sibling cover image (cover/folder/front .jpg/.jpeg/.png) next to a
+file TRACK, or nil. Embedded-tag art extraction is deferred (vNext)."
+ (when (eq (emms-track-type track) 'file)
+ (when-let ((dir (file-name-directory (emms-track-name track))))
+ (cl-loop for base in '("cover" "folder" "front")
+ thereis (cl-loop for ext in '("jpg" "jpeg" "png")
+ for f = (expand-file-name (concat base "." ext) dir)
+ when (file-exists-p f) return f)))))
+
+(defun cj/music-art--fetch-to-cache (url key)
+ "Fetch URL and, if it is a valid image, write it into the art cache under KEY.
+Returns the cached path, or nil on a failed or non-image response. Blocks on
+the network, so call it off the redisplay path. Only http/https URLs are
+fetched, so an external favicon field can't point the reader at a file:// or
+other-scheme resource."
+ (when-let* (((string-match-p "\\`https?://" url))
+ (data (cj/music-radio--http-get url))
+ ((cj/music-art--valid-image-p data)))
+ (make-directory cj/music-art-cache-dir t)
+ (let ((path (expand-file-name
+ (concat key "." (symbol-name (image-type-from-data data)))
+ cj/music-art-cache-dir))
+ (coding-system-for-write 'binary))
+ (with-temp-file path
+ (set-buffer-multibyte nil)
+ (insert data))
+ path)))
+
+(defun cj/music-art--byuuid-favicon (uuid)
+ "Look up station UUID via radio-browser byuuid and return its favicon URL, or
+nil. The fallback for a legacy station that carries a uuid but no captured
+favicon. Blocks on the network."
+ (when-let* ((body (cj/music-radio--http-get
+ (format "https://%s/json/stations/byuuid/%s"
+ cj/music-radio-server uuid)))
+ (stations (ignore-errors (cj/music-radio--parse-search body)))
+ (fav (plist-get (car stations) :favicon)))
+ (and (stringp fav) (not (string-empty-p fav)) fav)))
+
+(defun cj/music-art--for-track (track)
+ "Local cover-art path for TRACK, WITHOUT any network: an already-cached file,
+a sibling cover for a local file, else the vinyl placeholder. Never blocks, so
+the row renderer can call it during redisplay; `cj/music-art--ensure' does the
+fetch off the render path."
+ (let ((key (cj/music-art--cache-key track (cj/music--radio-metadata))))
+ (or (cj/music-art--cached-file key)
+ (cj/music-art--file-cover track)
+ cj/music-art-placeholder)))
+
+(defun cj/music-art--ensure (track)
+ "Fetch and cache TRACK's cover art if it is not cached yet. Blocks on the
+network, so call it off the redisplay path. Returns the cached path, or nil
+when there is nothing to fetch."
+ (let* ((entries (cj/music--radio-metadata))
+ (key (cj/music-art--cache-key track entries)))
+ (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)))
+ (and (stringp uuid) (not (string-empty-p uuid))
+ (cj/music-art--byuuid-favicon uuid))))))
+ (and fav (cj/music-art--fetch-to-cache fav key)))))))
+
+(defun cj/music-clear-art-cache ()
+ "Delete every cached cover-art file so art is re-fetched on next need."
+ (interactive)
+ (when (file-directory-p cj/music-art-cache-dir)
+ (dolist (f (directory-files cj/music-art-cache-dir t "\\`[^.]"))
+ (delete-file f)))
+ (message "Cleared music art cache: %s" cj/music-art-cache-dir))
+
;; 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).
diff --git a/tests/test-music-config--art-cache-key.el b/tests/test-music-config--art-cache-key.el
new file mode 100644
index 00000000..0ef82a21
--- /dev/null
+++ b/tests/test-music-config--art-cache-key.el
@@ -0,0 +1,54 @@
+;;; test-music-config--art-cache-key.el --- Tests for cover-art cache key -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music-art--cache-key': the stable cache-file basename for
+;; a track. A url with a known #RADIOBROWSERUUID keys on the uuid (so the same
+;; station shares one cached logo); any other url keys on a hash of its address;
+;; a file keys on a hash of its path.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "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)
+
+(ert-deftest test-music-config--art-cache-key-normal-uuid ()
+ "Normal: a url with a UUID in the entries keys on the UUID."
+ (let ((track (emms-track 'url "https://ck.somafm.com/gs"))
+ (entries '(("https://ck.somafm.com/gs" :name "GS" :uuid "uuid-42" :favicon nil))))
+ (should (string= (cj/music-art--cache-key track entries) "uuid-42"))))
+
+(ert-deftest test-music-config--art-cache-key-boundary-url-no-uuid ()
+ "Boundary: a url with no UUID keys on a stable url- hash, not the raw URL."
+ (let* ((url "https://ck2.example.net/live")
+ (track (emms-track 'url url))
+ (key (cj/music-art--cache-key track nil)))
+ (should (string-prefix-p "url-" key))
+ (should (string= key (concat "url-" (sha1 url))))))
+
+(ert-deftest test-music-config--art-cache-key-normal-file ()
+ "Normal: a file keys on a file- hash of its path."
+ (let* ((path "/music/Kind of Blue/01.flac")
+ (track (emms-track 'file path)))
+ (should (string= (cj/music-art--cache-key track nil)
+ (concat "file-" (sha1 path))))))
+
+(ert-deftest test-music-config--art-cache-key-boundary-empty-uuid-falls-to-hash ()
+ "Boundary: an empty-string UUID is treated as absent, so the url hashes."
+ (let* ((url "https://ck3.example.net/x")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid "" :favicon nil))))
+ (should (string= (cj/music-art--cache-key track entries)
+ (concat "url-" (sha1 url))))))
+
+(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
new file mode 100644
index 00000000..d9759ab3
--- /dev/null
+++ b/tests/test-music-config--art-favicon-url.el
@@ -0,0 +1,52 @@
+;;; test-music-config--art-favicon-url.el --- Tests for stream favicon URL -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music-art--favicon-url': the direct favicon image URL for
+;; a url track, taken from the #RADIOBROWSERFAVICON captured at station creation.
+;; A station with only a UUID resolves its favicon via a separate byuuid lookup
+;; (done in the impure orchestrator), so this pure helper returns nil there.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "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)
+
+(ert-deftest test-music-config--art-favicon-url-normal-captured ()
+ "Normal: a captured #RADIOBROWSERFAVICON is returned directly."
+ (let ((track (emms-track 'url "https://fav.somafm.com/gs"))
+ (entries '(("https://fav.somafm.com/gs"
+ :name "GS" :uuid "u1" :favicon "https://cdn.example/gs.png"))))
+ (should (string= (cj/music-art--favicon-url track entries)
+ "https://cdn.example/gs.png"))))
+
+(ert-deftest test-music-config--art-favicon-url-boundary-uuid-only ()
+ "Boundary: a station with a UUID but no captured favicon returns nil
+\(the byuuid lookup is the orchestrator's job)."
+ (let ((track (emms-track 'url "https://fav2.example.net/live"))
+ (entries '(("https://fav2.example.net/live"
+ :name "X" :uuid "u2" :favicon nil))))
+ (should (null (cj/music-art--favicon-url track entries)))))
+
+(ert-deftest test-music-config--art-favicon-url-boundary-empty-favicon ()
+ "Boundary: an empty-string favicon is treated as absent."
+ (let ((track (emms-track 'url "https://fav3.example.net/live"))
+ (entries '(("https://fav3.example.net/live" :name "X" :uuid "u3" :favicon ""))))
+ (should (null (cj/music-art--favicon-url track entries)))))
+
+(ert-deftest test-music-config--art-favicon-url-error-file-track ()
+ "Error: a file track has no stream favicon URL."
+ (let ((track (emms-track 'file "/music/x.flac")))
+ (should (null (cj/music-art--favicon-url track nil)))))
+
+(provide 'test-music-config--art-favicon-url)
+;;; test-music-config--art-favicon-url.el ends here
diff --git a/tests/test-music-config--art-valid-image.el b/tests/test-music-config--art-valid-image.el
new file mode 100644
index 00000000..c8de0eda
--- /dev/null
+++ b/tests/test-music-config--art-valid-image.el
@@ -0,0 +1,51 @@
+;;; test-music-config--art-valid-image.el --- Tests for fetched-image validation -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music-art--valid-image-p': recognize whether fetched bytes
+;; are actually a displayable image, so an empty body, an HTML error page served
+;; 200, or a text response is rejected before it lands in the cache. Detection
+;; is by image header (`image-type-from-data'), which works headless.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "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)
+
+(defconst test-art--png-1x1
+ (base64-decode-string
+ (concat "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
+ "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="))
+ "A minimal valid 1x1 PNG, as raw bytes.")
+
+(ert-deftest test-music-config--art-valid-image-normal-png ()
+ "Normal: real PNG bytes are recognized as a valid image."
+ (should (cj/music-art--valid-image-p test-art--png-1x1)))
+
+(ert-deftest test-music-config--art-valid-image-error-html ()
+ "Error: an HTML error page served 200 is not a valid image."
+ (should-not (cj/music-art--valid-image-p "<html><body>502 Bad Gateway</body></html>")))
+
+(ert-deftest test-music-config--art-valid-image-error-text ()
+ "Error: arbitrary text is not a valid image."
+ (should-not (cj/music-art--valid-image-p "this is not an image")))
+
+(ert-deftest test-music-config--art-valid-image-boundary-empty ()
+ "Boundary: an empty body is not a valid image."
+ (should-not (cj/music-art--valid-image-p "")))
+
+(ert-deftest test-music-config--art-valid-image-boundary-nil ()
+ "Boundary: nil is not a valid image."
+ (should-not (cj/music-art--valid-image-p nil)))
+
+(provide 'test-music-config--art-valid-image)
+;;; test-music-config--art-valid-image.el ends here
diff --git a/tests/test-music-config--m3u-entries.el b/tests/test-music-config--m3u-entries.el
new file mode 100644
index 00000000..1eaf1345
--- /dev/null
+++ b/tests/test-music-config--m3u-entries.el
@@ -0,0 +1,67 @@
+;;; test-music-config--m3u-entries.el --- Tests for #EXTINF/UUID/favicon parse -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--m3u-entries': parse .m3u text into an alist of
+;; (stream-url . plist), each plist carrying :name (the #EXTINF label), :uuid
+;; (#RADIOBROWSERUUID), and :favicon (#RADIOBROWSERFAVICON). This is the one
+;; pure parser both the name resolution (Phase 1) and the cover-art layer read.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "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)
+
+(ert-deftest test-music-config--m3u-entries-normal-name-only ()
+ "Normal: an #EXTINF + url pair yields :name with nil :uuid and :favicon."
+ (let* ((text "#EXTM3U\n#EXTINF:1,SomaFM Groove Salad\nhttps://ice6.somafm.com/gs\n")
+ (e (cdr (assoc "https://ice6.somafm.com/gs" (cj/music--m3u-entries text)))))
+ (should (equal (plist-get e :name) "SomaFM Groove Salad"))
+ (should (null (plist-get e :uuid)))
+ (should (null (plist-get e :favicon)))))
+
+(ert-deftest test-music-config--m3u-entries-normal-uuid-and-favicon ()
+ "Normal: UUID and favicon comment lines are captured onto the entry."
+ (let* ((text (concat "#EXTM3U\n#EXTINF:1,Jazz24\n"
+ "#RADIOBROWSERUUID:abc-123\n"
+ "#RADIOBROWSERFAVICON:https://cdn.example/jazz.png\n"
+ "https://jazz.example/live\n"))
+ (e (cdr (assoc "https://jazz.example/live" (cj/music--m3u-entries text)))))
+ (should (equal (plist-get e :name) "Jazz24"))
+ (should (equal (plist-get e :uuid) "abc-123"))
+ (should (equal (plist-get e :favicon) "https://cdn.example/jazz.png"))))
+
+(ert-deftest test-music-config--m3u-entries-normal-multiple-reset ()
+ "Normal: fields reset between stations (station B has no UUID leak from A)."
+ (let* ((text (concat "#EXTINF:1,A\n#RADIOBROWSERUUID:aaa\nhttps://a.example/1\n"
+ "#EXTINF:1,B\nhttps://b.example/2\n"))
+ (entries (cj/music--m3u-entries text))
+ (b (cdr (assoc "https://b.example/2" entries))))
+ (should (equal (plist-get b :name) "B"))
+ (should (null (plist-get b :uuid)))))
+
+(ert-deftest test-music-config--m3u-entries-boundary-url-without-extinf ()
+ "Boundary: a bare url with no #EXTINF is skipped."
+ (should (null (cj/music--m3u-entries "#EXTM3U\nhttps://plain.example/stream\n"))))
+
+(ert-deftest test-music-config--m3u-entries-boundary-empty ()
+ "Boundary: empty text yields nil."
+ (should (null (cj/music--m3u-entries ""))))
+
+(ert-deftest test-music-config--m3u-entries-boundary-comma-in-name ()
+ "Boundary: a comma inside the #EXTINF label is preserved."
+ (let* ((text "#EXTINF:1,Radio, the Good Kind\nhttps://x.example/s\n")
+ (e (cdr (assoc "https://x.example/s" (cj/music--m3u-entries text)))))
+ (should (equal (plist-get e :name) "Radio, the Good Kind"))))
+
+(provide 'test-music-config--m3u-entries)
+;;; test-music-config--m3u-entries.el ends here
diff --git a/tests/test-music-config--radio.el b/tests/test-music-config--radio.el
index 98368d76..56a9c2dc 100644
--- a/tests/test-music-config--radio.el
+++ b/tests/test-music-config--radio.el
@@ -90,6 +90,21 @@
"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 ()