diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-06 17:14:58 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-06 17:14:58 -0500 |
| commit | e74bb871ac0aa7ab6e63cd39759b8850f1181ffe (patch) | |
| tree | b974964c460dbba843ad2a8f891e360b9e31824e /tests | |
| parent | 0a7afa70fa9c2c20771696b303c9cae4aa7c58a2 (diff) | |
| download | dotemacs-e74bb871ac0aa7ab6e63cd39759b8850f1181ffe.tar.gz dotemacs-e74bb871ac0aa7ab6e63cd39759b8850f1181ffe.zip | |
feat(music): cover-art fetch and cache for the player (fancy UI phase 2)
Phase 2 gives each track a local cover-image path so phase 3's GUI has art to draw. A radio station uses its logo, a local file a sibling cover image, and anything without either falls back to a shipped vinyl placeholder.
I consolidated the .m3u parse into one richer cj/music--m3u-entries that reads :name, :uuid, and :favicon per station. Phase 1's cj/music--m3u-labels is now a thin projection of it, so names and art share a single cached disk read. New stations capture their favicon into a #RADIOBROWSERFAVICON line at creation, so most need no lookup later. A legacy station with only a UUID resolves its favicon through a byuuid call.
The render path never touches the network. cj/music-art--for-track reads only the cache and returns the placeholder until art exists. cj/music-art--ensure does the blocking fetch off that path. A fetched response is validated as an actual image before it's cached, so an HTML error page or an empty body becomes the placeholder, not a poisoned cache entry. Only http and https URLs are fetched, so an external favicon field can't reach a file:// resource. Art lands under data/music-art/, keyed by UUID or a file hash. cj/music-clear-art-cache empties it, with no automatic expiry.
The pure helpers carry the tests: the parser, the cache key, the favicon URL, and the image validation, each with Normal, Boundary, and Error cases. The fetch, the byuuid lookup, and the placeholder fallback are verified live against a real station. Local files use a sibling cover image for now. Embedded-tag extraction is deferred. The full suite is green.
Diffstat (limited to 'tests')
| -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 |
5 files changed, 239 insertions, 0 deletions
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 () |
