Spotuify
Spotuify
A daemon-backed, CLI-first, keyboard-native Spotify controller and music library runtime for the terminal. Same architectural blueprint as Mxr, pointed at music instead of email.
Project lives at: ~/code/bhekanik/spotuify/
Repository: https://github.com/planetaryescape/spotuify
In one paragraph
A single binary spotuify with subcommands. Bare spotuify opens a TUI; spotuify search, spotuify play, spotuify queue add, etc. are CLI subcommands that talk to a local daemon over length-delimited JSON. Unix builds use Unix-domain sockets. Windows builds use Tokio named pipes. The daemon owns runtime state (current playback, queue, devices), SQLite (cached metadata, canonical local truth), Tantivy (search, rebuildable), and playback via embedded librespot driving a Spotify Connect device. Everything else (TUI, CLI, MCP, agents, the macOS app) is a client of the same IPC contract. The CLI is the canonical surface, which is also why an agent can drive and verify every feature end to end.
The rules it shares with mxr
- Daemon owns state; clients are views. Even optimistic UI updates originate from the daemon as events, never flipped locally in the TUI. See Client-Agnostic Cores.
- CLI-first. Every TUI/MCP capability has an equivalent CLI surface in the same change. A feature that only lives in the TUI is incomplete.
- Mutations are dry-run-first through the same selection path as the real run. See Same-Code-Path Preview and Mutations Documented Dry-Run First.
- The crate dependency graph is the architecture, enforced by Cargo (
tests/workspace_boundaries.rs), not convention. There are 18 workspace packages: the root binary plus 17 crates.coredepends on nothing internal,protocol/store/searchdepend only oncore, and clients cannot reach pastprotocol. - Every external operation has a bounded timeout (keychain, Spotify Web API, librespot, IPC, image fetch). Non-negotiable. See Tokio.
The 2026-05-28 latency lesson
The slow-TUI investigation turned into a reusable daemon lesson: the daemon should keep useful music state warm even when no TUI is open, but that work must not compete with playback controls.
- Hot Path vs Warm Path - play, pause, seek, next, search, and explicit queue commands are hot. Queue warming is warm.
- Background Work Should Be Bounded and Supersedable - warm 3-5 queue tracks at a time; if a newer queue snapshot arrives, prefer the newer truth.
- Priority Lanes Need Downstream Isolation - a parallel IPC lane helps only if it does not converge on the same blocking actor or lock as the hot path.
- Preload Belongs to the Resource Owner - embedded audio preload goes through the player backend; a remote Spotify Connect device cannot be prewarmed from outside.
- Warm Reusable Truth, Not Screens - warm track metadata, lyrics, art, search records, queue snapshots, and player-owned preload state, not TUI-specific panels.
The 2026-05-28 device lesson
The "current daemon device is missing, TUI play chooses some other device" bug was a device-registry and fallback-policy bug, not a TUI bug.
Validated against code:
BackendKindis now embedded-only;spotifydandconnectparse as errors.- Legacy
[spotifyd] device_nameis still valid only as a config migration shim when[player] device_nameis absent. - The daemon tracks its own embedded device name/id and overlays a connected own-device into
DevicesListwhen the cached Spotify device registry omits it. preferred_deviceno longer falls through to an unrelated unrestricted device; playback target selection fails closed.- The daemon auto-spawn path detaches the child into its own process group so agent/test harnesses do not reap the daemon they just started.
Reusable notes from this:
- Fallbacks Are Product Decisions - a mutation fallback decides what the product may do on the user's behalf. Playing on a random visible Spotify device is a product decision, not a harmless default.
- Stale Registries Need Local Authority - Spotify's device list is a snapshot; the daemon's embedded player connection is local authority that should be represented while the remote registry catches up.
- Agent-Native Interfaces - the CLI contract let the bug be reproduced and verified without a human TUI loop.
- Generated Docs as Drift Defense - the docs site is generated from CLI help snapshots, so stale source help becomes stale public docs unless the snapshot moves too.
The 2026-05-29 auth prompt lesson
The keychain prompt storm was not just an auth bug. It was a side-effect bug.
Validated against code:
spotuifynow tries the mode-0600 disk auth mirror before touching macOS Keychain.- Keychain access that needs user approval maps to
AuthRequired. - The daemon latches
AuthRequired, emits one auth event, and avoids repeated interactive keychain reads while the latch is active. - The notification bridge deduplicates auth-error notifications by error kind.
- Recovery is explicit:
spotuify loginwrites fresh credentials, or a disk-backed recovery probe clears the latch.
Reusable note from this:
- Credential Prompts Are Side Effects - a background daemon should treat "needs the user" as state, not as something to rediscover every poll.
The 2026-06-02 startup seed lesson
The "rate limited on the first song after opening the app" bug was not really a
playback bug. Opening the TUI was spending Spotify Web API budget before the
user pressed play.
Validated against code:
Request::ClientSeedexists inspotuify-protocolas a client-specific,
cached startup snapshot.- The daemon handles
ClientSeedfromsnapshot_playback, cached queue,
cached devices, recent items, and visualizer diagnostics. No Spotify refresh
runs in that handler. - The TUI's
spawn_seedusesClientSeedon startup, event-stream reconnect,
and lag recovery. - The TUI refresh plan skips library refresh on the Player/Home startup path.
The reusable lesson is the same Hot Path vs Warm Path rule, but sharper:
client startup is not automatically a reason to call the provider. Seed the
client from reusable daemon truth, then let explicit actions and daemon-owned
warm loops refresh the world.
The 2026-06-02 player hot-path lesson
Release v0.1.35 fixed the part that optimism alone could not fix: the TUI
could show "paused" quickly while the audio kept playing for a few seconds.
That felt wrong because the physical side effect was still waiting behind a
slower path.
Validated against code:
- Embedded volume now uses librespot's
VolumeCtrl::Linear, caches the
user-facing 0..100 value, and reapplies it after activation so first playback
starts at the chosen volume instead of near silence. Request::PlaybackCommandfreezes the effective command before the daemon
applies optimistic playback state, so toggle resolves against the pre-command
clock instead of its own optimistic update.- The daemon emits optimistic
PlaybackChanged, then tries embedded local
transport with a bounded 250ms fast path. - The player actor has a dedicated transport channel and checks it before
normal player commands or warm audio preloads. - Background Spotify reconciliation persists the local result instead of
replaying an already-applied local command, which avoids double next/previous. - Queue-added tracks now schedule best-effort warming for metadata, cover art,
lyrics, search-index rows, and first audio preload. spotuify refresh-mediaand TUIUforce-refresh current cover art and
lyrics without clearing the existing display before the new data arrives.
Reusable notes from this:
- Optimistic UI Is Not the Side Effect - a paused icon is not the same as
stopped audio. - Priority Lanes Need Downstream Isolation - playback priority has to reach
the player actor, not just the IPC handler. - Warm Reusable Truth, Not Screens - queue warming belongs in the daemon
because cover art, lyrics, index rows, and audio preload serve every client.
The 2026-06-03 playlist context lesson
The playlist bug was a queue-truth bug, not just a playback bug. Starting a
playlist from the TUI did make sound, but the daemon did not publish the
context queue, so Home, Queue, CLI watchers, MCP clients, and agents could
still see an empty queue.
Validated against code:
PlayUrifor albums and playlists now resolves context items through
queueable_items_for_selection.- The daemon builds a
Queuewhere the first context item is
currently_playing, the rest areitems, andsession_activeis true. - It caches that queue, warms its URIs, and emits
QueueChanged { action: "play-context" }. - TUI
Enteron playlist rows and playlist tracks uses the same context-play
path. - TUI Space starts the selected Home, Search, Library, or Playlist item only
when the daemon has no resumable current item. Ended tracks and item-less
snapshots do not take the fast local resume path. - Search and Library selected-artwork previews now include shows and episodes,
not just albums and playlists. - Release
v0.1.36shipped with Homebrew andcargo install --tag v0.1.36
verified.
Reusable notes from this:
- Context Playback Must Publish Queue Truth - a context start is a queue
state transition, not only a play command. - Warm Reusable Truth, Not Screens - publish the reusable queue snapshot,
not a TUI queue rail. - Optimistic UI Is Not the Side Effect - fast local resume must refuse
stale or ended playback snapshots. - Agent-Native Interfaces - the CLI/log contract made the bug verifiable
without asking for screenshots.
The 2026-06-09 analytics provenance lesson
The original audit found Last.fm import and sample-counted playback still
unfinished. Both have since shipped, but the rule from that audit still holds:
imported history can enrich local analytics, but it is not the same thing as
observed playback.
Current code truth, revalidated 2026-08-12:
- Historical Last.fm import supports dry-run, apply, status, unresolved review,
idempotent promotion, and undo. Export is still a reserved follow-up. SessionTrackeruses the embeddedAudioCounterHandledelta for
audible_msand falls back to elapsed-minus-paused wall-clock time when no
counter is available.- Production session tracking writes
playback_progresssamples with audible
samples, sample rate, and channel count. - Imported scrobbles keep
measurement_kind = "lastfm_scrobble_import"because
Last.fm cannot reconstruct pauses, stop points, or progress samples.
Reusable notes from this:
- Imported History Is Provenance Not Playback Truth - a Last.fm scrobble is
a qualified external event, not proof of stop point, pause behavior, or
audible sample count. - Generated Docs as Drift Defense - generated CLI pages can still repeat a
stale source string. The truth chain is source help text, snapshot, generated
site, then grep for the old claim.
The 2026-08-12 comparison docs lesson
The ncspot and spotify-player comparison pages answered a real question: why
build this instead of using an existing terminal player? The answer is lineage
plus a boundary, not a feature contest.
Validated against current source and releases:
- ncspot v1.3.4 is still the mature, simpler choice when one terminal player is
enough. It owns its queue and supports more installation targets. - spotify-player v0.24.1 is the closest alternative. It has an optional daemon,
a useful CLI, broad configuration, synced lyrics, cover art, and its own audio
visualizer. Its CLI still talks to the running client over chunked localhost
UDP, and current auth uses two independent OAuth flows. spotuifyv0.1.84 has 60 top-level commands, 41 MCP tools, 18 workspace
packages, a provider capability command, SQLite, Tantivy, local analytics,
operation history, and four clients over daemon-owned state.spotuifydeliberately spends more machinery on a modern player layout,
album art, synced lyrics, and the bouncing-bar visualizer. Some users have
called out the visualizer specifically. That is a real preference, not a
claim that the other interfaces look bad.
Reusable notes from this:
- Durable Docs Avoid Opponent-Shaped Claims: say what was borrowed, explain
the design boundary, and name where the earlier project is better. - SOTA Claims Have Short Shelf Life: version numbers, command counts, auth
flows, and package matrices need dates. Architectural trade-offs last longer. - Generated Docs as Drift Defense: regenerating 134 CLI pages exposed two
commands,providersandunlike, that still lack individual help snapshots.
The 2026-06-02 release-lockfile lesson
The Release Please failure on PR 18 was not an upstream release-please bug. The
repo used release-type = "simple", so Release Please correctly updated the
manifest, changelog, and workspace version in Cargo.toml. Cargo still refused
--locked CI because Cargo.lock kept the old workspace package versions.
Current code truth:
release-please-config.jsonupdates workspaceCargo.tomlas an extra file..github/workflows/release-lockfile.ymlnow runs on same-repo release-please
PRs and commitsCargo.lockaftercargo update --workspacewhen needed.- CI still treats lockfile drift as a hard failure.
That belongs with Release PRs Must Own Derived Version State and Release Gates Encode Product Contracts. The release PR has to carry the whole version graph, not just the files a human reads first.
The 2026-06-09 V1 hardening lesson
The V1 shape is narrower than "Spotify app for everyone" and stronger because
of that. spotuify is BYO Spotify Developer app GA: users create their own
Spotify OAuth app, paste the client_id, and use PKCE. A fresh
first-party/keymaster setup opts in through SPOTUIFY_USE_FIRST_PARTY=1 because
keymaster-backed Web API polling gets a worse rate-limit posture.
Validated against code and docs:
Config::load()requiresclient_idfrom config orSPOTUIFY_CLIENT_ID
unless a caller has arranged fake-provider config.- Auth lives under
<config_dir>/auth/token.json; first-party lives under
<config_dir>/auth/first-party.json; the old runtime keyring story is gone. - With no auth override, stored credentials decide the mode. Dev-app credentials
remain primary for reads; both files enable hybrid dev-app reads and
first-party writes; first-party-only storage stays first-party across daemon
restarts. spotuify-protocol::ipc_streamnow keeps one JSON protocol over Unix sockets
on Unix and Tokio named pipes on Windows.- Windows x64 is a real release artifact:
spotuify-v{version}-windows-x86_64.zip. CI checks, tests, builds, and
fake-smokesx86_64-pc-windows-msvc. - Windows is still beta until a real Windows machine verifies login, daemon
startup, playback, and Task Scheduler install. - Homebrew install docs need the fully qualified formula plus
brew trust --formula planetaryescape/spotuify/spotuify. - The SwiftUI Spotuify macOS App is a separate client and packaging surface.
The DMG is built locally today, not by the tag-driven CI release. Signing and
notarization depend on the local build environment.
The reusable lesson is mostly Release Artifacts Are Product Promises:
publishing a Windows zip changes the product promise even if Windows remains
beta. Publishing a DMG changes a different promise again. The docs have to name
which surface they mean.
The 2026-06-09 daemon readiness lesson
The false "daemon did not become stable within 5s" failure was not a crash. It
was a readiness-budget mismatch.
Validated against current code:
run_daemon()still buildsDaemonState, callsensure_player_ready(), and
only then binds the IPC listener.START_DAEMON_TIMEOUTis now 60s because the packaged embedded player
registration can legitimately take up to 30s on macOS.- A unit test asserts the startup timeout covers that pre-socket player
registration budget.
The immediate fix is valid: do not declare failure before a legitimate startup
path has had time to finish. The deeper design note is [[Daemon Readiness Is Not
Process Liveness]]: if the daemon has slow pre-bind work, either the launcher
wait has to cover it, or the daemon should bind a small status surface early and
report player readiness separately.
The 2026-09-07 native audio teardown lesson
The silent-playback incident crossed three layers: Spotify control state,
Spotuify's native sink, and macOS CoreAudio. The first two looked alive while the
third could not play a one-second system sound.
Validated against release v0.1.102, runtime evidence, and origin/main at
97cdbd5:
- A successful play request and a connected Spotify session did not prove that
sound reached the local output. - The daemon samples the embedded sink's PCM counter every 2 seconds and treats
6 seconds of flat samples during owned playback as a stall. /usr/bin/afplayalso hung after the Spotuify daemon stopped. That isolated
the active fault to CoreAudio rather than Spotify, IPC, or the TUI.- A 2-second timeout around native player destruction only bounded the caller's
wait. The cleanup thread could still own PortAudio/CoreAudio state. - v0.1.102 latches
audio_teardown_blockedafter failed cleanup. Reconnect aborts
andregister_device()refuses a replacement sink until a clean daemon
restart. - After resetting CoreAudio,
afplaycompleted and Spotuify's PCM counter kept
advancing past the watchdog window.
The reusable rule is A Teardown Timeout Does Not Reclaim the Resource. The
debugging rule is Put the Probe Where the Fault Runs: use a system audio
probe below the app, then a PCM counter inside the sink. Process existence,
session connectivity, and a successful command are all too high in the stack.
The documentation audit found two other current truths. Release v0.1.102 exposes
65 user commands, excluding help, and 54 MCP tools. The public README and site
still said 58/60 commands and 41 tools. The v0.1.102 macOS CLI jobs also passed
while explicitly skipping signing because the repository secrets were absent.
Workflow capability is not artifact state.
The 2026-05-23 idiomatic audit
A full idiomatic-Rust pass against Idiomatic Rust Rubric. At the time, the 14 existing crates had zero unwrap/expect/panic calls in non-test production code. The findings that became reusable notes:
- Idiomatic Is Not Pedantic-Clean — the 2014-warning pedantic sweep was ~90% noise; the rubric's job is to say what to ignore.
- Carry Error Kinds Don't Re-derive Them — the
classify_error_kindsubstring-matching anti-pattern lived here. - Use VecDeque for Bounded FIFOs — the daemon event log's
Vec+remove(0)FIFO, fixed. - Refactor Behind a Behavior Test — the discipline used for every change, and the reason the keychain-bound auth fix was deferred rather than done blind.
Weak cells were structural (a 1190-line daemon dispatch function, a 79-field TUI App) and a few async-blocking spots, never safety or correctness. The high-risk rewrites were documented and deferred.
Gotchas worth remembering
- Dev builds default to instance
spotuify-dev; installed builds tospotuify. - Fresh setup defaults to dev-app PKCE.
SPOTUIFY_USE_FIRST_PARTY=1opts a fresh
setup into keymaster; when unset, stored credentials decide the mode. The
dev-app token lives in<config_dir>/auth/token.jsonwith mode 0600 on Unix. - Playback is embedded librespot only. Old
[spotifyd] device_nameconfig still migrates the preferred device name, but no spotifyd backend/process is managed. - The 2026-05-28 auth loop fix was really a shared-state fix: refresh uses
<config_dir>/auth/token.lock, reloads disk under lock, persists replacement refresh tokens, and purges revoked tokens only if the failed refresh token is still current. See Refresh Token Rotation Is Shared State. - A full
cargo test --workspacecan still be a poor agent default because some paths touch real config/auth state. Verify the daemon path withcli_fake_daemonorscripts/smoke.sh(fake provider, isolated IPC) instead. - Docs ship at
spotuify.appthrough Vercel; the project's Root Directory must besiteornpm cifails on the repo root.
See also
- Mxr — the architecture this copies; the reuse map is explicit in spotuify's docs
- Idiomatic Rust Rubric — the standard the audit ran against
- Tokio — the async hub spotuify's runtime rules come from
- Hot Path vs Warm Path - the async/daemon lesson from queue warming
- Release PRs Must Own Derived Version State - the release-please/Cargo.lock
lesson from PR 18 - Durable Docs Avoid Opponent-Shaped Claims - the comparison-page rule
- SOTA Claims Have Short Shelf Life - which comparison facts need dates
- A Teardown Timeout Does Not Reclaim the Resource - the native ownership
rule from the v0.1.102 audio incident - Put the Probe Where the Fault Runs - why command success and session
liveness were the wrong audio probes