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: 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-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/spot-marginalia.el b/spot-marginalia.el index 6157739..7f63713 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,120 @@ 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 (milliseconds) as \"M:SS\". +Return \"?\" when MS is nil." + (if ms + (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))) + (ht-get first 'name))) + +;;; Annotators + +(defun spot--annotate-album (cand) + "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)) + :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. +Popularity is prefixed with `★' and followers with `♥'." + (let ((data (get-text-property 0 'multi-data cand))) + (marginalia--fields + ((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. +The track number is prefixed with `#' and duration rendered as M:SS." + (let ((data (get-text-property 0 'multi-data cand))) + (marginalia--fields + ((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)) + :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)) + :truncate 8 :face 'spot-marginalia-type) + ((spot--annotation-field (ht-get* data 'album 'release_date)) + :truncate 10 :face 'spot-marginalia-date)))) + +(defun spot--annotate-playlist (cand) + "Annotate playlist CAND with track count prefixed by `#'." + (let ((data (get-text-property 0 'multi-data cand))) + (marginalia--fields + ((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. +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)) + :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. +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)) + :truncate 10 :face 'spot-marginalia-date) + ((spot--format-duration (ht-get data 'duration_ms)) + :truncate 7 :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/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 674345a..fe80d49 100644 --- a/spot-var.el +++ b/spot-var.el @@ -120,6 +120,33 @@ 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) + +(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/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..67770ea 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) @@ -15,6 +16,8 @@ (require 'spot-mode-line) (require 'spot-marginalia) (require 'spot-var) +(require 'spot-auth) +(require 'spot-generic-query) ;;; spot--alist-get-chain @@ -118,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 () @@ -210,6 +245,175 @@ (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)))) + + +;;; 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 `#'-prefixed 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-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, M:SS 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 "#1" ann)) + (should (string-match-p "Radiohead" ann)) + (should (string-match-p "Kid A" ann)) + (should (string-match-p "4:11" 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/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"))) + + +;;; 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