Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions spot-auth.el
Original file line number Diff line number Diff line change
Expand Up @@ -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)))))))
Comment on lines +83 to +91

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spot--start-refresh-timer starts a repeating timer whenever spot-refresh-interval is non-nil, but it doesn’t guard against non-positive/invalid interval values. If a user customizes the interval to 0 (or a negative number), this can cause a tight loop / excessive requests. Recommend adding a check like and (numberp spot-refresh-interval) (> spot-refresh-interval 0) (and otherwise skip starting the timer with a helpful message).

Copilot uses AI. Check for mistakes.

(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
29 changes: 27 additions & 2 deletions spot-generic-query.el
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""))
Expand All @@ -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

Expand All @@ -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))))
Expand Down
249 changes: 152 additions & 97 deletions spot-marginalia.el
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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

Expand Down
Loading
Loading