Spotify client for macOS (and maybe later iPad and iOS).
- Language: Swift 6 language mode with strict concurrency, built with Xcode 27. The app
target defaults to
@MainActorisolation (SWIFT_DEFAULT_ACTOR_ISOLATION), so a type used off the main actor, or from the test target, needsnonisolated - Target Platforms: Latest Apple OSes only — macOS 27 is the deployment target (iOS and visionOS are listed at 27 too)
- UI Framework: SwiftUI
- Playback and Spotify Connect: Swift, in
Spotifly/SwiftLibrespot/, ported from librespot, which is where its type names come from. Ogg Vorbis decoding is the vendored C libvorbis inSpotifly/Vendor/.DEVELOPMENT.mdhas the architecture.
- Use Swift's strict concurrency features (
Sendable,@MainActor, async/await) - No backwards compatibility needed - target only the latest OS versions
- Format all Swift code with:
swiftformat --swiftversion 6.4 .
Also read AGENTS-twostraws.md for general development guidelines and best practices inspired by Paul Hudson's "Two Straws" approach.
All Spotify network requests must include debug logging. Add a log statement after constructing the URL string:
let urlString = "\(baseURL)/endpoint"
debugLog("SpclientAPI", "[GET] \(urlString)")- Use the appropriate HTTP method:
[GET],[POST],[PUT],[DELETE] - The first argument names the module making the request —
"SpclientAPI","PartnerAPI","KeymasterAuth", and so on debugLoglives inDebugLog.swiftand compiles to an empty inlinable function outside DEBUG builds, so it needs no#if DEBUGaround it
A track can have two ids. When a recording is not playable in the account's market,
Spotify substitutes one that is — a different id and uri for what a listener would call
the same song. Spotify's Web API, which the app no longer calls, names both, returning the
substitute as id and the id you asked for under linked_from. Which one you get depends
on the endpoint and on market.
The rule: the app keys everything on the id the API returned, and never rewrites it. Store keys, favorites, queue position, playback, writes — all the market id. Nothing in the app reconstructs an original, and no code should start.
The reason is that reconstruction is no longer possible. Search now runs on pathfinder, which
returns the market recording and carries no linked_from — there is nothing to trade the
substitute back for, and the substitute looks canonical from every angle. spclient is
id-faithful: it returns whatever id you ask for, so it hydrates entities without ever
introducing a second identity. Measured against a known pair on 2026-08-13 (Xavier Rudd, "The
Letter": original 459GknUJgpky3io0y482bi, DE substitute 7FcObTmCbQYyC8qzlTL2SE); the
detail is in plans/done/single-grant-partner-api.md.
So the choice is only which id every path agrees on, and the market id is the one every
path can produce. Send market=from_token everywhere it is supported and let the answer
stand: that is what makes a searched track and a saved track the same track.
This reverses the earlier rule, which normalised back to the original through a
RelinkableTrackCodable protocol. The two plans that introduced it were deleted on
2026-09-29 and are in git history. That rule existed because Spotify's
relinking docs
require the original id for Web API writes. It stopped being available once search moved to
pathfinder, and the mismatch it caused was live: a relinked track favorited from search saved
one id while the library row held the other, so the heart did not light and removal missed.
Relinking is many-to-one, which the market id inherits: several saved recordings can
substitute to the same playable one, so a library page can name the same track twice, and two
pages can each name it once. Collections keyed by track id must tolerate that — AppStore
deduplicates savedTrackIds across the whole list, and anything building a dictionary from
track ids uses uniquingKeysWith: rather than uniqueKeysWithValues:, which traps. A knock-on
to expect rather than fix: a list can be shorter than the total Spotify reports, since that
counts saved entries and the list counts tracks.
What the old rule was right about, and what still holds: one identity per track, or the
store corrupts. Two ids for one song means the queue points at a key store.tracks misses,
the track re-fetches forever behind a placeholder, and the recovery loader writes a second
entity. Consistency is the requirement; which id carries it is not.
Writes with a market id work — measured on 2026-08-13, against the relinked pair above. Spotify's docs warn that a substitute id "will likely return an error or other unexpected result" for saves and removals; it does not. Saving and removing "The Letter" by its market id both succeeded, and the proof is not the UI, which updates optimistically, but Spotify's own collection service pushing the change back over Mercury:
hm://collection/collection/<user>/json
{"items":[{"type":"track",…,"removed":true, "identifier":"7FcObTmCbQYyC8qzlTL2SE"}]}
{"items":[{"type":"track",…,"removed":false,"identifier":"7FcObTmCbQYyC8qzlTL2SE","addedAt":1786605160}]}
addedAt matches the second the request was sent, so the write was recorded rather than
accepted and dropped — and the collection service names the track by its market id, the
same one pathfinder returns. The removal also cleared an entry that had been saved under the
original id, which says Spotify resolves the relink on write rather than keying entries
literally. That is the reading, not a certainty: the library was not re-snapshotted first.
There is more than one track-shaped response, which is the part that bites. Pathfinder
answers with a different shape for an album's tracks, a playlist's items (saved tracks
included: Liked Songs is read as a playlist, LikedSongs) and search results, and spclient's
metadata has its own; each has its own Track initializer in
PartnerAPI/PathfinderEntities.swift or PartnerAPI/SpclientEntities.swift. The failure
mode is a hand-written conversion that reintroduces an original id from somewhere: the
request looks correct and the store is wrong.
When adding or changing a track-returning request:
- send
market=from_tokenwhere the endpoint supports it, as spclient's metadata does, so the id matches what pathfinder and playback use; - do not project or read
linked_from; if a response carries one, ignore it; - build entities through those initializers —
Track(pathfinder:)and its siblings,Track(spclient:id:)— rather than field by field. A hand-written conversion is how/searchonce came to disagree with everything around it while looking reasonable; - where a response is read without one, as the queue reads the cluster's uris, take the id
in the uri as given (
SpotifyAPI.parseTrackURI).
The app uses a normalized state store pattern (similar to Pinia/Redux) for data management.
AppStore (Store/AppStore.swift)
- Single source of truth for all entity data
- Normalized entity tables:
tracks,albums,artists,playlists - ID arrays for ordered collections:
savedTrackIds,userPlaylistIds,userAlbumIds,userArtistIds - Injected via
@Environment(AppStore.self)
PlayerModel (Store/PlayerModel.swift)
- What the UI shows of the player: the connection, the Connect devices and the active one,
the playback state, the queue with its context, the volume, and the last track playback
skipped or stopped at over an error (
PlaybackInterruption) - Fed by
LibrespotClient.snapshots: onePlayerSnapshotper change, and a slow main thread gets the newest rather than a backlog. The client never waits for the UI - Views read it via
@Environment(PlayerModel.self).QueueServiceandPlaybackViewModelfollow it withObservations. Nothing subscribes to the client directly, and nothing needs a hop to the main queue - The queue is the player's alone:
queueEntriesgives it as rows of track ids, written in the sameapplyasqueue, and the store holds only the tracks' metadata, whichQueueServiceasks for on every change of the queue and again when the network returns. No copy of the queue is kept anywhere else, so its rows and its context come from one snapshot - Commands still go through the static
SpotifyPlayerfacade
Entities (Store/Entities.swift)
- Unified data models:
Track,Album,Artist,Playlist,Device - Decoupled from API response types (conversions in
EntityConversions.swift, and for the partner APIs inPartnerAPI/PathfinderEntities.swiftandPartnerAPI/SpclientEntities.swift)
Services (Store/Services/)
- Handle API calls and update AppStore on success
- Each service takes
AppStorein its initializer, exceptDeviceService, which only transfers playback and writes its guess at the active device intoPlayerModel - Injected via
@Environment(XxxService.self) - Available services:
TrackService,AlbumService,ArtistService,PlaylistService,ProfileService,HomeService,SearchService,DeviceService,QueueService
Every fetch goes through InFlightRequests (Store/Services/InFlightRequests.swift), a
keyed single-flight registry. A second caller for the same key awaits the run the first
one started, and the run is an unstructured Task, so it is not cancelled when its
caller is — SwiftUI cancels a view's .task on teardown, and the detail views are torn
down routinely (selection changes, section switches, the 2→3 column flip). The result
lands in AppStore either way, and whatever view replaces the cancelled one reads it
from there.
try await albumRequests.run(albumId) {
try await self.loadAlbum(albumId: albumId)
}Requests that carry many IDs at once use BatchInFlightRequests
(Store/Services/BatchInFlightRequests.swift) instead, because one key to one run does
not fit them: track metadata and saved-status checks each cover a set of IDs, and the next
caller arrives with an overlapping but different set. It joins the runs already carrying
some of its IDs and starts one run for the remainder, which it hands to the operation:
try await metadataLoads.run(missingTrackIds) { uncoveredTrackIds in
// fetch only the IDs no current run covers
}Same guarantees as the keyed registry — unstructured runs, cache check before the run,
entries dropped on failure so the next caller retries. TrackService owns both batch
registries; route new track metadata through ensureTracksLoaded(trackIds:) rather than
adding a second fetch path.
Rules when adding a loading path:
- A key means one postcondition.
album:<id>always means "metadata and tracks are in the store". Two operations that fetch different amounts may not share a key. - Check the cache before the run. Return early when the store already holds what the
key promises, as
ensureAlbumLoadeddoes, so a cache hit costs no request at all. - A superseded run must not write.
cancel(_:)only asks; calltry Task.checkCancellation()after the network call, before touching the store. - Cache what was fetched, not what is non-empty.
detailsLoaded/tracksLoadedare set by the load, so a genuinely empty album is not re-fetched forever. - Views read the store, never a
@Statecopy of an entity. - Views do not write the store. A write belongs to the service that owns the fact, and a
view calls the service, even for an optimistic write such as the order shown while a
playlist row is dragged (
PlaylistService.previewMove). What the player reports is followed by a service observingPlayerModel(QueueService), not by a view's.onChange. Navigation state, such as the search the sidebar reopens to, is the coordinator's, not the store's.
The store and the services are a LoggedInSession, which the app holds for as long as the
account is signed in (AuthViewModel.sessions), not a window: closing the window keeps them,
and a reopened one shows the same session. A window keeps only its navigation. So a service's
registries survive any view's recreation; plans/done/section-request-pattern.md has why they
must, and plans/done/menu-commands-lose-the-session-with-the-window.md why not the window.
Everything logs through debugLog(module, message) in DebugLog.swift. It writes to
stderr in Debug builds and compiles away to nothing in Release, so there is no log level
to set and no environment variable to pass — building Debug is the switch.
Connect state transitions come from the modules that own them, so narrowing a run down means filtering on the module prefix:
./path/to/Spotifly.app/Contents/MacOS/Spotifly 2>&1 \
| grep -E 'SpircController|DealerConnection|LibrespotSession'SpircController— registration, PutState reasons, the cluster it is answered withDealerConnection— the WebSocket: connection id, cluster pushes, remote commands, pingsAccesspoint— the TCP connection to Spotify: connect, handshake, login, audio keysLibrespotSession,LibrespotClient— session lifecycle, recovery, command dispatchAudioPipeline,AudioOutput— track loads, decoding, position, end of track; the player's loader requests, seeks and stalls
plans/ holds one Markdown file per problem, named after it, in one of two folders:
plans/open/— not done: problems recorded but not planned, and those with a plan. Status says which, and ranks the ones being worked on. A plan being written lives on aplan/<topic>branch with a draft PR until it is merged.plans/done/— implemented. Kept because they record why the code does what it does, and code comments cite several. Move a plan here in the PR that finishes it, and set its status.
There is no index file; the folders are the index. A plan that no longer describes the code, and whose reasoning nothing relies on, is deleted rather than kept, since git history has it. Paths and plan names inside a done plan are as of when it was written, so a plan it names may since have been deleted.
Every plan has the same structure:
# <The problem, or what is built, in one line>
Status: **Open** or **Done**, then the date, PR or commits, and anything unconfirmed
Components: `the/files.swift` it touches
Found: <date>, <how it came up>
## Summary
## Problem
## Solution
## VerificationAnything else goes under one of the four as a ### section. An open plan with no solution
yet says so under Solution and Verification rather than leaving them out.
- Keep
CHANGELOG.mdin this repo up to date with all changes (detailed, technical notes welcome) - Use Keep a Changelog format with sections: Added, Changed, Fixed, Removed
- Add entries under
## [Unreleased]as you work
When ready to release, run: /release [version] (e.g., /release 1.1.5)
This will:
- Move
[Unreleased]entries to a new version section with today's date - Bump
MARKETING_VERSIONin the Xcode project - Update
../homebrew-spotifly/CHANGELOG.mdwith user-facing summary (temporary) - Commit both repos
After /release, you must:
- Push both repos
- Create a GitHub Release in this repo with the built .zip artifact
- Update the cask in homebrew-spotifly,
Casks/spotifly.rb: itsversionandsha256(the URL follows the version)
The ../homebrew-spotifly repo is temporary scaffolding for the Homebrew tap. Once the app is accepted into official Homebrew, it will be deleted. Until then:
- Releases are published to this repo (ralph/Spotifly)
- The homebrew-spotifly repo only contains the tap's cask and a user-facing changelog
- Both changelogs are updated during
/release