{
  "markdown": "# upnext\n\n[![npm](https://img.shields.io/npm/v/upnext-core?label=upnext-core)](https://www.npmjs.com/package/upnext-core)\n[![CI](https://github.com/tothienbao6a0/upnext/actions/workflows/ci.yml/badge.svg)](https://github.com/tothienbao6a0/upnext/actions/workflows/ci.yml)\n[![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)\n[![deps](https://img.shields.io/badge/dependencies-0-brightgreen)](./packages/core/package.json)\n\n**One queue over every audio source — including the ones you don't control.**\n\nA library your product imports so that whatever is driving — a model, a person\nclicking, a script — can control audio without knowing whether the sound is\ncoming from Spotify, a browser tab, a podcast feed, or a file on disk.\n\n```ts\nconst runtime = new Runtime({ adapters: [spotify, browser, local, nowPlaying] });\n\nruntime.enqueue(NOW_PLAYING_URI);                         // the podcast already\n                                                          // playing in their browser\nruntime.enqueue('spotify:track:1OWBh1eVxUdA1Z6UA8r4nh');  // then a Spotify track\nruntime.enqueue('https://example.com/episode.mp3');       // then a file on the web\nruntime.enqueue('something calmer after those');          // then whatever you decide, later\n\nawait runtime.play();\n```\n\nFour sources, one list, in order. The first one is playing inside an app you do\nnot own — and the queue waits for it to finish before taking over.\n\n### What you get\n\n**Add audio to your product without marrying one service.** Write against one\nqueue; swap or add backends later. An entry describes *what to play*, not *where\nfrom*, so it can bind to whichever source is available at the moment it plays.\n\n**Join what someone is already listening to instead of talking over it.** The\nmachine's current playback — a YouTube tab, a podcast in Safari, VLC — can be a\nqueue entry like any other. Your track starts when theirs ends.\n\n**Know what you can do before you try it.** `runtime.can('seek')` answers for the\nbackend that is actually loaded. No silent no-ops, no discovering at 2am that\none source quietly ignored a command.\n\n**Keep playing when a source fails.** If a backend cannot load an entry, the same\ndescription is handed to the next one that can. A queue does not stop because\none service is down.\n\n**Survive a restart.** `serialize()` / `restore()` — and a queue saved on a\nmachine with Spotify reopens on one without it, then plays from somewhere else.\n\n### What ships today\n\n| package | plays |\n|---|---|\n| **`upnext-core`** | nothing — the queue, state machine, capability model and events. Zero dependencies, no I/O. |\n| **`upnext-adapter-spotify`** | the Spotify **desktop app** on macOS with no credentials, or the **Web API** with a token you hold |\n| **`upnext-adapter-browser`** | any media element you control — browser, Electron renderer, webview, across a process boundary |\n| **`upnext-adapter-local`** | local files and streams via `ffplay`/`afplay` |\n| **`upnext-adapter-apple-music`** | your Apple Music library through the **Music app** — no credentials, **and it can search** |\n| **`upnext-adapter-nowplaying`** | whatever the **machine** is already playing, whichever app is playing it (macOS + Linux) |\n| **`upnext-adapter-process`** | an adapter written in any language, over a pipe |\n| **`upnext-desktop`** | all of the above wired for you, in one call — plus the `upnext` CLI |\n| **`upnext-mcp`** | the same, as an MCP server any agent can use |\n| **`upnext-http`** | the same, over HTTP with a live event stream |\n\n### What each one can actually do\n\nThe point of the capability model is that these differ, and say so:\n\n| | starts tracks | end of track | position | seek | pause | volume | search | someone else can change it |\n|---|:---:|---|---|:---:|:---:|:---:|:---:|:---:|\n| **browser** | ✅ | `event` | exact | ✅ | ✅ | ✅ | ❌ | no |\n| **local** (ffplay) | ✅ | `event` | estimated | ✅ | ✅ | ❌ | ✅¹ | no |\n| **local** (afplay) | ✅ | `event` | estimated | ❌ | ✅ | ❌ | ✅¹ | no |\n| **spotify** desktop | ✅ | `event` | exact | ✅ | ✅ | ✅ | ❌² | **yes** |\n| **spotify** web | ✅ | `event` | exact | ✅ | ✅ | ✅ | ✅ | **yes** |\n| **apple music** | ✅ | `poll` | exact | ✅ | ✅ | ✅ | **✅** | **yes** |\n| **nowplaying** | ❌³ | `poll` | exact | ❌ | ✅ | ❌ | ❌ | **yes** |\n\n¹ only when you point it at a music folder to index · ² the AppleScript\ndictionary cannot search a catalogue · ³ there is no way to ask macOS's Now\nPlaying register to start a specific track\n\nEvery ❌ there is a refusal rather than a silent failure. An adapter that claims\nit can seek and then doesn't is a bug you chase for an hour; these tell you\nfirst, and the runtime routes around them.\n\n**Not built yet:** Now Playing on Windows, and controlling one *specific*\nbrowser tab (needs an extension). Details at the [bottom](#not-built-yet).\n\n---\n\n## The problem\n\nEvery audio integration today puts the queue in the wrong place.\n\n```\n   WITHOUT upnext                          WITH upnext\n\n   caller                                  caller\n     │                                       │\n     │ \"play X\"                              │ enqueue / move / skip\n     ▼                                       ▼\n   Spotify Web API                     ┌───────────────┐\n     │                                 │   THE QUEUE   │ ← yours. one of them.\n     ▼                                 └───────┬───────┘\n   ┌───────────────┐                           │\n   │ Spotify queue │ ← the real one    ┌───────┼───────┬────────┐\n   └───────────────┘                   ▼       ▼       ▼        ▼\n                                    Spotify  Apple  browser   local\n   Now queue a YouTube video.                 Music    tab      file\n   Nowhere to put it.               Each one just plays what it's handed.\n```\n\nThat works right up until the next item isn't a Spotify track — and then there is\nnowhere to put it. **upnext inverts it:** the runtime owns the queue, and Spotify's\nqueue, Apple Music's Up Next and a browser tab's `<audio>` element all become\nplaces to send *one item at a time*.\n\n---\n\n## Quickstart\n\nThe fast way — every source this machine can reach, one call:\n\n```bash\nnpm i upnext-desktop\n```\n\n```ts\nimport { desktop } from 'upnext-desktop';\n\nconst audio = await desktop();\naudio.enqueue('spotify:track:1OWBh1eVxUdA1Z6UA8r4nh');\naudio.enqueue('https://example.com/podcast.mp3');\nawait audio.play();\n```\n\nIt ships a CLI too:\n\n```\n$ upnext now\n▶ Korea's STRANGEST Food is on Jeju Island!! — More Best Ever Food Review Show\n  Google Chrome · 23:20 / 24:11\n```\n\nThat is a YouTube tab, read with no browser extension.\n\n### Or give it to an agent\n\n```json\n{ \"mcpServers\": { \"upnext\": { \"command\": \"npx\", \"args\": [\"-y\", \"upnext-mcp\"] } } }\n```\n\nTwelve tools in Claude Desktop, Cursor or anything else that speaks MCP —\nincluding `media_adopt_current`, which puts what someone is already listening to\ninto the queue so the agent adds to it rather than talking over it.\n\n### Or wire it yourself\n\n```bash\nnpm i upnext-core upnext-adapter-local\n```\n\n`upnext-adapter-local` needs `ffplay` (from ffmpeg) or `afplay` (built into macOS).\n\n```ts\nimport { Runtime } from 'upnext-core';\nimport { LocalAdapter } from 'upnext-adapter-local';\n\nconst runtime = new Runtime({\n  // Absolute paths only — Node does not expand `~`.\n  adapters: [new LocalAdapter({ library: ['/Users/you/Music'] })],\n});\n\nruntime.on('item:started', ({ item }) => console.log('▶', item.ref.title));\nruntime.on('item:ended',   ({ item }) => console.log('■', item.ref.title));\n\nruntime.enqueue('file:///path/to/first.mp3');\nruntime.enqueue('file:///path/to/second.mp3');\n\nawait runtime.play();     // plays the first, then the second, on its own\nawait runtime.next();     // skip\n```\n\nOn a Mac with Spotify open, add a second source with nothing to sign up for:\n\n```bash\nnpm i upnext-adapter-spotify\n```\n\n```ts\nimport { SpotifyDesktopAdapter } from 'upnext-adapter-spotify';\n\nconst runtime = new Runtime({\n  adapters: [new LocalAdapter({ library: ['/Users/you/Music'] }), new SpotifyDesktopAdapter()],\n});\n\nruntime.enqueue('https://open.spotify.com/track/1OWBh1eVxUdA1Z6UA8r4nh');\nruntime.enqueue('file:///path/to/second.mp3');\nawait runtime.play();   // Spotify, then a local file, without either knowing\n```\n\nWant to hear it right now, with no files of your own?\n\n```bash\ngit clone https://github.com/tothienbao6a0/upnext && cd upnext\nnpm install && npm run demo    # synthesizes its own tones and plays them\n```\n\n---\n\n## How it works\n\n### The pieces\n\n```\n                          your application\n                                │\n   ┌────────────────────────────┼────────────────────────────┐\n   │  Runtime                   ▼                            │\n   │  ┌──────────┐  ┌──────────────┐  ┌────────────────┐     │\n   │  │  Queue   │  │    Binder    │  │   Prefetcher   │     │\n   │  │ ordered  │  │ which source │  │  resolve ahead │     │\n   │  │ id-based │  │  + fallback  │  │  of the head   │     │\n   │  └──────────┘  └──────────────┘  └────────────────┘     │\n   │  ┌──────────┐  ┌──────────────┐  ┌────────────────┐     │\n   │  │   Deck   │  │   Watcher    │  │   Reconciler   │     │\n   │  │  loaded  │  │  is it over  │  │  human took    │     │\n   │  │   item   │  │     yet?     │  │      over      │     │\n   │  └──────────┘  └──────────────┘  └────────────────┘     │\n   └────────────────────────────┬────────────────────────────┘\n                                │  Adapter interface\n    ┌──────────┬──────────┬────────┴───┬──────────┬──────────┐\n    ▼          ▼          ▼            ▼          ▼          ▼\n Spotify    Spotify    a media      local     whatever    anything\n desktop    Web API     element     files      is on      you write\n                     (browser /              (macOS Now\n                      Electron)               Playing)\n```\n\n### The life of one queue entry\n\n```\n  runtime.enqueue('something calmer')\n              │\n              ▼\n        ┌──────────┐   your resolveIntent()    ┌────────────┐\n        │ pending  │ ────────────────────────► │ unresolved │\n        └──────────┘   \"calmer\" → a MediaRef   └─────┬──────┘\n                                                     │ Binder picks a source\n                                                     ▼\n                                               ┌───────────┐\n                                               │   ready   │  bound, not yet playing\n                                               └─────┬─────┘\n                                                     │ play()\n                                                     ▼\n                                               ┌───────────┐\n                                               │  active   │  ◄── Watcher is watching\n                                               └─────┬─────┘\n                              ┌──────────────────────┼──────────────────────┐\n                              ▼                      ▼                      ▼\n                        ┌──────────┐           ┌──────────┐           ┌──────────┐\n                        │  ended   │           │ skipped  │           │  failed  │\n                        └──────────┘           └──────────┘           └──────────┘\n```\n\nEntries are prepared **before** the playhead reaches them, so an intent has\nalready become a real track on a real backend by the time it's needed — no\nsilence while a model thinks.\n\n### What `play()` actually does\n\n```\n1.  detach the deck, stop whatever was playing\n2.  mark the entry loading\n3.  intent?  ──► call your resolveIntent()          ──► MediaRef\n4.  Binder scores every adapter with match(ref)\n5.       ├─ resolve()      ──► a Binding\n6.       ├─ confidence check: is this actually the song asked for?   ← ⚠ the big one\n7.       ├─ load()\n8.       └─ play()                    any step fails ──► try the next source\n9.  Deck attaches, Watcher arms end-of-track detection\n10. emit item:started + one queue:changed\n```\n\n---\n\n## The two ideas that make it work\n\n### 1. Media is described, not located\n\nA queue entry is **not a URI**. It's a `MediaRef` — a description that binds to a\nsource as late as possible.\n\n```ts\n{ title: 'Bad Habit', artist: 'Steve Lacy', isrc: 'USUM72209293' }\n```\n\n| this gives you | because |\n|---|---|\n| enqueue before choosing a source | the entry doesn't name one |\n| automatic fallback mid-queue | if Spotify fails to load, the same ref goes to the next adapter |\n| queues portable between people | your Spotify and their Apple Music resolve the same ISRC |\n| queues that survive a restart | a saved queue reopens on a machine with different backends and still plays |\n\nStrong external ids (ISRC, MusicBrainz) are the join key; normalized title and\nartist are the fallback. **Resolutions are verified before they play** — an\nadapter returning *something* is not the same as it returning the right thing,\nand confidently playing the wrong song is the classic cross-source failure.\n\n> **A title needs a backend that can search.** A link says exactly what to play;\n> a title has to be looked up, and not every backend can look things up. The\n> Spotify *desktop* app is the sharp case — it plays a URI you hand it, but its\n> AppleScript dictionary cannot search a catalogue, so it scores **0** for a bare\n> title rather than guessing. On a default Mac setup that means nothing resolves\n> `{ title: 'Bad Habit' }`.\n>\n> On a Mac this is now answered for you: `upnext-adapter-apple-music` searches\n> your library and needs no credentials at all, so a plain title resolves out of\n> the box. Elsewhere, index a music folder, add a Spotify Web token, or supply\n> `resolveIntent` and answer it yourself. `upnext-desktop`'s `explainSetup()`\n> and `upnext doctor` both say which of those you have — this is a real gap and\n> it is better named than discovered.\n\n### 2. Capabilities describe what a backend actually *is*\n\nEvery backend sits somewhere on this line, and the runtime is correct across all\nof it:\n\n```\n  you own it completely  ◄──────────────────────────────►  someone else owns it\n\n  local file            browser tab          Apple Music         Spotify app\n  ───────────           ───────────          ───────────         ───────────\n  process exit          'ended' event        must be polled      must be watched\n  = end of track        = end of track\n  exact position        exact position       exact position      exact position\n  nobody else           nobody else          A HUMAN CAN         A HUMAN CAN\n  can touch it          can touch it         HIT NEXT            HIT NEXT\n```\n\n```ts\n{\n  endOfTrack:      'event',      // 'event' | 'poll' | 'none'\n  position:        'estimated',  // 'authoritative' | 'estimated' | 'none'\n  externalControl: true,         // can a human change this behind our back?\n  seek: true, pause: true, volume: false, search: true,\n}\n```\n\n`play: true` would be useless — every adapter can play. These are the flags that\nchange what the runtime and the caller actually *do*:\n\n| flag | if it's weak, the runtime… |\n|---|---|\n| `endOfTrack: 'poll'` | asks on an interval instead of being told |\n| `endOfTrack: 'none'` | runs a duration timer and marks the position a guess |\n| `position: 'estimated'` | extrapolates from a local clock |\n| `externalControl: true` | reconciles instead of assuming it's the only writer |\n\nThey're published **inline on playback state**, so this is one call, not a join\nagainst `adapterId`:\n\n```ts\nif (runtime.can('seek')) await runtime.seek(30_000);\n```\n\n#### Worked example: the same service, twice\n\n`upnext-adapter-spotify` ships two adapters for Spotify, and they are not\ninterchangeable:\n\n| | desktop app | Web API |\n|---|---|---|\n| credentials | **none** | OAuth token + Premium |\n| `search` | **`false`** | `true` |\n| runs on | macOS | anywhere |\n\n`search: false` is the interesting one. Spotify's AppleScript dictionary cannot\nsearch a catalogue. That could be faked — scrape something, guess — and then\nevery resolution of a title would be a coin flip dressed as a lookup. **An adapter\nthat says it cannot do a thing is correct and slightly limited; one that says it\ncan and then does it badly is broken.** So it declares `false`, scores `0` on\nanything that isn't already a Spotify link, and the entry goes to a backend that\ncan actually find it.\n\nThat is the whole capability model in one flag, and it is why capabilities belong\nto an adapter rather than to a service.\n\n---\n\n## When a human takes over\n\nYou queue three songs. The listener picks up their phone and hits next in Spotify.\n\n```\n   runtime thinks:   ▶ Nights          backend is actually playing:  ▶ Ivy\n                       ↑                                               ↑\n                       └───────────────── desync ──────────────────────┘\n\n   policy 'adopt'    (default)  →  Ivy becomes a real queue entry, playback continues\n   policy 'correct'             →  force the backend back to Nights\n   policy 'ignore'              →  report it, change nothing\n```\n\n**The human wins by default.** A queue that fights the person holding the\nkeyboard is a bug, not a feature.\n\nThe hard part is that \"the track I loaded is not the track that is playing\" has\ntwo opposite causes — our track *ended and the backend rolled on*, or a person\n*chose something else* — and they call for opposite responses. What separates\nthem is where the playhead was a moment ago, which is knowledge only the adapter\nhas. See [`adapter-spotify/src/sampler.ts`](./packages/adapter-spotify/src/sampler.ts)\nfor the real one.\n\n---\n\n## Intents are queue entries\n\n```ts\nruntime.enqueue('something calmer after this');\n```\n\nThat entry stays unresolved until the playhead gets close, then calls the\nresolver **your host supplies**:\n\n```ts\nnew Runtime({\n  resolveIntent: async (intent, ctx) => {\n    // ctx.nowPlaying is what the listener actually just heard\n    return await yourModel.pickTrack(intent, ctx);\n  },\n});\n```\n\n> **The core never calls a model, never holds an API key, never picks a\n> provider.** That boundary is what makes this embeddable in someone else's\n> product instead of being one agent with a `package.json`. The Spotify adapter\n> draws the same line around OAuth: you supply `getAccessToken`, it runs no flow.\n\nWithout a resolver it falls back to searching whatever adapters advertise\n`search`, so it's useful with nothing but adapters wired up.\n\n---\n\n## API\n\n```ts\n// queue — always addressed by stable id, never by index\nruntime.enqueue(input, position?)          // MediaRef | uri string | intent string\nruntime.enqueueMany(inputs, position?)\nruntime.move(id, { after: otherId }, expectVersion?)\nruntime.remove(id, expectVersion?)\nruntime.clear({ keepActive })\n\n// transport\nruntime.play(id?)        runtime.playNow(input)\nruntime.pause()          runtime.resume()        runtime.toggle()\nruntime.next()           runtime.previous()\nruntime.seek(ms)         runtime.setVolume(0..1)  runtime.stop()\n\n// how the queue is traversed\nruntime.setRepeat('off' | 'one' | 'all')\nruntime.setShuffle(true)\n\n// surviving a restart\nruntime.serialize()      // plain JSON — store it wherever\nruntime.restore(state)   // → { positionMs }; replaces the queue, starts nothing\n\n// reading\nruntime.can(capability)  // what the loaded backend supports, right now\nruntime.getState()       // { version, repeat, shuffle, playback, nowPlaying, queue, adapters }\nruntime.queue            // frozen read-only view\nruntime.search(query, { limit, adapterId })\n\n// events\nruntime.on(event, handler)  // returns an unsubscribe function\n```\n\n### Repeat and shuffle live above the backends\n\nSpotify has a repeat button. So does Apple Music. Neither knows about the browser\ntab queued behind it, so the only place the question can be answered once is\nabove all of them. The adapters don't touch their backend's own setting.\n\nTwo details worth knowing: **repeat-one still yields to `next()`**, because a\nrepeat mode that ignores the skip button is a trap; and **shuffle is a traversal\norder, not a re-ordering** — your list stays in the order you built it, and the\nruntime just picks differently. Inject `random` to make a shuffle reproducible in\na test.\n\n### Surviving a restart\n\n```ts\nawait fs.writeFile('queue.json', JSON.stringify(runtime.serialize()));\n\n// …next launch\nconst { positionMs } = runtime.restore(JSON.parse(await fs.readFile('queue.json', 'utf8')));\nawait runtime.play();\nawait runtime.seek(positionMs);\n```\n\nRestoring **never starts playback** — that's the host's call. And **bindings are\ndropped**: a binding is a live handle to a backend session, and none of that\nsurvives a restart, so every entry rebinds against the adapters that exist *now*.\n\nWhich is the payoff for describing media instead of locating it: a queue saved on\na machine with Spotify reopens on one without it, and still plays from somewhere\nelse.\n\n### Why positions are ids, not indexes\n\n```\n   caller reads queue:  [0] Nights  [1] Ivy  [2] Pyramids\n   caller decides:      \"move index 2 to the front\"\n   meanwhile a human:   removes Nights\n   the call lands:      moves Ivy.  Wrong song. No error.\n```\n\nSo it's `move(id, { after: otherId })`. Every mutation bumps a `version`, and any\nmutation can pass `expectVersion` to refuse a stale write.\n\n### Events\n\n| event | when |\n|---|---|\n| `item:started` / `item:ended` | a track began / finished, with the reason |\n| `item:resolved` | an intent became a real `MediaRef` |\n| `item:unresolvable` | lookahead failed — a warning, retried at play time |\n| `item:failed` | this entry cannot play |\n| `queue:changed` | **one per logical change**, not one per internal write |\n| `playback:changed` | status, position source, capabilities |\n| `position` | playhead moved |\n| `desync` | a human changed the backend under us |\n| `adapter:error` / `error` | a backend, or work nobody was awaiting, failed |\n\nEverything handed out is a **copy**, including event payloads. `runtime.queue` is\na frozen view with no mutators on it — not a type-level `Readonly` a cast could\ndefeat.\n\n---\n\n## Failure is a first-class case\n\nThree things look identical from a listener's chair — nothing is playing — so the\nruntime tells them apart:\n\n| what went wrong | what happens |\n|---|---|\n| **a backend lies** — claims `endOfTrack: 'event'` with no `subscribe` | rejected at `addAdapter`, listing every inconsistency at once |\n| **a backend breaks** — `init()` throws | excluded from selection; `getState().adapters` shows `available: false` and why |\n| **a backend hangs** — never returns | bounded by `timeoutMs` (30s default); falls through to the next source |\n| **you change your mind** — skip mid-`play` | the abandoned backend is *stopped*, not left playing alongside the new one |\n| **you use it after `dispose()`** | throws, rather than accepting a write to a queue nobody will ever hear |\n\n---\n\n## Writing an adapter\n\nRequired: `id`, `capabilities`, `match`, `resolve`, `load`, `play`, `stop`.\nEverything else is optional and gated by what you declare — a thirty-line adapter\nis a legitimate adapter.\n\n```ts\nimport { defaultCapabilities, type Adapter } from 'upnext-core';\n\nclass MyAdapter implements Adapter {\n  id = 'mine';\n  capabilities = { ...defaultCapabilities, endOfTrack: 'event', pause: true };\n\n  match(ref)          { return ref.uri?.startsWith('mine:') ? 1 : 0; }\n  async resolve(ref)  { return { adapterId: this.id, nativeUri: ref.uri, ref }; }\n  async load(binding) { /* … */ }\n  async play()        { /* … */ }\n  async stop()        { /* … */ }\n  subscribe(listener) { /* call listener({ type: 'ended' }) when a track finishes */ }\n}\n```\n\nTwo rules that matter more than the code:\n\n1. **Declare capabilities honestly.** When in doubt, declare the weaker thing. A\n   backend that says it can't seek is correct and slightly limited; one that says\n   it can and then doesn't is broken.\n2. **Return `null` from `resolve` rather than guessing.** The runtime tries the\n   next source, which beats confidently playing the wrong song.\n\n### …in any language\n\nAdapters don't have to be TypeScript, or even in this process.\n\n```\n   host                                 your child process\n   ────                                 ──────────────────\n   → {\"id\":1,\"method\":\"init\"}\n                                        ← {\"id\":1,\"result\":{\"capabilities\":{…}}}\n   → {\"id\":2,\"method\":\"resolve\", …}\n                                        ← {\"id\":2,\"result\":{\"nativeUri\":\"…\"}}\n   → {\"id\":3,\"method\":\"play\"}\n                                        ← {\"event\":{\"type\":\"ended\"}}\n```\n\nOne JSON object per line. No framing headers, no schema registry, no codegen.\n[`examples/python-adapter/adapter.py`](./packages/adapter-process/examples/python-adapter/adapter.py)\nis a complete working backend in ~150 lines of Python, covered by the test suite —\nthe runtime can't tell it apart from a native one.\n\n---\n\n## Packages\n\n| package | what it is |\n|---|---|\n| [**`upnext-core`**](https://www.npmjs.com/package/upnext-core) | queue, state machine, capabilities, events. **Zero dependencies, no I/O.** |\n| `upnext-core/testing` | a fake adapter whose capabilities you set |\n| `upnext-core/internal` | the pieces it's built from. Unsupported; they move. |\n| [**`upnext-adapter-local`**](https://www.npmjs.com/package/upnext-adapter-local) | files and streams via `ffplay`/`afplay`. No credentials. |\n| [**`upnext-adapter-spotify`**](https://www.npmjs.com/package/upnext-adapter-spotify) | the Spotify desktop app (macOS, no credentials) or the Web API (your token) |\n| [**`upnext-adapter-process`**](https://www.npmjs.com/package/upnext-adapter-process) | adapters as subprocesses, in any language |\n\n`upnext-core` does **no I/O at all** — no filesystem, no network, no clock it\nwasn't handed. It runs identically in Node, Bun, Deno, Electron, Tauri or a\nbrowser, and the entire suite runs in milliseconds with no fake-timer library\nand no flakes.\n\n---\n\n## Status\n\nEarly, but the core, the capability model and the adapter contract are real and\ntested. Every behavioural change carries a test, and CI runs the whole suite —\nplus the audible demo — on Node 20 and 22 across Linux and macOS on every push.\n\nSeveral bugs in this design were found by running the demo *out loud* rather than\nby reading code — a doubled end-of-track event, a late prefetch overwriting the\ntrack that had just started, two tracks playing at once after a cancelled skip.\nEach has a regression test. If you touch playback, run `npm run demo` and listen.\n\n<a name=\"not-built-yet\"></a>\n\n**Not built yet:**\n\n- **The Windows equivalent of Now Playing.** Windows has one — SMTC — and the\n  adapter's shape would carry over, but nobody has written it. macOS\n  (MediaRemote) and Linux (MPRIS, via `playerctl`) both work today, behind the\n  same `nowplaying:current` entry.\n- **Controlling one *specific* browser tab.** `upnext-adapter-nowplaying` already\n  reaches whatever the machine is playing, browser included, through macOS's\n  system Now Playing register — no extension needed. Singling out *one* tab among\n  several, though, does need a browser extension, and that is a control feature\n  rather than a queue one: you cannot queue into a tab you do not own.\n- **Gapless on backends we do not own.** `upnext-adapter-browser` is gapless\n  now — give it a `spare` element and the next track is buffered while the\n  current one plays, so the switch is instant. Spotify and Apple Music are not,\n  and cannot be from here: their AppleScript dictionaries have no way to hand\n  them a track to play next, so the runtime has to drive each transition and\n  that round trip is the gap.\n(All three transports ship now: `upnext-mcp`, the `upnext` CLI in\n`upnext-desktop`, and `upnext-http`.)\n\n---\n\n## Contributing\n\nThe most valuable thing you can contribute is an **adapter** — see\n[CONTRIBUTING.md](./CONTRIBUTING.md). The core is deliberately small and mostly\nfinished; what makes this useful is the number of places it can send audio.\n\nApache-2.0. Adoption is the only moat that matters for a substrate like this — a\nqueue abstraction is worthless unless other people's adapters target it.\n",
  "bytes": 27997,
  "sha": "d7d69e5d51046e71458e96aa8d916b82d06285ede0be06f3e448b13428627b32",
  "repo_slug": "tothienbao6a0/upnext",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_tothienbao6a0_upnext_2c1c4ed1/readme"
}