Kopuz's playback core is a daemon. Every feature the built-in GUI has goes
through the gRPC surface described here. The contract is one file:
crates/proto/proto/kopuz.proto (package kopuz.v1).
This is a local IPC channel between the daemon and a frontend on the same machine. It is never exposed to a network, and it is not a stable public API -- the schema changes with the app, in the same commit.
Two deployment shapes serve the same API:
- Headless: run
kopuzd. It owns the audio engine, the SQLite library, the configured sources and their credentials, scan/sync jobs, downloads, scrobbling, and OS media integration (MPRIS/SMTC/Now Playing). No window, no webview. Build it withcargo build --release -p kopuz-daemon --features kopuzd --bin kopuzd. - Embedded: the desktop app runs that same core in its own process and
serves the identical API from it. This exists because SQLite is
single-writer:
kopuzdand the app must never run against one library at once, so a frontend that wants to attach while the app is open attaches to the app itself.
kopuzd flags: --socket <path>, --listen <ip>:<port>,
--token-file <path>, --db-path <file>.
Exclusive ownership is enforced with a lock file beside the database, so a second process pointed at the same library fails to start with a readable error rather than becoming a second writer.
The daemon listens on a Unix domain socket, created 0600:
- Linux:
$XDG_RUNTIME_DIR/kopuz/kopuzd.sock - macOS: the user cache dir,
~/Library/Caches/kopuz/kopuzd.sock(the exact path is logged at startup) - Windows: a named pipe,
\\.\pipe\kopuz-<user SID>, in place of the socket.--socketaccepts a\\.\pipe\name as is; any other path is flattened into one.
The path is the whole rendezvous -- there is no discovery file, no port and no token. There is no authentication. The socket's file mode is the access control: the kernel admits your own processes and refuses everyone else, which is the same boundary a token over loopback was reconstructing in userspace, minus the secret. On Windows the pipe is created with a DACL that admits only the creating user, and a client checks that the pipe it opened is owned by the same user before it speaks, since pipe names are one global namespace that anyone can claim first.
A leftover socket from a crashed daemon has no listener behind it, so a
refused connect is what marks it stale; kopuzd clears it and takes the
path. Anything at the path that is not a socket is left alone. A socket that
is being served makes a second kopuzd exit with AddrInUse rather than
stealing the channel. A pipe has no leftover to clear: it vanishes with its
process.
Server reflection (v1 and v1alpha) is registered, so grpcurl works out of
the box:
SOCK=$XDG_RUNTIME_DIR/kopuz/kopuzd.sock
grpcurl -unix -plaintext $SOCK list kopuz.v1.Kopuz
grpcurl -unix -plaintext $SOCK kopuz.v1.Kopuz/GetPlayerStatekopuzd --listen <ip>:<port> serves the same API as plain HTTP/2 on a port
as well, for clients that cannot open a socket: another machine, a browser
bridge, a language whose gRPC stack has no Unix socket support. A port has
no owner the kernel can check, so this transport is the one place a secret
comes back: every call must carry authorization: Bearer <token>, or it is
refused with UNAUTHENTICATED before it reaches a service. The token is
minted on first use and kept, 0600, in the token file (kopuzd.token
beside the socket by default; --token-file moves it). Binding beyond
loopback is allowed and logged with a warning: the token is then the only
thing between the network and the library, and the wire is not encrypted.
kopuzd --listen 127.0.0.1:7770
TOKEN=$(cat $XDG_RUNTIME_DIR/kopuz/kopuzd.token)
grpcurl -plaintext -H "authorization: Bearer $TOKEN" \
127.0.0.1:7770 kopuz.v1.Kopuz/GetPlayerStateThe Rust client dials it with client::GrpcApi::connect_tcp(address, token)
and presents the token on every call, the event stream included.
RPC failures are gRPC statuses, and the status code is the whole story — localize by it, never by the message. The mapping is one-to-one, so nothing rides alongside it in metadata:
| gRPC code | ErrorCode |
meaning |
|---|---|---|
| INVALID_ARGUMENT | invalid_input |
malformed request or out-of-range position |
| FAILED_PRECONDITION | source_auth_expired |
the media server needs a re-login |
| NOT_FOUND | not_found |
unknown key/id |
| ALREADY_EXISTS | conflict |
a single-flight job of that kind is already running |
| UNIMPLEMENTED | unsupported |
this daemon runs without that service |
| UNAVAILABLE | source_unreachable |
the media server did not answer |
| UNAVAILABLE | daemon_gone |
raised locally — nothing is listening on the socket |
| INTERNAL | internal |
daemon-side failure |
A failed mutation fails its own RPC with that status, so ordinary gRPC
error handling applies. Those last two share a status code because gRPC has one for "unreachable"
and both are: the daemon distinguishes them by whether the status carries a
transport cause, which only one tonic raised itself does. daemon_gone is
never sent -- the daemon cannot report its own absence.
The ErrorCode enum in the schema is for failures
reported inside a message, where there is no status to carry them, such
as JobStatus.error. Treat an unrecognized status code as internal.
Playback commands are ordinary unary RPCs: Play, Pause, Toggle,
Next, Previous, Stop, Seek {position_ms}, SetVolume {0..1}, and
SetMode {shuffle?, loop?}. Each returns MutationResult {rev}, where
rev names the state revision containing the command's effect, so you can
wait for your mirror to catch up before trusting it.
Kopuz/Subscribe is the event stream, one server-streaming RPC per client.
It carries events from the moment you subscribe -- there is no resume
cursor and no replay log. Both processes live and die together, so a stream
that ends means the daemon is gone, not that a connection blipped; the
answer is to reattach and refetch, which is what a cursor would have made
you do anyway.
- Attach order. Subscribe first, then fetch
GetPlayerStateand your queue window. Events emitted while the snapshot is in flight are already buffered for you, so nothing is missed. resyncmeans your mirror is untrustworthy: either you fell behind the buffer, or the daemon restarted under you. Refetch the snapshots.- Events arrive in an
EventEnvelope. The kinds mirror the state machine:player_state(full snapshot on every transition),position(new anchor after seek/pause),buffered,queue_changed,library_invalidated {table},job_progress,job_finished,config_changed,source_status,notice,resync.
Two rules make a frontend feel native:
- Position is an anchor, not a ticker.
PlayerState.positionis{ms, at_ms, playing}andnow_msis the daemon clock at send time. Compute a clock offset once and interpolate locally whileplayingis true. The daemon does not stream per-second ticks. intentvsphase.phaseis engine truth;intentis what the daemon is trying to do. Render optimistic UI fromintent(show the pause glyph while a track is still loading), exactly like the built-in GUI. Whilefadingis present, keep displayingfading.trackand drive the seek bar fromfading.position_ms.
Playback and queue:
| rpc | request | returns |
|---|---|---|
GetStatus |
{version, uptime_secs} |
|
GetPlayerState |
PlayerState snapshot |
|
GetQueue |
Page {offset, limit} |
play-order window {rev, total, items: [{index, track}]} |
GetQueueSnapshot |
the whole queue: {rev, items, shuffle_order, position?, shuffle} |
|
SetQueue |
mode + context (+ start_index/shuffle for replace) |
MutationResult {rev} |
EditQueue |
jump {index} / jump_physical {index} / move {from, to} / remove {index} / insert {index, keys} |
MutationResult {rev} |
GetExternalDevices |
{kind} |
where an integration can play: [{id, name, kind, active}] |
SelectExternalDevice |
{kind, device_id?} |
move playback there, or back to this app with no id |
Library:
| rpc | request | returns |
|---|---|---|
GetTracks |
TracksRequest {filter, page} |
{total, offset, items: [TrackInfo]} |
GetFolderTracks |
{prefix, page} |
same shape |
GetTracksByKeys |
{keys} |
rows in the order asked for |
GetAlbums / GetAlbum / GetAlbumTracks |
album rows and their tracks | |
GetRecentlyAddedAlbums |
Page |
newest first, by each album's newest track |
GetArtists / GetArtistTracks / GetArtistSampleTracks |
artist rows and their tracks | |
GetGenres / GetTopGenre / GetGenreTracks |
||
GetRecentTracks |
Page |
most recently played first |
Search |
{query} |
tracks and albums; a remote source answers over the network |
GetTrackWebUrl / GetAlbumWebUrl |
{key} / {id} |
the source's public page, absent when it has none |
GetStats |
{listen_counts: {uid: count}} |
|
GetLyrics |
{key} |
{plain?, synced: [lines with word timing]} |
GetFavorites / SetFavorite |
{refs, generation}; a rejected remote push reverts and emits a notice |
|
RefreshArtistArtwork |
{names} |
find photos for these artists |
Browsing what the library does not hold:
| rpc | request | returns |
|---|---|---|
GetCatalog |
{continuation?} |
shelves of tiles; songs come as TrackInfo and are registered, so their keys queue |
GetCatalogDetail |
{kind, id, continuation?} |
one album, playlist or artist: its tracks, or its own shelves |
GetRadioStations |
every station the configured registries hold | |
SearchRadio |
{query, limit} |
the public directory; hits join the live registry |
PinRadioStation |
{id, pinned} |
|
ValidateRadioRegistry |
{url} |
how many stations that URL holds, or invalid_input |
Playlists: GetPlaylists, CreatePlaylist, RenamePlaylist,
DeletePlaylist, AddPlaylistTracks, RemovePlaylistTrack,
ReorderPlaylist, RefreshPlaylist, and the folder equivalents.
Changing the library: UpdateTrackMetadata (tags and the embedded cover in
one call), DeleteTracks {keys, from_disk}, DeleteAlbum, UploadArtwork,
RemoveArtwork. A from-disk delete is refused for anything outside the
configured library roots.
Jobs: StartJob {kind} (scan / library_sync / favorites_sync /
playlist_sync), CancelJob, StartDownloads {keys}, RemoveDownload,
GetDownloadStatuses, StartYtdlp {url, output_dir, format, options}.
A job kind is single-flight: a second start returns ALREADY_EXISTS.
Sources and integrations: GetSources, SelectSource, UpsertServer,
DeleteServer, UpsertLocalSource, DeleteLocalSource,
SetSourceDirectories, ProvisionCredentials, LoginSource,
ClearCredentials, AuthenticateSource, BrowseSource, ValidateSource,
CanOpenBrowser, and GetIntegrations / ProvisionIntegration /
ClearIntegration / AuthenticateIntegration for the scrobblers.
Settings: GetConfig, SetConfig, PreviewEqualizer (heard, not kept).
Artwork: GetArtwork {target, hq} streams ArtworkChunk, the first
carrying content_type.
Queue contexts materialize daemon-side, so "play this album" never
round-trips a track list through the client: tracks {keys}, album {id},
artist {name}, genre {name}, playlist {id}, filter {TrackFilter},
radio {station_id, stream_id}, track_radio {key} and playlist_radio {id} for a source's own mixes.
TrackInfo: key (the library ref -- use it for queueing, favorites and
lyrics), uid (the same track qualified by its source,
"<service>:<id>"), title, artist, artists, album, album_id,
duration_ms?, khz, bitrate, track_number?, disc_number?, kind
(normal/radio), seekable, offline, service?, the MusicBrainz ids,
playlist_item_id?, and artwork?.
Rows carry a reference, not an image: ArtworkRef {target, version}, where
target names the entity (track, album, artist, playlist,
catalog, station) and version changes exactly when the picture does.
An absent ref means the entity has no picture, so a client draws its
placeholder and asks for nothing.
GetArtwork streams the bytes for a ref's target. Two things follow from
that shape:
- The bytes are the contract. A client that cannot render a URL asks for a ref and gets an image; nothing needs a webview.
- The version is the cache key. It is a hash of the resolved cover reference, so a response is safe to cache forever and a new photo, a re-uploaded cover or a rotated tag produces a different ref.
Why it works this way: a Jellyfin or Subsonic cover URL is signed with the account's credentials, and those never leave the daemon. A client that built its own URL could only ever fetch local files.
Some services do not hand out decodable audio. Spotify plays itself, either in a browser tab running its Web Playback SDK or on a Connect device the account already owns, and the daemon drives both.
To a client it looks like ordinary playback. PlayerState.external names
the integration and the device when one owns playback; the transport
commands are the same ones; the queue, the shuffle order and what plays
next stay the daemon's. When the queue reaches a track only the integration
can play the daemon hands it over, and when it reaches one the engine can
play it hands back.
GetExternalDevices {kind} lists where an integration can play and
SelectExternalDevice {kind, device_id?} moves it, with an absent id
meaning this machine's own player.
pip install grpcio grpcio-tools
python -m grpc_tools.protoc -I proto --python_out=. --grpc_python_out=. proto/kopuz.protoimport os, pathlib
import grpc
import kopuz_pb2 as pb
import kopuz_pb2_grpc as rpc
sock = pathlib.Path(os.environ["XDG_RUNTIME_DIR"]) / "kopuz/kopuzd.sock"
channel = grpc.insecure_channel(f"unix:{sock}")
stub = rpc.KopuzStub(channel)
state = stub.GetPlayerState(pb.GetPlayerStateRequest())
print(state.track.title if state.HasField("track") else "nothing playing")
print("toggled at rev", stub.Toggle(pb.ToggleRequest()).rev)- A daemon built without a service answers UNIMPLEMENTED for its RPCs instead of lying; probe once and hide the feature. Both shapes serve everything today.
- Browser frontends need a grpc-web proxy in front of the daemon; the daemon itself speaks plain gRPC only.
- Browser sign-ins (YouTube Music, Apple Music, SoundCloud, Spotify) are
run by the daemon, so they need a machine it can open a browser on. Ask
CanOpenBrowserfirst: a sandboxed daemon says no. Local files, Jellyfin, Subsonic/Navidrome and Nextcloud need nothing but the daemon. - Spotify audio comes out of a browser with Widevine, spawned by the daemon, so a headless box with no browser cannot play it -- though it can still drive a Connect device that can.
Config mirrors the app's settings struct field for field. It is a real
message, not JSON in a string: the settings are a closed Rust struct on both
ends of one binary, so the schema says so and a wrong key or type fails to
compile rather than at runtime.
Two groups of fields never cross: credentials (media-server logins, Last.fm
and Libre.fm keys, the MusicBrainz token) and machine-local path state
(offline_tracks). They come back as defaults in the view, and SetConfig
ignores whatever you send for them -- the daemon keeps its own -- so reading
a view and writing it straight back cannot erase them.
SetConfig replaces the surface wholesale rather than patching: read the
view, change what you want, send it back. The daemon diffs it against what
it holds and reports only the keys that actually changed in
config.changed. locked_keys are pinned by a managed settings layer -- a
read-only or Nix-store settings.toml, a settings.d drop-in, or a
KOPUZ_CONFIG_* override -- and changing one is refused; leaving it at the
value you read is not, so a read-modify-write of any other key still works.