- Go 99.1%
- Objective-C 0.9%
|
|
||
|---|---|---|
| cmd/upnext | ||
| docs/superpowers/specs | ||
| internal | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
upnext
▸▬▬▬▬ ┬ ┬┌─┐┌┐┌┌─┐─┐ ┬┌┬┐
▬▬▬ │ │├─┘│││├┤ ┌┴┬┘ │
▬ └─┘┴ ┘└┘└─┘┴ └─ ┴
A terminal client for Spotify Connect: log in, search, and control playback — with vim keys. The bars in the mark are a queue receding into the distance, which is the whole idea: what is playing, and what comes after it.
It opens on your queue. / opens a search panel over it; playing something
closes the panel and drops you back on the queue.
upnext up next NORMAL
# TITLE ARTIST ALBUM TIME
▸1 Warszawa David Bowie, Brian Eno Low 6:25
2 Sound and Vision ᴱ David Bowie Low 3:03
3 Fugue Igorrr Spirituality 4:12
3 results for bowie
▶ Heroes — David Bowie from "Heroes"
━━━━━━━━━━━━━━━━────────────────────────────── 1:37 / 6:11 · MacBook Pro
j/↓ down • k/↑ up • enter play • / search • space play/pause • ? help • q quit
How playback works
upnext does not stream audio. It is a remote control: it uses the official Spotify Web API to drive playback on a Spotify Connect device you already have open — the desktop app, your phone, a browser tab, a speaker.
Two consequences worth knowing before you start:
- Spotify Premium is required to control playback. Search works without it.
- Something must be running Spotify. If no device is active, upnext says so
and
Dopens the device picker.
On macOS, upnext also publishes the active track to Control Center and handles the system play/pause, previous, and next controls. These controls are forwarded to the active Spotify Connect device, so upnext must remain running while they are in use. Other platforms build without this optional native integration.
Setup
You need your own Spotify application, which takes about a minute. upnext uses the PKCE flow, so there is no client secret to guard.
-
Open https://developer.spotify.com/dashboard and create an app.
-
Add this redirect URI to the app settings, exactly:
http://127.0.0.1:8888/callback -
Copy the Client ID and expose it:
export UPNEXT_CLIENT_ID=<your client id>Or write
~/.config/upnext/config.json:{ "client_id": "<your client id>", "callback_port": 8888, "search_limit": 10 }If you change
callback_port, update the redirect URI to match.
search_limit, and why searches are paged
Spotify applications start in development mode, and such apps are capped at
10 results per request — asking for more returns 400 Invalid limit, even
though the public docs advertise 50. upnext therefore defaults to 10, which is
exactly one request.
That cap is per request, not per search, though: offset still works under it.
Set search_limit (or UPNEXT_SEARCH_LIMIT) anywhere up to 50 and upnext pages
to reach the total — 20 results is two requests of 10, and it works today on a
development-mode app. The cost is a round trip per page, which is why the
default stays at one. If a later page fails, the results already collected are
returned rather than thrown away.
Install and run
go build -o upnext ./cmd/upnext
./upnext
On first launch, press enter to log in. Your browser opens, you approve, and
the token is cached in ~/.config/upnext/token.json (mode 0600) — you will not
be asked again. ./upnext -logout deletes it.
Keys
upnext is modal, like vim. The list is normal mode; the search field is insert mode. The current mode is shown in the top right, and the pane you are on next to the logo.
The queue is home. The app opens on it, / opens the search panel over it,
and playing something from a search closes the panel and returns you there. The
search field only exists while that panel is open.
Normal mode
| Key | Action |
|---|---|
j / k |
move down / up |
h / l |
back / open selected item |
gg / G |
jump to top / bottom |
ctrl+d / ctrl+u |
half page down / up |
1 |
queue |
2 or / |
search |
3 |
playlists |
4 |
artists |
5 |
podcasts |
u, P, R, S |
legacy aliases for queue, playlists, artists, podcasts |
o / O |
open album/artist menu for the selected song / currently playing song |
a |
queue the selected track to play next |
A |
queue the selected track at the end |
d |
queue pane: explains why a queued track cannot be removed |
enter |
queue pane: jump to that track. search panel: play it and close the panel |
esc |
close the search panel |
space |
play / pause |
> / < |
next / previous track (n / p also work) |
D |
device picker |
r |
refresh now playing |
v |
toggle cover art |
? |
toggle full help |
q / ctrl+c |
quit |
Insert mode (the search panel)
| Key | Action |
|---|---|
enter |
run the search |
esc |
back to normal mode — or close the panel if you typed nothing |
esc stages the way vim does: with a query in the field the first esc returns
to normal mode so you can j/k through the results, and a second one closes
the panel. On an untouched field one esc closes it, since there is nothing to
come back to.
Cover art
The playing track's album cover is drawn beside the now-playing footer, and v
toggles it. It is 8 x 4 cells — sized to sit exactly alongside the four
lines of title, artist, album and progress, so showing it costs no rows of the
list at all. How well it is drawn depends on the terminal:
- A real image where the terminal speaks the kitty graphics protocol — kitty, Ghostty, WezTerm — sent as a PNG.
- Coloured half-blocks everywhere else, which need nothing but colour.
The blocks use the upper-half-block character ▀: the foreground paints the
top pixel of each cell and the background the bottom one, so every row of
characters carries two rows of pixels. Each output pixel is the mean of the
source region it covers, not a sampled point; at twelve cells across,
nearest-neighbour is noise while the average keeps the cover's blocks of colour
recognisable. That gives 8x8 pixels of detail in the fallback, against a real
160px image under the kitty protocol.
The cover is dropped on a window narrower than 60 columns, where the text beside it needs those columns more.
Asking the terminal rather than guessing
Which one is used is decided by querying the terminal, not by sniffing
$TERM. At startup — before bubbletea takes over the input stream, since a
reply arriving later would be read as keystrokes — upnext sends the kitty
graphics query followed by a Primary Device Attributes request:
\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\ then \x1b[c
Every terminal answers the second one, which makes it a fence: if the kitty reply arrives before it, the protocol is supported; if only the attributes come back, it is not. No waiting on a timeout in the normal case — measured at 0ms under tmux, which answers immediately.
Two details that are easy to get wrong, and were:
- The read timeout comes from the terminal driver (
VMIN=0,VTIME), not fromSetReadDeadline, which fails on a TTY with "file type does not support deadline". A goroutine-and-select would work but leaves a read blocked on the keyboard, stealing the next keystroke. - The reply must always be drained. Bailing out early on an unsupported
terminal leaves the attributes answer unread, and it spills onto the screen as
stray text like
^[[?1;2;4c.
If the query cannot be run at all — not a TTY, or a non-Unix platform — upnext
falls back to inferring support from $TERM, $TERM_PROGRAM and friends, and
to blocks if that says nothing. Sixel support is detected (foot and xterm
advertise it, as does tmux) but not yet implemented; those terminals get blocks.
Covers come from Spotify's public image CDN (no authentication), are fetched only when the artwork actually changes — consecutive tracks from one album share it — and are rendered once and cached, since the view is rebuilt on every spinner tick. A failed download is silent: artwork is decoration and must never put an error over the status line. Below a 20-row terminal the cover is dropped, because four lines of list matter more than decoration on a short window.
Playlists
P opens your saved playlists. enter opens the highlighted one and lists
its tracks — browsing a playlist should not commit you to hearing it. enter on
a track in there starts it, and esc steps back one level (playlist contents →
playlist list → queue).
A track played from a playlist is sent with the playlist as its context and
the track as an offset, so the rest of the playlist follows and the footer reads
"from ". Playing the track URI alone would leave nothing queued behind
it. a and A queue tracks from here exactly as they do from search.
Up to 100 tracks of a playlist are read, in pages of 10 — each page is a round trip, so that caps how long a large playlist takes to open.
Ordering the list
s cycles the playlist order: library order (Spotify's own, as returned) →
by name → by length → by owner (yours first, since only those can be
opened). The header shows which is in force, and re-sorting keeps the cursor on
the playlist you had selected rather than on whatever now occupies that row.
Two orderings people ask for are missing, and cannot be added:
- Date added — a saved playlist carries no timestamp of any kind.
GET /v1/me/playlistsreturnscollaborative, description, external_urls, href, id, images, items, name, owner, primary_color, public, snapshot_id, type, uriand nothing else, and no other endpoint records when you saved one. (Playlist tracks do carryadded_at, so ordering inside a playlist by date added is possible — ask if you want it.) - Last played — only obtainable from
GET /v1/me/player/recently-played, which needs theuser-read-recently-playedscope and covers just the last 50 plays. Most playlists would sort as "never played", so it is left out rather than offered as an order that mostly does not work.
Two API surprises worth recording
Spotify renamed the resource. GET /v1/playlists/{id}/tracks — the endpoint
in most documentation and tutorials — now answers 403 Forbidden for a
development-mode app, on every playlist, including your own. The live resource
is /v1/playlists/{id}/items, and each entry arrives under item rather than
track. The playlist object follows the same rename: contents and the track
count are under items, and tracks is null. upnext reads the new shape and
still accepts the old keys, so it works either way.
You can only read your own. /items returns 200 for playlists you own and
403 for anyone else's, even public ones you follow. Those still appear in the
list (and can be played as a context), but opening one reports that Spotify will
not show this app its contents — it is a restriction, not a bug in upnext.
Listing them needs the playlist-read-private and playlist-read-collaborative
scopes. A token issued before upnext asked for these keeps the narrower grant
— OAuth scopes cannot be widened by refreshing, only by logging in again.
upnext detects that case (403 Insufficient client scope) and drops you on the
login screen, where enter re-authorizes and overwrites the stored token with
one carrying the wider grant. No -logout needed.
The one thing it cannot do for you is rebuild itself: the scopes live in the
binary, so an older upnext will keep asking for the narrower set no matter how
often you log in.
go build -o upnext ./cmd/upnext # first — scopes are compiled in
./upnext # then press P, and enter on the login screen
Queue ("up next")
u switches between the queue and the last search results; the header says
which one you are looking at, and each keeps its own cursor, so switching back
returns you to where you were. All the same motions work in both. Results are
kept after you play something, so u reopens them without re-searching.
The queue is loaded when the app starts, refreshed whenever the song changes, and polled every few seconds on top of that. The poll is not redundant: Spotify's queue endpoint is eventually consistent, so the read taken at the moment a song ends can still describe the previous track. Relying on track-change detection alone, that stale answer would sit on screen until the next song ended, because no further change would be seen. Polling heals it, and picks up tracks queued from another device as a bonus.
Those background fetches fail silently — you did not ask for them, so a hiccup
should not overwrite the status line; the fetch behind u and r reports its
errors normally. Refreshes keep your cursor where it is (clamped to the new
length) rather than snapping to the top, which is what makes it possible to work
down a list that reloads under you every few seconds.
enter on a queued track jumps to it. Two API quirks worth knowing:
- Spotify returns at most 20 upcoming tracks and offers no way to page further.
- For a single track played with no playlist or album behind it, Spotify's own queue endpoint returns that same track repeated. That is the API's answer, not a display bug.
How jumping works, given there is no "skip to queue item" endpoint
There is no endpoint that means "play the Nth item of the queue", which is why a
naive client can only press next repeatedly. upnext uses two mechanisms, in
internal/spotify/queuejump.go:
-
Context offset (lossless).
PUT /v1/me/player/playaccepts acontext_uritogether with anoffset, so the current playlist or album can be restarted at any track it contains:{ "context_uri": "spotify:playlist:…", "offset": { "uri": "spotify:track:…" } }Spotify then keeps supplying what comes next, so everything below the chosen track survives. This is what the official clients do when you pick a track out of "Next from". Verified live: jumping four tracks down a playlist kept both the context and the remaining queue intact.
-
Explicit URI list (fallback). With no usable context — a bare track, an artist or "collection" context, or a track the user queued by hand, which is not part of the context and so is rejected by the offset — upnext replays the visible queue as
{"uris": [chosen, …the rest]}. Verified live: this plays the right tracks but setscontexttonull, so Spotify's own autoplay no longer follows on, and anything queued beyond the visible 20 is lost. The UI says so in the status line when this path is taken.
offset is accepted for playlist and album contexts only; artist contexts
reject it, so upnext does not attempt it for them.
What the API cannot do directly: reorder the queue, or remove anything from
it. POST /v1/me/player/queue only appends. Clients that offer true
drag-and-drop queue editing (ncspot, spotify_player, spotifyd) are not using the
Web API for playback — they embed librespot and are the player, so they own
the queue outright. That is the only lossless way past this limit, and it is the
tradeoff upnext deliberately declined (see How playback works).
Why there is no "remove from queue"
d on the queue explains why it cannot do what you want, because the answer is
not obvious and the obvious workaround makes things worse.
Spotify exposes no endpoint that removes a queued track. The tempting
workaround is the same rebuild used for jumping — replace playback with an
explicit list that omits the unwanted track. It does not work, for a reason
worth recording: a pending queue is not part of playback. It survives every
play call, whatever you start. So the rebuilt list does not replace the queue,
it plays after it, while the track you wanted gone is still queued and still
plays. Worse, each attempt folds the surviving queue back into a new list and
prepends the current track, so repeated presses multiply entries rather than
removing them.
That behaviour is easy to confirm: queue a track, start a completely different
playlist, and read GET /v1/me/player/queue — the queued track is still there.
So removal genuinely requires owning the player, which means librespot (see
How playback works). Within the Web API the only ways past an unwanted queued
track are to skip it with n or to clear the queue in an official client.
Adding to the queue: a and A
Spotify has exactly one queue-mutating endpoint, POST /v1/me/player/queue, and
it always appends. There is no insert, no reorder, and no remove — anything
queued stays queued until it plays or the user clears it in an official client.
Verified live, the queue is ordered:
[ user-queued tracks, in the order they were added ] then [ the context continuation ]
So a track queued while nothing else is user-queued genuinely does play next,
which is what makes a meaningful:
Aappends. Always exactly one request, always truthful.aappends and then reads the queue back to see where it landed. If it is first, the status says "plays next". If other tracks were already queued ahead of it, the status says how many play first rather than claiming something the API cannot deliver. Nothing can move it forward — that is the limit, not an implementation shortcut.
A queued track also survives restarting the playing context, so a is durable.
enter: playing a search result without the music stopping
Playing a bare track URI is what makes a client feel broken — the song ends and everything stops, with no queue behind it. The natural cure is to seed a radio from the chosen track, and that is not available: Spotify's November 2024 deprecation removed the endpoints for applications in development mode, which is every app a upnext user can create. Verified against a live token:
| Endpoint | Result |
|---|---|
GET /v1/recommendations |
404 — gone |
GET /v1/artists/{id}/top-tracks |
403 — forbidden |
GET /v1/artists/{id}/related-artists |
403 — forbidden |
GET /v1/albums/{id}/tracks |
200 |
GET /v1/artists/{id}/albums |
200 |
What remains reachable is the track's own album, so enter plays the track as
an offset into its album (context_uri = the album, offset = the track).
The chosen song plays first and the rest of the album follows, giving Spotify a
context to continue from. A track with no album, or an album that will not play,
falls back to the bare track — and the status line says which happened.
If your app is ever granted extended quota, the recommendations endpoint would
be the better seed; spotify.PlayTrackWithContinuation is the single place that
choice is made.
Playing from
The now-playing footer names the playlist, album, or artist the current track is playing from, on the right of the title line. Album contexts are named from the track itself, at no extra request; playlists are looked up once and cached.
Spotify's editorial and algorithmic playlists (Discover Weekly, Daily Mix, radio) are invisible to development-mode apps — looking one up returns 404 — so the footer falls back to "from a playlist" rather than showing nothing. Failed lookups are cached too, so a playlist that cannot be named is not re-requested on every poll.
Device picker
j / k to move, enter to switch device, r to refresh, esc to close.
Switching a device does not start playback — it just moves the session.
Architecture
The dependency arrow points inward: the UI depends on interfaces, and the
network code implements them. Nothing in internal/tui imports an HTTP client,
and nothing in internal/spotify knows a terminal exists.
cmd/upnext composition root — the only place concrete types are chosen
│
├── internal/config client ID, redirect URI, XDG paths
├── internal/auth OAuth2 PKCE, loopback callback server, token store
├── internal/session joins auth to the API client (implements SessionProvider)
├── internal/spotify domain model + ports (interfaces) + HTTP adapter
└── internal/tui bubbletea models, vim keymap, lipgloss theme
Where the SOLID principles actually earn their keep here:
- Single responsibility.
spotify.Clienttranslates HTTP;authowns token lifecycle;tuiowns state and rendering. The one place they meet isinternal/session, which is 40 lines. - Open/closed. A new screen is a new model plus a case in
Update; adding playlists means adding a port and an adapter method, touching no existing UI. - Liskov.
fakeServicein the tests is a drop-inspotify.Service— the whole app runs against it with no network. - Interface segregation.
TrackSearcher,PlaybackController,PlaybackReader, andDeviceListerare separate. A command that searches cannot pause your music, and the compiler enforces it. - Dependency inversion.
tui.SessionProvideris declared by the consumer (internal/tui) and satisfied byinternal/session, so the UI never learns what OAuth is.
Some specifics worth knowing when reading the code:
- Domain types are not Spotify's JSON.
internal/spotify/dto.goholds the wire structs; they are unexported and converted at the boundary, so an API change stops at the adapter. - Errors are translated once.
translateErrormaps status codes and reason strings onto sentinels (ErrNoActiveDevice,ErrPremiumRequired, …), andstatusForErrorturns each into an actionable sentence. The device-related meanings of 403 and 404 apply only to/v1/me/player*paths — off those routes the same codes mean a missing resource or a missing scope. - Every list shares one cursor. Results, queue and playlists embed
listCursor, soj,k,gg,Gand the half-page keys are implemented once and cannot drift between panes; each list keeps its own position. - All I/O lives in
tea.Cmds (internal/tui/commands.go).Updateis synchronous and pure, which is why the tests can drive it message by message. - The results list is hand-written rather than
bubbles/list, whose built-in key map claims/andqand would fight the vim bindings. - Transient failures are retried, but only where repeating is safe. Spotify
intermittently answers a valid request with 502 or 503, and losing a search to
that is pointless.
internal/spotify/retry.goretries up to three attempts with jittered backoff, honouring a 429'sRetry-After(and reporting rather than sleeping off a long one). Crucially it retries only GET and PUT: every POST upnext sends is non-idempotent, so a retry would queue a track twice — with no endpoint to remove it — or skip two tracks instead of one. - Coalesced keystrokes are replayed one at a time. A terminal delivers fast
typing in a single read, so bubbletea can hand the app one
KeyMsgholding several runes —ggtyped at speed arrives as one message whoseString()is"gg"and matches no binding. Outside insert mode,replayRunessplits the chunk back into individual presses; inside insert mode the chunk is left intact so pasting still works.
Tests
go test ./...
The suite covers response decoding and error translation against an
httptest server, token store permissions and corrupt-cache handling, and the
UI end to end against a fake service — vim motions, mode switching, stale search
responses, playback commands, and that the view never exceeds the terminal
height.
Scope
This is v1: login, search, playback control, the queue (view and jump), and the
playing-from context. Browsing your library, adding to the queue, volume, and
shuffle/repeat are deliberately absent — the ports in
internal/spotify/ports.go are where they would go.