diff options
| -rw-r--r-- | assets/vinyl-placeholder.svg | 20 | ||||
| -rw-r--r-- | modules/music-config.el | 197 | ||||
| -rw-r--r-- | tests/test-music-config--art-cache-key.el | 54 | ||||
| -rw-r--r-- | tests/test-music-config--art-favicon-url.el | 52 | ||||
| -rw-r--r-- | tests/test-music-config--art-valid-image.el | 51 | ||||
| -rw-r--r-- | tests/test-music-config--m3u-entries.el | 67 | ||||
| -rw-r--r-- | tests/test-music-config--radio.el | 15 |
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 () |
