From 83b23a17bda407ea4c05f18f8f18e6e881996c58 Mon Sep 17 00:00:00 2001 From: Charlie Holland Date: Tue, 21 Apr 2026 00:50:13 -0400 Subject: [PATCH 1/6] feat: auto-refresh access token when spot-mode is enabled Access tokens expire after ~1h. Previously the user had to run `M-x spot-refresh` manually once per session to keep the client working; otherwise all Bearer-authenticated requests 401'd silently and commands stopped returning results. `spot-mode` now starts a periodic refresh timer and performs an immediate refresh on enable (when `spot-refresh-token` is set). Interval is configurable via `spot-refresh-interval' (default 3000s, below Spotify's 3600s token lifetime). Set to nil to opt out. Co-Authored-By: Claude Opus 4.7 (1M context) --- spot-auth.el | 24 ++++++++++++++++++++++++ spot-var.el | 8 ++++++++ spot.el | 15 ++++++++++----- test/spot-test.el | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/spot-auth.el b/spot-auth.el index 50197b2..546ef47 100644 --- a/spot-auth.el +++ b/spot-auth.el @@ -72,6 +72,30 @@ returned authorization code." ("Content-Length" . "0") ("Authorization" . ,(concat "Basic " (spot--b64-id-secret)))))) +(defvar spot--refresh-timer nil + "The periodic timer that refreshes the Spotify access token.") + +(defun spot--start-refresh-timer () + "Start the periodic access-token refresh timer. +Refreshes immediately if `spot-refresh-token' is set, then on +`spot-refresh-interval' thereafter. No-op when the interval is +nil or a timer is already running." + (when (and spot-refresh-interval (not spot--refresh-timer)) + (when spot-refresh-token + (spot-refresh)) + (setq spot--refresh-timer + (run-with-timer spot-refresh-interval + spot-refresh-interval + (lambda () + (when spot-refresh-token + (spot-refresh))))))) + +(defun spot--stop-refresh-timer () + "Stop the periodic access-token refresh timer." + (when spot--refresh-timer + (cancel-timer spot--refresh-timer) + (setq spot--refresh-timer nil))) + (provide 'spot-auth) ;;; spot-auth.el ends here diff --git a/spot-var.el b/spot-var.el index 674345a..1aaeb38 100644 --- a/spot-var.el +++ b/spot-var.el @@ -120,6 +120,14 @@ after a player action, even when using a callback function.") (defvar spot--request-timeout 10 "Timeout in seconds for Spotify API requests.") +(defcustom spot-refresh-interval 3000 + "Seconds between automatic access-token refreshes when `spot-mode' is on. +Spotify access tokens expire after 3600 seconds, so a value below +that keeps the token valid across the lifetime of an Emacs +session. Set to nil to disable automatic refresh." + :type '(choice (const :tag "Disabled" nil) (integer :tag "Seconds")) + :group 'spot) + (defvar spot--redirect-uri (url-hexify-string "https://spotify.com") "URL-encoded redirect URI for OAuth2 flow.") diff --git a/spot.el b/spot.el index f681654..aec1beb 100644 --- a/spot.el +++ b/spot.el @@ -73,23 +73,28 @@ (declare-function spot--teardown-marginalia "spot-marginalia") (declare-function spot--start-update-timer "spot-mode-line") (declare-function spot--stop-update-timer "spot-mode-line") +(declare-function spot--start-refresh-timer "spot-auth") +(declare-function spot--stop-refresh-timer "spot-auth") ;;;###autoload (define-minor-mode spot-mode "Global minor mode for the spot Spotify client. -Registers embark keymaps, marginalia annotators, and starts the -mode-line update timer when enabled. Cleanly removes all -integrations when disabled." +Registers embark keymaps, marginalia annotators, starts the +mode-line update timer, and starts a periodic access-token +refresh timer when enabled. Cleanly removes all integrations +when disabled." :global t :group 'spot (if spot-mode (progn (spot--setup-embark) (spot--setup-marginalia) - (spot--start-update-timer)) + (spot--start-update-timer) + (spot--start-refresh-timer)) (spot--teardown-embark) (spot--teardown-marginalia) - (spot--stop-update-timer))) + (spot--stop-update-timer) + (spot--stop-refresh-timer))) (provide 'spot) diff --git a/test/spot-test.el b/test/spot-test.el index c8e632e..5ee8e38 100644 --- a/test/spot-test.el +++ b/test/spot-test.el @@ -15,6 +15,7 @@ (require 'spot-mode-line) (require 'spot-marginalia) (require 'spot-var) +(require 'spot-auth) ;;; spot--alist-get-chain @@ -210,6 +211,38 @@ (should (assq 'track marginalia-annotators))) (spot-mode -1))) + +;;; spot--refresh-timer + +(ert-deftest spot-test-refresh-timer/enable-starts-timer () + "Enabling spot-mode starts the refresh timer." + (require 'spot) + (let ((spot-refresh-token nil)) ; avoid triggering an actual refresh + (unwind-protect + (progn + (spot-mode 1) + (should (timerp spot--refresh-timer))) + (spot-mode -1)))) + +(ert-deftest spot-test-refresh-timer/disable-stops-timer () + "Disabling spot-mode stops the refresh timer." + (require 'spot) + (let ((spot-refresh-token nil)) + (spot-mode 1) + (spot-mode -1) + (should-not spot--refresh-timer))) + +(ert-deftest spot-test-refresh-timer/nil-interval-skips-timer () + "Setting `spot-refresh-interval' to nil disables the refresh timer." + (require 'spot) + (let ((spot-refresh-interval nil) + (spot-refresh-token nil)) + (unwind-protect + (progn + (spot-mode 1) + (should-not spot--refresh-timer)) + (spot-mode -1)))) + (provide 'spot-test) ;;; spot-test.el ends here From 9b6fd1ffb4b31925254f3bd291d695192caf24eb Mon Sep 17 00:00:00 2001 From: Charlie Holland Date: Tue, 21 Apr 2026 00:50:48 -0400 Subject: [PATCH 2/6] feat(marginalia): use marginalia--fields for aligned, coloured annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite every annotator to use `marginalia--fields', giving: - Column alignment — marginalia's alignment pass lines up all annotations across candidates, and `:truncate N' on each field gives fixed-width columns with ellipsis on overflow. - Per-field faces — new `spot-marginalia-*' faces (artist, album, date, number, type, description) inheriting from marginalia's base faces so themes style them out of the box and users can override individually. - Drop the " <- " prefix and " || " separator in favour of `marginalia-separator', matching the style of every built-in marginalia annotator. - Drop the redundant name field — the candidate string already is the name (see `spot--propertize-items'). Add `spot--format-duration' and `spot--first-name' helpers; the latter replaces the `(or ... '(nil))' guard that would have errored on genuinely empty artist/narrator/author lists. Co-Authored-By: Claude Opus 4.7 (1M context) --- spot-marginalia.el | 230 ++++++++++++++++++++++++++------------------- test/spot-test.el | 63 +++++++++++++ 2 files changed, 196 insertions(+), 97 deletions(-) diff --git a/spot-marginalia.el b/spot-marginalia.el index 6157739..ed8fde7 100644 --- a/spot-marginalia.el +++ b/spot-marginalia.el @@ -20,12 +20,50 @@ ;;; Commentary: ;; Marginalia annotation functions for Spotify search result categories. +;; +;; Annotations are built with `marginalia--fields' so they render as +;; aligned columns with per-field truncation and faces, matching the +;; look of marginalia's built-in annotators. ;;; Code: (require 'ht) (require 'marginalia) +;;; Faces + +(defface spot-marginalia-artist + '((t :inherit marginalia-type)) + "Face for artist, publisher, narrator, and author fields." + :group 'spot) + +(defface spot-marginalia-album + '((t :inherit marginalia-value)) + "Face for album name references inside track annotations." + :group 'spot) + +(defface spot-marginalia-date + '((t :inherit marginalia-date)) + "Face for release-date fields." + :group 'spot) + +(defface spot-marginalia-number + '((t :inherit marginalia-number)) + "Face for numeric fields (counts, popularity, duration, track number)." + :group 'spot) + +(defface spot-marginalia-type + '((t :inherit marginalia-type)) + "Face for type fields (album_type, media_type)." + :group 'spot) + +(defface spot-marginalia-description + '((t :inherit marginalia-documentation)) + "Face for description fields." + :group 'spot) + +;;; Helpers + (defun spot--annotation-field (value) "Format VALUE as a string for marginalia annotations. Returns \"?\" for nil values, converts numbers with `number-to-string', @@ -40,103 +78,101 @@ and passes strings through unchanged." "Round NUM to two decimal places." (/ (round (* num 100)) 100.0)) -(defun spot--annotate-album (album) - "Annotate ALBUM with name, artist, release date, and track count." - (let ((data (get-text-property 0 'multi-data album))) - (concat - " <- " - (mapconcat - #'spot--annotation-field - `(,(ht-get* data 'name) - ,(ht-get* (nth 0 (or (ht-get* data 'artists) '(nil))) 'name) - ,(ht-get* data 'release_date) - ,(ht-get* data 'total_tracks)) - " || ")))) - -(defun spot--annotate-artist (artist) - "Annotate ARTIST with name, popularity, and follower count." - (let ((data (get-text-property 0 'multi-data artist))) - (concat - " <- " - (mapconcat - #'spot--annotation-field - `(,(ht-get* data 'name) - ,(ht-get* data 'popularity) - ,(ht-get* data 'followers 'total)) - " || ")))) - -(defun spot--annotate-track (track) - "Annotate TRACK with name, number, artist, duration, album, and date." - (let* ((data (get-text-property 0 'multi-data track)) - (duration-ms (ht-get* data 'duration_ms))) - (concat - " <- " - (mapconcat - #'spot--annotation-field - `(,(ht-get* data 'name) - ,(ht-get* data 'track_number) - ,(ht-get* (nth 0 (or (ht-get* data 'artists) '(nil))) 'name) - ,(when duration-ms - (number-to-string (spot--round-to-two-decimals (/ duration-ms 60000.0)))) - ,(ht-get* data 'album 'name) - ,(ht-get* data 'album 'album_type) - ,(ht-get* data 'album 'release_date)) - " || ")))) - -(defun spot--annotate-playlist (playlist) - "Annotate PLAYLIST with name and track count." - (let ((data (get-text-property 0 'multi-data playlist))) - (concat - " <- " - (mapconcat - #'spot--annotation-field - `(,(ht-get* data 'name) - ,(ht-get* data 'tracks 'total)) - " || ")))) - -(defun spot--annotate-show (show) - "Annotate SHOW with name, publisher, media type, episode count, and description." - (let ((data (get-text-property 0 'multi-data show))) - (concat - " <- " - (mapconcat - #'spot--annotation-field - `(,(ht-get* data 'name) - ,(ht-get* data 'publisher) - ,(ht-get* data 'media_type) - ,(ht-get* data 'total_episodes) - ,(ht-get* data 'description)) - " || ")))) - -(defun spot--annotate-episode (episode) - "Annotate EPISODE with name, release date, description, and duration." - (let* ((data (get-text-property 0 'multi-data episode)) - (duration-ms (ht-get* data 'duration_ms))) - (concat - " <- " - (mapconcat - #'spot--annotation-field - `(,(ht-get* data 'name) - ,(ht-get* data 'release_date) - ,(ht-get* data 'description) - ,(when duration-ms - (number-to-string (spot--round-to-two-decimals (/ duration-ms 60000.0))))) - " || ")))) - -(defun spot--annotate-audiobook (audiobook) - "Annotate AUDIOBOOK with name, publisher, narrator, author, and description." - (let* ((data (get-text-property 0 'multi-data audiobook)) - (desc (ht-get* data 'description))) - (concat - " <- " - (mapconcat - #'spot--annotation-field - `(,(ht-get* data 'name) - ,(ht-get* data 'publisher) - ,(ht-get* (nth 0 (or (ht-get* data 'narrators) '(nil))) 'name) - ,(ht-get* (nth 0 (or (ht-get* data 'authors) '(nil))) 'name) - ,(when desc (string-replace "\n" " " desc))) - " || ")))) +(defun spot--format-duration (ms) + "Format duration MS in milliseconds as a minutes string (e.g. \"3.25\"). +Return \"?\" when MS is nil." + (if ms + (number-to-string (spot--round-to-two-decimals (/ ms 60000.0))) + "?")) + +(defun spot--first-name (items) + "Return the `name' of the first element in ITEMS, or nil when empty." + (when-let* ((first (nth 0 items))) + (ht-get first 'name))) + +;;; Annotators + +(defun spot--annotate-album (cand) + "Annotate album CAND with artist, release date, and track count." + (let ((data (get-text-property 0 'multi-data cand))) + (marginalia--fields + ((spot--annotation-field (spot--first-name (ht-get data 'artists))) + :truncate 25 :face 'spot-marginalia-artist) + ((spot--annotation-field (ht-get data 'release_date)) + :width 10 :face 'spot-marginalia-date) + ((spot--annotation-field (ht-get data 'total_tracks)) + :width 5 :face 'spot-marginalia-number)))) + +(defun spot--annotate-artist (cand) + "Annotate artist CAND with popularity and follower count." + (let ((data (get-text-property 0 'multi-data cand))) + (marginalia--fields + ((spot--annotation-field (ht-get data 'popularity)) + :width 3 :face 'spot-marginalia-number) + ((spot--annotation-field (ht-get* data 'followers 'total)) + :width 10 :face 'spot-marginalia-number)))) + +(defun spot--annotate-track (cand) + "Annotate track CAND with number, artist, duration, album, type, and date." + (let ((data (get-text-property 0 'multi-data cand))) + (marginalia--fields + ((spot--annotation-field (ht-get data 'track_number)) + :width 3 :face 'spot-marginalia-number) + ((spot--annotation-field (spot--first-name (ht-get data 'artists))) + :truncate 25 :face 'spot-marginalia-artist) + ((spot--format-duration (ht-get data 'duration_ms)) + :width 6 :face 'spot-marginalia-number) + ((spot--annotation-field (ht-get* data 'album 'name)) + :truncate 30 :face 'spot-marginalia-album) + ((spot--annotation-field (ht-get* data 'album 'album_type)) + :width 8 :face 'spot-marginalia-type) + ((spot--annotation-field (ht-get* data 'album 'release_date)) + :width 10 :face 'spot-marginalia-date)))) + +(defun spot--annotate-playlist (cand) + "Annotate playlist CAND with track count." + (let ((data (get-text-property 0 'multi-data cand))) + (marginalia--fields + ((spot--annotation-field (ht-get* data 'tracks 'total)) + :width 5 :face 'spot-marginalia-number)))) + +(defun spot--annotate-show (cand) + "Annotate show CAND with publisher, media type, episode count, and description." + (let ((data (get-text-property 0 'multi-data cand))) + (marginalia--fields + ((spot--annotation-field (ht-get data 'publisher)) + :truncate 25 :face 'spot-marginalia-artist) + ((spot--annotation-field (ht-get data 'media_type)) + :width 8 :face 'spot-marginalia-type) + ((spot--annotation-field (ht-get data 'total_episodes)) + :width 5 :face 'spot-marginalia-number) + ((spot--annotation-field (ht-get data 'description)) + :truncate 60 :face 'spot-marginalia-description)))) + +(defun spot--annotate-episode (cand) + "Annotate episode CAND with release date, duration, and description." + (let ((data (get-text-property 0 'multi-data cand))) + (marginalia--fields + ((spot--annotation-field (ht-get data 'release_date)) + :width 10 :face 'spot-marginalia-date) + ((spot--format-duration (ht-get data 'duration_ms)) + :width 6 :face 'spot-marginalia-number) + ((spot--annotation-field (ht-get data 'description)) + :truncate 60 :face 'spot-marginalia-description)))) + +(defun spot--annotate-audiobook (cand) + "Annotate audiobook CAND with publisher, narrator, author, and description." + (let* ((data (get-text-property 0 'multi-data cand)) + (desc (ht-get data 'description))) + (marginalia--fields + ((spot--annotation-field (ht-get data 'publisher)) + :truncate 25 :face 'spot-marginalia-artist) + ((spot--annotation-field (spot--first-name (ht-get data 'narrators))) + :truncate 25 :face 'spot-marginalia-artist) + ((spot--annotation-field (spot--first-name (ht-get data 'authors))) + :truncate 25 :face 'spot-marginalia-artist) + ((spot--annotation-field (when desc (string-replace "\n" " " desc))) + :truncate 60 :face 'spot-marginalia-description)))) ;;; Register annotators diff --git a/test/spot-test.el b/test/spot-test.el index 5ee8e38..9ec2980 100644 --- a/test/spot-test.el +++ b/test/spot-test.el @@ -243,6 +243,69 @@ (should-not spot--refresh-timer)) (spot-mode -1)))) + +;;; Annotators + +(defun spot-test--cand (data) + "Return a propertized candidate carrying DATA as `multi-data'." + (propertize (or (ht-get data 'name) "cand") 'multi-data data)) + +(ert-deftest spot-test-annotate-album/contains-fields () + "Album annotation includes artist, release date, and track count." + (let* ((data (ht ('name "Kid A") + ('artists (list (ht ('name "Radiohead")))) + ('release_date "2000-10-02") + ('total_tracks 11))) + (ann (substring-no-properties + (spot--annotate-album (spot-test--cand data))))) + (should (string-match-p "Radiohead" ann)) + (should (string-match-p "2000-10-02" ann)) + (should (string-match-p "11" ann)) + (should-not (string-match-p " <- " ann)) + (should-not (string-match-p " || " ann)))) + +(ert-deftest spot-test-annotate-track/contains-fields () + "Track annotation includes number, artist, duration, album, type, and date." + (let* ((data (ht ('name "Everything In Its Right Place") + ('track_number 1) + ('duration_ms 251000) + ('artists (list (ht ('name "Radiohead")))) + ('album (ht ('name "Kid A") + ('album_type "album") + ('release_date "2000-10-02"))))) + (ann (substring-no-properties + (spot--annotate-track (spot-test--cand data))))) + (should (string-match-p "Radiohead" ann)) + (should (string-match-p "Kid A" ann)) + (should (string-match-p "4.18" ann)) + (should (string-match-p "album" ann)) + (should (string-match-p "2000-10-02" ann)))) + +(ert-deftest spot-test-annotate-audiobook/handles-empty-narrators () + "Audiobook annotation survives empty narrator and author lists." + (let* ((data (ht ('name "Book") + ('publisher "Publisher") + ('narrators '()) + ('authors '()) + ('description "line1\nline2"))) + (ann (substring-no-properties + (spot--annotate-audiobook (spot-test--cand data))))) + (should (string-match-p "Publisher" ann)) + (should (string-match-p "line1 line2" ann)) + (should-not (string-match-p "\n" ann)))) + +(ert-deftest spot-test-annotation-field/nil-returns-question-mark () + "Missing values render as \"?\"." + (should (equal (spot--annotation-field nil) "?"))) + +(ert-deftest spot-test-format-duration/nil-returns-question-mark () + "Duration formatter tolerates a nil input." + (should (equal (spot--format-duration nil) "?"))) + +(ert-deftest spot-test-format-duration/minutes () + "Duration formatter returns minutes rounded to two decimals." + (should (equal (spot--format-duration 251000) "4.18"))) + (provide 'spot-test) ;;; spot-test.el ends here From e2700343ca95a340c0a17a6a6d78f2e82a2053b9 Mon Sep 17 00:00:00 2001 From: Charlie Holland Date: Tue, 21 Apr 2026 01:02:27 -0400 Subject: [PATCH 3/6] feat(marginalia): prefix ambiguous numeric fields with symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare numbers in the annotation were hard to read — a 3-digit value could be popularity, track count, or track number depending on position. Prefix the ones that aren't self-evident: - `#' for counts: total_tracks, total_episodes, track_number - `★' for popularity (0–100) - `♥' for followers All three symbols render in default Emacs fonts on macOS, Linux, and Windows without requiring an icon font. Also: - Render duration as M:SS instead of N.NN minutes. The old format "4.18" looks like a time but actually means 4.18 minutes (= 4:11), which was actively confusing. - Humanize large counts with K/M/B suffixes via `spot--format-count' so follower counts like 2175423 render as "2.2M" instead of a raw digit string. Name fields (artist, album, publisher, etc.), dates, and type keywords are left unlabelled — they're already unambiguous. Co-Authored-By: Claude Opus 4.7 (1M context) --- spot-marginalia.el | 73 +++++++++++++++++++++++++++++----------------- test/spot-test.el | 37 ++++++++++++++++++----- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/spot-marginalia.el b/spot-marginalia.el index ed8fde7..7f63713 100644 --- a/spot-marginalia.el +++ b/spot-marginalia.el @@ -79,12 +79,26 @@ and passes strings through unchanged." (/ (round (* num 100)) 100.0)) (defun spot--format-duration (ms) - "Format duration MS in milliseconds as a minutes string (e.g. \"3.25\"). + "Format duration MS (milliseconds) as \"M:SS\". Return \"?\" when MS is nil." (if ms - (number-to-string (spot--round-to-two-decimals (/ ms 60000.0))) + (let* ((total (round (/ ms 1000.0))) + (minutes (/ total 60)) + (seconds (mod total 60))) + (format "%d:%02d" minutes seconds)) "?")) +(defun spot--format-count (n) + "Format count N with a K/M/B suffix for readability. +Values below 10000 render unchanged. Return \"?\" when N is nil." + (cond + ((null n) "?") + ((not (numberp n)) (format "%s" n)) + ((< n 10000) (number-to-string n)) + ((< n 1000000) (format "%dK" (/ n 1000))) + ((< n 1000000000) (format "%.1fM" (/ n 1000000.0))) + (t (format "%.1fB" (/ n 1000000000.0))))) + (defun spot--first-name (items) "Return the `name' of the first element in ITEMS, or nil when empty." (when-let* ((first (nth 0 items))) @@ -93,70 +107,75 @@ Return \"?\" when MS is nil." ;;; Annotators (defun spot--annotate-album (cand) - "Annotate album CAND with artist, release date, and track count." + "Annotate album CAND with artist, release date, and track count. +The track count is prefixed with `#' to disambiguate the bare integer." (let ((data (get-text-property 0 'multi-data cand))) (marginalia--fields ((spot--annotation-field (spot--first-name (ht-get data 'artists))) :truncate 25 :face 'spot-marginalia-artist) ((spot--annotation-field (ht-get data 'release_date)) - :width 10 :face 'spot-marginalia-date) - ((spot--annotation-field (ht-get data 'total_tracks)) - :width 5 :face 'spot-marginalia-number)))) + :truncate 10 :face 'spot-marginalia-date) + ((spot--format-count (ht-get data 'total_tracks)) + :format "#%s" :truncate 6 :face 'spot-marginalia-number)))) (defun spot--annotate-artist (cand) - "Annotate artist CAND with popularity and follower count." + "Annotate artist CAND with popularity and follower count. +Popularity is prefixed with `★' and followers with `♥'." (let ((data (get-text-property 0 'multi-data cand))) (marginalia--fields - ((spot--annotation-field (ht-get data 'popularity)) - :width 3 :face 'spot-marginalia-number) - ((spot--annotation-field (ht-get* data 'followers 'total)) - :width 10 :face 'spot-marginalia-number)))) + ((spot--format-count (ht-get data 'popularity)) + :format "★%s" :truncate 5 :face 'spot-marginalia-number) + ((spot--format-count (ht-get* data 'followers 'total)) + :format "♥%s" :truncate 8 :face 'spot-marginalia-number)))) (defun spot--annotate-track (cand) - "Annotate track CAND with number, artist, duration, album, type, and date." + "Annotate track CAND with number, artist, duration, album, type, and date. +The track number is prefixed with `#' and duration rendered as M:SS." (let ((data (get-text-property 0 'multi-data cand))) (marginalia--fields - ((spot--annotation-field (ht-get data 'track_number)) - :width 3 :face 'spot-marginalia-number) + ((spot--format-count (ht-get data 'track_number)) + :format "#%s" :truncate 5 :face 'spot-marginalia-number) ((spot--annotation-field (spot--first-name (ht-get data 'artists))) :truncate 25 :face 'spot-marginalia-artist) ((spot--format-duration (ht-get data 'duration_ms)) - :width 6 :face 'spot-marginalia-number) + :truncate 7 :face 'spot-marginalia-number) ((spot--annotation-field (ht-get* data 'album 'name)) :truncate 30 :face 'spot-marginalia-album) ((spot--annotation-field (ht-get* data 'album 'album_type)) - :width 8 :face 'spot-marginalia-type) + :truncate 8 :face 'spot-marginalia-type) ((spot--annotation-field (ht-get* data 'album 'release_date)) - :width 10 :face 'spot-marginalia-date)))) + :truncate 10 :face 'spot-marginalia-date)))) (defun spot--annotate-playlist (cand) - "Annotate playlist CAND with track count." + "Annotate playlist CAND with track count prefixed by `#'." (let ((data (get-text-property 0 'multi-data cand))) (marginalia--fields - ((spot--annotation-field (ht-get* data 'tracks 'total)) - :width 5 :face 'spot-marginalia-number)))) + ((spot--format-count (ht-get* data 'tracks 'total)) + :format "#%s" :truncate 6 :face 'spot-marginalia-number)))) (defun spot--annotate-show (cand) - "Annotate show CAND with publisher, media type, episode count, and description." + "Annotate show CAND with publisher, media type, episode count, and description. +The episode count is prefixed with `#'." (let ((data (get-text-property 0 'multi-data cand))) (marginalia--fields ((spot--annotation-field (ht-get data 'publisher)) :truncate 25 :face 'spot-marginalia-artist) ((spot--annotation-field (ht-get data 'media_type)) - :width 8 :face 'spot-marginalia-type) - ((spot--annotation-field (ht-get data 'total_episodes)) - :width 5 :face 'spot-marginalia-number) + :truncate 8 :face 'spot-marginalia-type) + ((spot--format-count (ht-get data 'total_episodes)) + :format "#%s" :truncate 6 :face 'spot-marginalia-number) ((spot--annotation-field (ht-get data 'description)) :truncate 60 :face 'spot-marginalia-description)))) (defun spot--annotate-episode (cand) - "Annotate episode CAND with release date, duration, and description." + "Annotate episode CAND with release date, duration, and description. +Duration is rendered as M:SS." (let ((data (get-text-property 0 'multi-data cand))) (marginalia--fields ((spot--annotation-field (ht-get data 'release_date)) - :width 10 :face 'spot-marginalia-date) + :truncate 10 :face 'spot-marginalia-date) ((spot--format-duration (ht-get data 'duration_ms)) - :width 6 :face 'spot-marginalia-number) + :truncate 7 :face 'spot-marginalia-number) ((spot--annotation-field (ht-get data 'description)) :truncate 60 :face 'spot-marginalia-description)))) diff --git a/test/spot-test.el b/test/spot-test.el index 9ec2980..1870da1 100644 --- a/test/spot-test.el +++ b/test/spot-test.el @@ -251,7 +251,7 @@ (propertize (or (ht-get data 'name) "cand") 'multi-data data)) (ert-deftest spot-test-annotate-album/contains-fields () - "Album annotation includes artist, release date, and track count." + "Album annotation includes artist, release date, and `#'-prefixed track count." (let* ((data (ht ('name "Kid A") ('artists (list (ht ('name "Radiohead")))) ('release_date "2000-10-02") @@ -260,12 +260,22 @@ (spot--annotate-album (spot-test--cand data))))) (should (string-match-p "Radiohead" ann)) (should (string-match-p "2000-10-02" ann)) - (should (string-match-p "11" ann)) + (should (string-match-p "#11" ann)) (should-not (string-match-p " <- " ann)) (should-not (string-match-p " || " ann)))) +(ert-deftest spot-test-annotate-artist/contains-fields () + "Artist annotation prefixes popularity with `★' and followers with `♥'." + (let* ((data (ht ('name "Radiohead") + ('popularity 87) + ('followers (ht ('total 2175423))))) + (ann (substring-no-properties + (spot--annotate-artist (spot-test--cand data))))) + (should (string-match-p "★87" ann)) + (should (string-match-p "♥2\\.2M" ann)))) + (ert-deftest spot-test-annotate-track/contains-fields () - "Track annotation includes number, artist, duration, album, type, and date." + "Track annotation includes `#'-number, artist, M:SS duration, album, type, and date." (let* ((data (ht ('name "Everything In Its Right Place") ('track_number 1) ('duration_ms 251000) @@ -275,9 +285,10 @@ ('release_date "2000-10-02"))))) (ann (substring-no-properties (spot--annotate-track (spot-test--cand data))))) + (should (string-match-p "#1" ann)) (should (string-match-p "Radiohead" ann)) (should (string-match-p "Kid A" ann)) - (should (string-match-p "4.18" ann)) + (should (string-match-p "4:11" ann)) (should (string-match-p "album" ann)) (should (string-match-p "2000-10-02" ann)))) @@ -302,9 +313,21 @@ "Duration formatter tolerates a nil input." (should (equal (spot--format-duration nil) "?"))) -(ert-deftest spot-test-format-duration/minutes () - "Duration formatter returns minutes rounded to two decimals." - (should (equal (spot--format-duration 251000) "4.18"))) +(ert-deftest spot-test-format-duration/mm-ss () + "Duration formatter renders milliseconds as M:SS." + (should (equal (spot--format-duration 251000) "4:11")) + (should (equal (spot--format-duration 5000) "0:05")) + (should (equal (spot--format-duration 3599000) "59:59"))) + +(ert-deftest spot-test-format-count/thresholds () + "Count formatter humanizes large numbers, leaves small ones bare." + (should (equal (spot--format-count nil) "?")) + (should (equal (spot--format-count 0) "0")) + (should (equal (spot--format-count 42) "42")) + (should (equal (spot--format-count 9999) "9999")) + (should (equal (spot--format-count 10000) "10K")) + (should (equal (spot--format-count 2175423) "2.2M")) + (should (equal (spot--format-count 1500000000) "1.5B"))) (provide 'spot-test) From 0e1cf93f774ca4ace6ac64ea5a676b40618cfa31 Mon Sep 17 00:00:00 2001 From: Charlie Holland Date: Tue, 21 Apr 2026 01:33:38 -0400 Subject: [PATCH 4/6] docs: document search arguments and field filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ` -- --key=value' syntax supported by `spot--parse-command' was entirely undocumented, so no one could discover useful knobs like `--limit' or `--market'. Add a "Search arguments" subsection under Usage/Search that: - Lists the four safe URL parameters (limit, market, offset, include_external) and their valid ranges. - Flags that `--type=' collides with the internally-hardcoded type set and should not be used — narrow via the per-type keys instead. - Documents the Spotify field filters (artist:, album:, year:, genre:, tag:, isrc:, upc:) and clarifies they belong inside the query string, not after the ` -- ' separator. - Notes that invalid argument values surface as empty result sets because the API returns 400 and spot currently swallows that. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 836a8e3..5d6e2a9 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,36 @@ To refresh an expired token: `M-x spot-refresh`. | `e` | Episodes | | `b` | Audiobooks | +#### Search arguments + +Pass Spotify Search API parameters after a ` -- ` separator, in `--key=value` form: + +``` +j dilla -- --limit=50 --market=US +``` + +| Argument | Effect | +|----------|--------| +| `--limit=N` | Max results per type (1–50, default 20) | +| `--market=CC` | Restrict to a country catalog (ISO 3166-1 alpha-2, e.g. `US`, `GB`, `JP`) | +| `--offset=N` | Pagination offset (0–1000) | +| `--include_external=audio` | Include externally-hosted audio | + +Values outside the valid range (e.g. `--limit=200`) are rejected by the Spotify API and surface as an empty result set. + +Do not pass `--type=`; spot hardcodes the type parameter internally to enable multi-source search. To narrow to a single type, use the narrowing keys above. + +The `q=` parameter itself also accepts [Spotify field filters](https://developer.spotify.com/documentation/web-api/reference/search) — these go **before** the ` -- ` separator since they are part of the query string, not URL parameters: + +``` +artist:Radiohead year:2000-2010 -- --market=GB --limit=50 +album:"Kid A" +genre:jazz year:1960-1970 +tag:new +``` + +Supported field filters include `artist:`, `album:`, `year:` (single or range), `genre:`, `tag:hipster`, `tag:new`, `isrc:`, `upc:`. + ### Embark actions Press `embark-act` (default `C-.`) on any search result: From d5ea8f001b37e83a7fa06ec3d342c2885992879a Mon Sep 17 00:00:00 2001 From: Charlie Holland Date: Tue, 21 Apr 2026 01:44:50 -0400 Subject: [PATCH 5/6] fix: surface Spotify API errors instead of silently returning nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spot-request' and `spot-request-async' used to ignore the HTTP status entirely — any 4xx/5xx was parsed as JSON, walked by `spot--union-search-items' and friends, and produced an empty result set with no user-visible feedback. Passing an out-of-range argument like `--limit=200' to `spot-consult-search' would return zero results with no explanation; the same held for 401 after a token revocation, 403 from missing scopes, etc. Add `spot--report-response-error' which, inside a `url-retrieve' response buffer, `message's `spot: ' when the status is outside 200–299, extracting the human-readable message from Spotify's standard `{"error":{"message":...}}' body shape and falling back to a generic "request failed" for non-JSON bodies. Call it from the sync helper, the non-`parse-json' path of `spot-request', and the async helper's success branch. The existing buffers returned to callers are unchanged, so no downstream logic needs to adapt — silent no-op paths (e.g. an empty mode-line update) continue to work. Co-Authored-By: Claude Opus 4.7 (1M context) --- spot-generic-query.el | 29 +++++++++++++++++++++-- test/spot-test.el | 53 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/spot-generic-query.el b/spot-generic-query.el index 1f8e866..969f9f7 100644 --- a/spot-generic-query.el +++ b/spot-generic-query.el @@ -28,13 +28,35 @@ (require 'json) (require 'url) +(require 'spot-util) (require 'spot-var) (defvar url-http-end-of-headers) +(defvar url-http-response-status) + +(defun spot--report-response-error () + "If the current response buffer is non-2xx, `message' the error. +Must be called inside the response buffer of a `url-retrieve' or +`url-retrieve-synchronously'. The message includes the HTTP +status and, when the body is Spotify's standard error shape +\({\"error\":{\"message\":...}\}), the server's error message." + (when (and (boundp 'url-http-response-status) + url-http-response-status + (not (and (>= url-http-response-status 200) + (<= url-http-response-status 299)))) + (let* ((body (decode-coding-region (+ 1 url-http-end-of-headers) + (point-max) 'utf-8 t)) + (json (and (not (string= body "")) + (ignore-errors (json-read-from-string body)))) + (msg (spot--alist-get-chain '(error message) json))) + (message "spot: %d %s" + url-http-response-status + (or msg "request failed"))))) (defun spot-retrieve-url-to-alist-synchronously (url) "Return alist representation of JSON response from URL." (with-current-buffer (url-retrieve-synchronously url nil nil spot--request-timeout) + (spot--report-response-error) (let ((json (decode-coding-region (+ 1 url-http-end-of-headers) (point-max) 'utf-8 t))) (when (not (string= json "")) @@ -54,8 +76,10 @@ headers, and DATA is the request body." (if parse-json (spot-retrieve-url-to-alist-synchronously (concat url q-params)) - (url-retrieve-synchronously - (concat url q-params) nil nil spot--request-timeout)))) + (let ((buffer (url-retrieve-synchronously + (concat url q-params) nil nil spot--request-timeout))) + (with-current-buffer buffer (spot--report-response-error)) + buffer)))) ;; Async @@ -66,6 +90,7 @@ headers, and DATA is the request body." (lambda (status) (if (plist-get status :error) (message "spot: request failed: %s" (cdr (plist-get status :error))) + (spot--report-response-error) (let ((json (decode-coding-region (+ 1 url-http-end-of-headers) (point-max) 'utf-8 t))) (funcall callback json)))) diff --git a/test/spot-test.el b/test/spot-test.el index 1870da1..9d02601 100644 --- a/test/spot-test.el +++ b/test/spot-test.el @@ -8,6 +8,7 @@ ;;; Code: +(require 'cl-lib) (require 'ert) (require 'ht) (require 'spot-util) @@ -16,6 +17,7 @@ (require 'spot-marginalia) (require 'spot-var) (require 'spot-auth) +(require 'spot-generic-query) ;;; spot--alist-get-chain @@ -329,6 +331,57 @@ (should (equal (spot--format-count 2175423) "2.2M")) (should (equal (spot--format-count 1500000000) "1.5B"))) + +;;; spot--report-response-error + +(defmacro spot-test--with-captured-message (var &rest body) + "Execute BODY with `message' stubbed; bind its formatted output to VAR." + (declare (indent 1)) + `(let (,var) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) + (setq ,var (apply #'format fmt args))))) + ,@body))) + +(defun spot-test--fake-response (status body) + "Populate current buffer to mimic a `url-retrieve' response buffer. +STATUS is the HTTP status integer, BODY is the response body +string. Sets `url-http-end-of-headers' and +`url-http-response-status' buffer-locally, matching how url.el +stores them in real response buffers." + (insert (format "HTTP/1.1 %d Status\nContent-Type: application/json\n\n" + status)) + (setq-local url-http-end-of-headers (1- (point))) + (insert body) + (setq-local url-http-response-status status)) + +(ert-deftest spot-test-report-response-error/4xx-messages-error () + "A non-2xx response with Spotify error shape is reported via `message'." + (spot-test--with-captured-message captured + (with-temp-buffer + (spot-test--fake-response + 400 "{\"error\":{\"status\":400,\"message\":\"Invalid limit\"}}") + (spot--report-response-error)) + (should (string-match-p "400" captured)) + (should (string-match-p "Invalid limit" captured)))) + +(ert-deftest spot-test-report-response-error/2xx-is-silent () + "A 2xx response does not emit any message." + (spot-test--with-captured-message captured + (with-temp-buffer + (spot-test--fake-response 200 "") + (spot--report-response-error)) + (should-not captured))) + +(ert-deftest spot-test-report-response-error/non-json-body-falls-back () + "A non-JSON error body falls back to a generic \"request failed\" message." + (spot-test--with-captured-message captured + (with-temp-buffer + (spot-test--fake-response 500 "oops") + (spot--report-response-error)) + (should (string-match-p "500" captured)) + (should (string-match-p "request failed" captured)))) + (provide 'spot-test) ;;; spot-test.el ends here From aa2ded2c383d31e4b05ccfd7dea1256162d5c412 Mon Sep 17 00:00:00 2001 From: Charlie Holland Date: Tue, 21 Apr 2026 02:29:48 -0400 Subject: [PATCH 6/6] fix: truncate very wide candidates so annotations don't render off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marginalia aligns annotations to column `marginalia--cand-width-max', which grows monotonically as it observes candidate widths — it never shrinks. Once the max exceeds the frame width, annotations are drawn past the right edge and clipped. Spotify can return genuinely long candidate names (verbose podcast episode titles in particular), and `orderless-annotation' filters compound the problem: when a component like `@' is present in the filter, orderless asks marginalia's affixation function for the annotation of *every* candidate rather than only the ones currently displayed, so a single pathological episode pushes the alignment column past the edge for everything else. Truncate candidate names via `spot--truncate-name' in `spot--propertize-items' before marginalia ever sees them. The full name stays in the `multi-data' text property, so embark actions, marginalia annotators, and consult's own lookup all see unchanged data. Width is tunable via the new `spot-candidate-max-width' defcustom (default 60 columns; set to nil to disable). Co-Authored-By: Claude Opus 4.7 (1M context) --- spot-util.el | 18 ++++++++++++++++-- spot-var.el | 19 +++++++++++++++++++ test/spot-test.el | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/spot-util.el b/spot-util.el index c9228c4..822f625 100644 --- a/spot-util.el +++ b/spot-util.el @@ -27,6 +27,18 @@ (require 'ht) (require 'dash) +(require 'spot-var) + +(defun spot--truncate-name (name) + "Return NAME truncated to `spot-candidate-max-width' columns. +A trailing ellipsis marks truncation. When `spot-candidate-max-width' +is nil or NAME fits, NAME is returned unchanged." + (if (and spot-candidate-max-width + (stringp name) + (> (string-width name) spot-candidate-max-width)) + (truncate-string-to-width name spot-candidate-max-width 0 nil "…") + name)) + (defun spot--alist-get-chain (symbols alist) "Look up the value for the chain of SYMBOLS in ALIST. Recursively follows the chain of keys to retrieve nested values @@ -52,11 +64,13 @@ Handles nested alists and arrays recursively." (defun spot--propertize-items (tables) "Propertize a list of hash TABLES for display in completion. -Each table is expected to have `name' and `type' keys." +Each table is expected to have `name' and `type' keys. Names are +truncated for display per `spot-candidate-max-width'; the full +name remains accessible via `multi-data'." (-map (lambda (table) (propertize - (ht-get table 'name) + (spot--truncate-name (ht-get table 'name)) 'category (intern (ht-get table 'type)) 'multi-data table)) tables)) diff --git a/spot-var.el b/spot-var.el index 1aaeb38..fe80d49 100644 --- a/spot-var.el +++ b/spot-var.el @@ -128,6 +128,25 @@ session. Set to nil to disable automatic refresh." :type '(choice (const :tag "Disabled" nil) (integer :tag "Seconds")) :group 'spot) +(defcustom spot-candidate-max-width 60 + "Maximum display width (in columns) of search candidate strings. +Candidates longer than this are truncated with an ellipsis. The +full name remains available via the `multi-data' text property, so +embark actions and annotators are unaffected. + +This exists because very wide candidates inflate marginalia's +`marginalia--cand-width-max' — which only grows, never shrinks — +and once it exceeds the frame width the annotation column is +drawn past the right edge and clipped. The interaction is +particularly easy to trigger with `orderless-annotation' filters, +because orderless then asks for annotations on every candidate +rather than only the ones currently displayed. + +Set to nil to disable truncation." + :type '(choice (const :tag "No truncation" nil) + (integer :tag "Columns")) + :group 'spot) + (defvar spot--redirect-uri (url-hexify-string "https://spotify.com") "URL-encoded redirect URI for OAuth2 flow.") diff --git a/test/spot-test.el b/test/spot-test.el index 9d02601..67770ea 100644 --- a/test/spot-test.el +++ b/test/spot-test.el @@ -121,6 +121,38 @@ (should (= (length (spot--filter candidates "artist")) 0)))) +;;; spot--truncate-name + +(ert-deftest spot-test-truncate-name/under-limit () + "Names shorter than the limit pass through unchanged." + (let ((spot-candidate-max-width 20)) + (should (equal (spot--truncate-name "Short name") "Short name")))) + +(ert-deftest spot-test-truncate-name/over-limit () + "Names longer than the limit are truncated with an ellipsis." + (let* ((spot-candidate-max-width 10) + (result (spot--truncate-name "A much longer name that overflows"))) + (should (<= (string-width result) 10)) + (should (string-suffix-p "…" result)))) + +(ert-deftest spot-test-truncate-name/nil-disables () + "Setting the limit to nil returns the name unchanged." + (let ((spot-candidate-max-width nil) + (long (make-string 200 ?a))) + (should (equal (spot--truncate-name long) long)))) + +(ert-deftest spot-test-propertize-items/truncates-long-names () + "Long candidate names are truncated; `multi-data' keeps the full name." + (let* ((spot-candidate-max-width 10) + (full-name (make-string 50 ?x)) + (table (ht ('name full-name) ('type "track"))) + (cands (spot--propertize-items (list table))) + (cand (car cands))) + (should (<= (string-width cand) 10)) + (should (equal (ht-get (get-text-property 0 'multi-data cand) 'name) + full-name)))) + + ;;; spot-mode-line-string (ert-deftest spot-test-mode-line-string/no-track ()