The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Upnext listing page.
One queue over every audio source — including the ones you don't control.
A library your product imports so that whatever is driving — a model, a person clicking, a script — can control audio without knowing whether the sound is coming from Spotify, a browser tab, a podcast feed, or a file on disk.
Four sources, one list, in order. The first one is playing inside an app you do not own — and the queue waits for it to finish before taking over.
Add audio to your product without marrying one service. Write against one queue; swap or add backends later. An entry describes what to play, not where from, so it can bind to whichever source is available at the moment it plays.
Join what someone is already listening to instead of talking over it. The machine's current playback — a YouTube tab, a podcast in Safari, VLC — can be a queue entry like any other. Your track starts when theirs ends.
Know what you can do before you try it. runtime.can('seek') answers for the
backend that is actually loaded. No silent no-ops, no discovering at 2am that
one source quietly ignored a command.
Keep playing when a source fails. If a backend cannot load an entry, the same description is handed to the next one that can. A queue does not stop because one service is down.
Survive a restart. serialize() / restore() — and a queue saved on a
machine with Spotify reopens on one without it, then plays from somewhere else.
| package | plays |
|---|---|
upnext-core | nothing — the queue, state machine, capability model and events. Zero dependencies, no I/O. |
upnext-adapter-spotify | the Spotify desktop app on macOS with no credentials, or the Web API with a token you hold |
upnext-adapter-browser | any media element you control — browser, Electron renderer, webview, across a process boundary |
upnext-adapter-local | local files and streams via ffplay/afplay |
upnext-adapter-apple-music | your Apple Music library through the Music app — no credentials, and it can search |
upnext-adapter-nowplaying | whatever the machine is already playing, whichever app is playing it (macOS + Linux) |
upnext-adapter-process | an adapter written in any language, over a pipe |
upnext-desktop | all of the above wired for you, in one call — plus the upnext CLI |
upnext-mcp | the same, as an MCP server any agent can use |
upnext-http | the same, over HTTP with a live event stream |
The point of the capability model is that these differ, and say so:
| starts tracks | end of track | position | seek | pause | volume | search | someone else can change it | |
|---|---|---|---|---|---|---|---|---|
| browser | ✅ | event | exact | ✅ | ✅ | ✅ | ❌ | no |
| local (ffplay) | ✅ | event | estimated | ✅ | ✅ | ❌ | ✅¹ | no |
| local (afplay) | ✅ | event | estimated | ❌ | ✅ | ❌ | ✅¹ | no |
| spotify desktop | ✅ | event | exact | ✅ | ✅ | ✅ | ❌² | yes |
| spotify web | ✅ | event | exact | ✅ | ✅ | ✅ | ✅ | yes |
| apple music | ✅ | poll | exact | ✅ | ✅ | ✅ | ✅ | yes |
| nowplaying | ❌³ | poll | exact | ❌ | ✅ | ❌ | ❌ | yes |
¹ only when you point it at a music folder to index · ² the AppleScript dictionary cannot search a catalogue · ³ there is no way to ask macOS's Now Playing register to start a specific track
Every ❌ there is a refusal rather than a silent failure. An adapter that claims it can seek and then doesn't is a bug you chase for an hour; these tell you first, and the runtime routes around them.
Not built yet: Now Playing on Windows, and controlling one specific browser tab (needs an extension). Details at the bottom.
Every audio integration today puts the queue in the wrong place.
That works right up until the next item isn't a Spotify track — and then there is
nowhere to put it. upnext inverts it: the runtime owns the queue, and Spotify's
queue, Apple Music's Up Next and a browser tab's <audio> element all become
places to send one item at a time.
The fast way — every source this machine can reach, one call:
It ships a CLI too:
That is a YouTube tab, read with no browser extension.
Twelve tools in Claude Desktop, Cursor or anything else that speaks MCP —
including media_adopt_current, which puts what someone is already listening to
into the queue so the agent adds to it rather than talking over it.
upnext-adapter-local needs ffplay (from ffmpeg) or afplay (built into macOS).
On a Mac with Spotify open, add a second source with nothing to sign up for:
Want to hear it right now, with no files of your own?
Entries are prepared before the playhead reaches them, so an intent has already become a real track on a real backend by the time it's needed — no silence while a model thinks.
play() actually doesA queue entry is not a URI. It's a MediaRef — a description that binds to a
source as late as possible.
| this gives you | because |
|---|---|
| enqueue before choosing a source | the entry doesn't name one |
| automatic fallback mid-queue | if Spotify fails to load, the same ref goes to the next adapter |
| queues portable between people | your Spotify and their Apple Music resolve the same ISRC |
| queues that survive a restart | a saved queue reopens on a machine with different backends and still plays |
Strong external ids (ISRC, MusicBrainz) are the join key; normalized title and artist are the fallback. Resolutions are verified before they play — an adapter returning something is not the same as it returning the right thing, and confidently playing the wrong song is the classic cross-source failure.
A title needs a backend that can search. A link says exactly what to play; a title has to be looked up, and not every backend can look things up. The Spotify desktop app is the sharp case — it plays a URI you hand it, but its AppleScript dictionary cannot search a catalogue, so it scores 0 for a bare title rather than guessing. On a default Mac setup that means nothing resolves
{ title: 'Bad Habit' }.On a Mac this is now answered for you:
upnext-adapter-apple-musicsearches your library and needs no credentials at all, so a plain title resolves out of the box. Elsewhere, index a music folder, add a Spotify Web token, or supplyresolveIntentand answer it yourself.upnext-desktop'sexplainSetup()andupnext doctorboth say which of those you have — this is a real gap and it is better named than discovered.
Every backend sits somewhere on this line, and the runtime is correct across all of it:
play: true would be useless — every adapter can play. These are the flags that
change what the runtime and the caller actually do:
| flag | if it's weak, the runtime… |
|---|---|
endOfTrack: 'poll' | asks on an interval instead of being told |
endOfTrack: 'none' | runs a duration timer and marks the position a guess |
position: 'estimated' | extrapolates from a local clock |
externalControl: true | reconciles instead of assuming it's the only writer |
They're published inline on playback state, so this is one call, not a join
against adapterId:
upnext-adapter-spotify ships two adapters for Spotify, and they are not
interchangeable:
| desktop app | Web API | |
|---|---|---|
| credentials | none | OAuth token + Premium |
search | false | true |
| runs on | macOS | anywhere |
search: false is the interesting one. Spotify's AppleScript dictionary cannot
search a catalogue. That could be faked — scrape something, guess — and then
every resolution of a title would be a coin flip dressed as a lookup. An adapter
that says it cannot do a thing is correct and slightly limited; one that says it
can and then does it badly is broken. So it declares false, scores 0 on
anything that isn't already a Spotify link, and the entry goes to a backend that
can actually find it.
That is the whole capability model in one flag, and it is why capabilities belong to an adapter rather than to a service.
You queue three songs. The listener picks up their phone and hits next in Spotify.
The human wins by default. A queue that fights the person holding the keyboard is a bug, not a feature.
The hard part is that "the track I loaded is not the track that is playing" has
two opposite causes — our track ended and the backend rolled on, or a person
chose something else — and they call for opposite responses. What separates
them is where the playhead was a moment ago, which is knowledge only the adapter
has. See adapter-spotify/src/sampler.ts
for the real one.
That entry stays unresolved until the playhead gets close, then calls the resolver your host supplies:
The core never calls a model, never holds an API key, never picks a provider. That boundary is what makes this embeddable in someone else's product instead of being one agent with a
package.json. The Spotify adapter draws the same line around OAuth: you supplygetAccessToken, it runs no flow.
Without a resolver it falls back to searching whatever adapters advertise
search, so it's useful with nothing but adapters wired up.
Spotify has a repeat button. So does Apple Music. Neither knows about the browser tab queued behind it, so the only place the question can be answered once is above all of them. The adapters don't touch their backend's own setting.
Two details worth knowing: repeat-one still yields to next(), because a
repeat mode that ignores the skip button is a trap; and shuffle is a traversal
order, not a re-ordering — your list stays in the order you built it, and the
runtime just picks differently. Inject random to make a shuffle reproducible in
a test.
Restoring never starts playback — that's the host's call. And bindings are dropped: a binding is a live handle to a backend session, and none of that survives a restart, so every entry rebinds against the adapters that exist now.
Which is the payoff for describing media instead of locating it: a queue saved on a machine with Spotify reopens on one without it, and still plays from somewhere else.
So it's move(id, { after: otherId }). Every mutation bumps a version, and any
mutation can pass expectVersion to refuse a stale write.
| event | when |
|---|---|
item:started / item:ended | a track began / finished, with the reason |
item:resolved | an intent became a real MediaRef |
item:unresolvable | lookahead failed — a warning, retried at play time |
item:failed | this entry cannot play |
queue:changed | one per logical change, not one per internal write |
playback:changed | status, position source, capabilities |
position | playhead moved |
desync | a human changed the backend under us |
adapter:error / error | a backend, or work nobody was awaiting, failed |
Everything handed out is a copy, including event payloads. runtime.queue is
a frozen view with no mutators on it — not a type-level Readonly a cast could
defeat.
Three things look identical from a listener's chair — nothing is playing — so the runtime tells them apart:
| what went wrong | what happens |
|---|---|
a backend lies — claims endOfTrack: 'event' with no subscribe | rejected at addAdapter, listing every inconsistency at once |
a backend breaks — init() throws | excluded from selection; getState().adapters shows available: false and why |
| a backend hangs — never returns | bounded by timeoutMs (30s default); falls through to the next source |
you change your mind — skip mid-play | the abandoned backend is stopped, not left playing alongside the new one |
you use it after dispose() | throws, rather than accepting a write to a queue nobody will ever hear |
Required: id, capabilities, match, resolve, load, play, stop.
Everything else is optional and gated by what you declare — a thirty-line adapter
is a legitimate adapter.
Two rules that matter more than the code:
null from resolve rather than guessing. The runtime tries the
next source, which beats confidently playing the wrong song.Adapters don't have to be TypeScript, or even in this process.
One JSON object per line. No framing headers, no schema registry, no codegen.
examples/python-adapter/adapter.py
is a complete working backend in ~150 lines of Python, covered by the test suite —
the runtime can't tell it apart from a native one.
| package | what it is |
|---|---|
upnext-core | queue, state machine, capabilities, events. Zero dependencies, no I/O. |
upnext-core/testing | a fake adapter whose capabilities you set |
upnext-core/internal | the pieces it's built from. Unsupported; they move. |
upnext-adapter-local | files and streams via ffplay/afplay. No credentials. |
upnext-adapter-spotify | the Spotify desktop app (macOS, no credentials) or the Web API (your token) |
upnext-adapter-process | adapters as subprocesses, in any language |
upnext-core does no I/O at all — no filesystem, no network, no clock it
wasn't handed. It runs identically in Node, Bun, Deno, Electron, Tauri or a
browser, and the entire suite runs in milliseconds with no fake-timer library
and no flakes.
Early, but the core, the capability model and the adapter contract are real and tested. Every behavioural change carries a test, and CI runs the whole suite — plus the audible demo — on Node 20 and 22 across Linux and macOS on every push.
Several bugs in this design were found by running the demo out loud rather than
by reading code — a doubled end-of-track event, a late prefetch overwriting the
track that had just started, two tracks playing at once after a cancelled skip.
Each has a regression test. If you touch playback, run npm run demo and listen.
Not built yet:
playerctl) both work today, behind the
same nowplaying:current entry.upnext-adapter-nowplaying already
reaches whatever the machine is playing, browser included, through macOS's
system Now Playing register — no extension needed. Singling out one tab among
several, though, does need a browser extension, and that is a control feature
rather than a queue one: you cannot queue into a tab you do not own.upnext-adapter-browser is gapless
now — give it a spare element and the next track is buffered while the
current one plays, so the switch is instant. Spotify and Apple Music are not,
and cannot be from here: their AppleScript dictionaries have no way to hand
them a track to play next, so the runtime has to drive each transition and
that round trip is the gap.
(All three transports ship now: upnext-mcp, the upnext CLI in
upnext-desktop, and upnext-http.)The most valuable thing you can contribute is an adapter — see CONTRIBUTING.md. The core is deliberately small and mostly finished; what makes this useful is the number of places it can send audio.
Apache-2.0. Adoption is the only moat that matters for a substrate like this — a queue abstraction is worthless unless other people's adapters target it.