Skip to content

feat: auto-refresh access token and polished marginalia annotations#5

Merged
chiply merged 6 commits into
mainfrom
feat/auto-refresh-token
Apr 21, 2026
Merged

feat: auto-refresh access token and polished marginalia annotations#5
chiply merged 6 commits into
mainfrom
feat/auto-refresh-token

Conversation

@chiply

@chiply chiply commented Apr 21, 2026

Copy link
Copy Markdown
Owner

Summary

Five changes, all exposed by the recently-added spot-mode scaffolding.

1. Auto-refresh the access token when spot-mode is enabled

Previously spot-refresh was 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-mode now starts a periodic refresh timer and runs an immediate refresh on enable (when spot-refresh-token is set). Interval is spot-refresh-interval (default 3000s — below Spotify's 3600s token lifetime, set to nil to disable). Teardown cancels the timer.

2. Rewrite marginalia annotations with marginalia--fields

Annotations went from hand-rolled mapconcat with <- prefix and || separator to marginalia's built-in primitives, which gives aligned columns + per-field faces for free.

  • :truncate N per field yields fixed-width columns (pad short, ellipsis long), matching marginalia's built-in annotators.
  • New spot-marginalia-* faces inherit from marginalia-date / marginalia-number / marginalia-type / marginalia-value / marginalia-documentation so themes style them out of the box and users can override individually.
  • Dropped the <- prefix and || separator — nothing in the marginalia ecosystem uses those.
  • Dropped the redundant name field from every annotator — the candidate string already is the name (see spot--propertize-items).
  • New spot--format-duration and spot--first-name helpers; 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 followers

Name 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:

  • Duration is now M:SS instead of N.NN minutes. The old "4.18" looked like a time but actually meant 4.18 minutes (= 4:11), which was actively confusing.
  • K/M/B humanization for large counts via new spot--format-count. A follower count like 2175423 now renders as 2.2M. Values under 10 000 are left unchanged.

4. Surface Spotify API errors instead of silently returning nothing

spot-request/spot-request-async used to ignore the HTTP status entirely — any 4xx/5xx was parsed as JSON, walked by spot--union-search-items, and produced an empty result set with zero user-visible feedback. Passing an out-of-range argument like --limit=200 to spot-consult-search returned 0 results with no explanation; the same held for 401 after a token revocation, 403 from missing scopes, etc.

New spot--report-response-error runs inside the response buffer and, when the status is outside 200–299, messages spot: <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-json path of spot-request, and the async helper's success branch.

Also documents the --key=value argument syntax (limit, market, offset, include_external) and the q=-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), and orderless-annotation filters 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-items now truncates candidate names via spot--truncate-name 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).

Test plan

  • eask compile passes
  • eask lint package / checkdoc / relint clean
  • eask lint elisp-lint unchanged (pre-existing noise on filename prefix)
  • eask test ert test/spot-test.el — 41/41
  • Live reload in running Emacs: timer starts on spot-mode enable, immediate refresh succeeds, annotation columns align, symbols render correctly, --limit=200 now produces "spot: 400 Invalid limit", and long candidate names truncate to the configured width rather than inflating marginalia--cand-width-max

Notes on scope

  • This does not add retry-on-401 — if Spotify invalidates the access token between scheduled refreshes (revocation, etc.) requests still fail until the next tick. With error surfacing in place, the user now sees the 401 message. Automatic retry is a follow-up I can send separately if you want.
  • Symbol choices (# / / ) and duration format (M:SS) are hardcoded. A defcustom layer for tuning them can go in later if someone asks.
  • The candidate-truncation fix masks an interaction between orderless-annotation and marginalia--cand-width-max's monotonic growth. Worth flagging upstream to either project; for now spot just sidesteps it.

🤖 Generated with Claude Code

Charlie Holland and others added 2 commits April 21, 2026 00:50
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>
Copilot AI review requested due to automatic review settings April 21, 2026 04:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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-interval customization 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.

Comment thread test/spot-test.el
Comment on lines +231 to +233
(spot-mode 1)
(spot-mode -1)
(should-not spot--refresh-timer)))

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.

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.

Suggested change
(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)))))

Copilot uses AI. Check for mistakes.
Comment thread spot-var.el
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"))

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-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.

Suggested change
: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))))))

Copilot uses AI. Check for mistakes.
Comment thread spot-auth.el
Comment on lines +83 to +91
(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)))))))

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.
Charlie Holland and others added 4 commits April 21, 2026 01:02
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>
@chiply chiply merged commit 2e947f8 into main Apr 21, 2026
7 checks passed
@chiply chiply deleted the feat/auto-refresh-token branch April 21, 2026 06:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants