feat: auto-refresh access token and polished marginalia annotations#5
Conversation
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) <noreply@anthropic.com>
…tions 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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds automatic Spotify access-token refreshing when spot-mode is enabled, and modernizes marginalia annotations to use marginalia--fields for aligned, face-aware columns.
Changes:
- Start/stop a periodic access-token refresh timer with
spot-mode(with configurable interval). - Introduce
spot-refresh-intervalcustomization for controlling auto-refresh behavior. - Rewrite marginalia annotators using
marginalia--fields, new faces, and helper formatters; expand ERT coverage accordingly.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
spot.el |
Hooks refresh timer lifecycle into spot-mode enable/disable. |
spot-auth.el |
Implements spot--start-refresh-timer / spot--stop-refresh-timer and stores timer state. |
spot-var.el |
Adds spot-refresh-interval customization for refresh cadence. |
spot-marginalia.el |
Replaces hand-built annotations with marginalia--fields, adds faces and helpers. |
test/spot-test.el |
Adds timer lifecycle tests and new/updated annotation helper tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| (spot-mode 1) | ||
| (spot-mode -1) | ||
| (should-not spot--refresh-timer))) |
There was a problem hiding this comment.
This test can leak a live timer into the rest of the ERT run if the assertion fails (which is exactly the case being tested): spot-mode -1 has already run, but spot--refresh-timer may still be non-nil. Consider wrapping the body in unwind-protect and explicitly cancelling/clearing spot--refresh-timer in the cleanup clause so later tests aren’t affected by a runaway timer.
| (spot-mode 1) | |
| (spot-mode -1) | |
| (should-not spot--refresh-timer))) | |
| (unwind-protect | |
| (progn | |
| (spot-mode 1) | |
| (spot-mode -1) | |
| (should-not spot--refresh-timer)) | |
| (spot-mode -1) | |
| (when spot--refresh-timer | |
| (cancel-timer spot--refresh-timer) | |
| (setq spot--refresh-timer nil))))) |
| 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")) |
There was a problem hiding this comment.
spot-refresh-interval is declared as a plain integer, which allows 0 or negative values. That can create a busy-loop (0) or error behavior when run-with-timer is started. Consider constraining this customization to positive values (and/or validating in the timer start logic) so a misconfiguration can’t cause runaway refreshes.
| :type '(choice (const :tag "Disabled" nil) (integer :tag "Seconds")) | |
| :type '(choice | |
| (const :tag "Disabled" nil) | |
| (restricted-sexp | |
| :tag "Seconds" | |
| :match-alternatives | |
| ((lambda (value) (and (integerp value) (> value 0)))))) |
| (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))))))) |
There was a problem hiding this comment.
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).
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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: <status> <message>' 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) <noreply@anthropic.com>
…reen 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) <noreply@anthropic.com>
Summary
Five changes, all exposed by the recently-added
spot-modescaffolding.1. Auto-refresh the access token when
spot-modeis enabledPreviously
spot-refreshwas a purely manual command — once per session (and whenever Spotify's ~1h access token expired), the user had to run it by hand, otherwise every Bearer-auth'd request 401'd silently and commands stopped returning results.spot-modenow starts a periodic refresh timer and runs an immediate refresh on enable (whenspot-refresh-tokenis set). Interval isspot-refresh-interval(default 3000s — below Spotify's 3600s token lifetime, set tonilto disable). Teardown cancels the timer.2. Rewrite marginalia annotations with
marginalia--fieldsAnnotations went from hand-rolled
mapconcatwith<-prefix and||separator to marginalia's built-in primitives, which gives aligned columns + per-field faces for free.:truncate Nper field yields fixed-width columns (pad short, ellipsis long), matching marginalia's built-in annotators.spot-marginalia-*faces inherit frommarginalia-date/marginalia-number/marginalia-type/marginalia-value/marginalia-documentationso themes style them out of the box and users can override individually.<-prefix and||separator — nothing in the marginalia ecosystem uses those.spot--propertize-items).spot--format-durationandspot--first-namehelpers; the latter replaces(or ... '(nil))guards that would have errored on genuinely empty artists/narrators/authors lists.3. Symbol prefixes on ambiguous numeric fields
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 with concise symbols that render in default Emacs fonts on all platforms (no icon font required):
#for counts —total_tracks,total_episodes,track_number★for popularity (0–100)♥for followersName fields (artist, album, publisher, narrator, author), release dates, and type keywords (album/single/compilation, audio/video) are left unlabelled — they're already unambiguous by shape or vocabulary.
Also in this commit:
M:SSinstead ofN.NNminutes. The old"4.18"looked like a time but actually meant 4.18 minutes (= 4:11), which was actively confusing.spot--format-count. A follower count like2175423now renders as2.2M. Values under 10 000 are left unchanged.4. Surface Spotify API errors instead of silently returning nothing
spot-request/spot-request-asyncused to ignore the HTTP status entirely — any 4xx/5xx was parsed as JSON, walked byspot--union-search-items, and produced an empty result set with zero user-visible feedback. Passing an out-of-range argument like--limit=200tospot-consult-searchreturned 0 results with no explanation; the same held for 401 after a token revocation, 403 from missing scopes, etc.New
spot--report-response-errorruns inside the response buffer and, when the status is outside 200–299,messagesspot: <status> <human message>— pulling the message out of Spotify's standard{"error":{"message":...}}body and falling back to a generic "request failed" for non-JSON bodies. Called from the sync helper, the non-parse-jsonpath ofspot-request, and the async helper's success branch.Also documents the
--key=valueargument syntax (limit, market, offset, include_external) and theq=-internal field filters (artist:,album:,year:, etc.) in the README, which was entirely undocumented.5. Truncate very wide candidates so annotations don't render off-screen
Marginalia aligns annotations to column
marginalia--cand-width-max, which only grows — it never shrinks. Once it exceeds the frame width, annotations are drawn past the right edge and clipped. Spotify returns genuinely long candidates (verbose podcast episode titles in particular), andorderless-annotationfilters compound the problem: when a component like@is in the filter, orderless asks marginalia's affixation function for the annotation of every candidate rather than only the ones displayed, so a single pathological episode pushes the alignment column past the edge for the whole session.spot--propertize-itemsnow truncates candidate names viaspot--truncate-namebefore marginalia ever sees them. The full name stays in themulti-datatext property, so embark actions, marginalia annotators, and consult's own lookup all see unchanged data. Width is tunable via the newspot-candidate-max-widthdefcustom (default 60 columns; set tonilto disable).Test plan
eask compilepasseseask lint package/checkdoc/relintcleaneask lint elisp-lintunchanged (pre-existing noise on filename prefix)eask test ert test/spot-test.el— 41/41spot-modeenable, immediate refresh succeeds, annotation columns align, symbols render correctly,--limit=200now produces "spot: 400 Invalid limit", and long candidate names truncate to the configured width rather than inflatingmarginalia--cand-width-maxNotes on scope
#/★/♥) and duration format (M:SS) are hardcoded. Adefcustomlayer for tuning them can go in later if someone asks.orderless-annotationandmarginalia--cand-width-max's monotonic growth. Worth flagging upstream to either project; for now spot just sidesteps it.🤖 Generated with Claude Code