15 KiB
Crabidy architecture overview
Status: as-built, describing the workspace at commit 634b89b (2026-07-20).
This document records the architecture of the existing system rather than proposing a new one. Where a design decision is load-bearing, the alternatives that were genuinely live are recorded alongside it, so a future change can see what was traded away.
Context and problem statement
Crabidy plays music from a streaming provider (currently Tidal) on the machine that runs the server, and is driven from a terminal UI that may run elsewhere. The split exists because the audio hardware and the user are not necessarily in the same place: the server owns the sound device, the queue, and the provider credentials; clients are thin and disposable.
The problems the design has to solve:
- Long-lived playback must survive provider failures. A track that cannot be fetched, an expired OAuth token, or a queue edit racing with a track transition must not silently kill playback. This was the dominant historical bug class.
- The library is a remote, paginated, slow tree. Browsing must not block playback, and playback must not block browsing.
- Multiple clients observe one shared state. Queue, play state, volume and position changes have to reach every connected client.
- Traces must be attributable. A command crossing a channel loses its caller's span by default, which previously made the logs actively misleading.
Assumptions
These are the assumptions the current design rests on. They were inferred from the code and the deployment shape, not confirmed in discussion — the ones that would most change the design if wrong are flagged.
- One server instance, one audio device, one user. There is no
multi-tenancy, no per-client playback, and no authentication on the gRPC
surface. Load-bearing: the server binds
0.0.0.0:50051unauthenticated, so it assumes a trusted network. - Clients are untrusted only in the sense of being buggy, not hostile. Paths from clients are validated for shape but the server will happily act on any well-formed path.
- State is ephemeral. The queue lives in memory and dies with the process.
SaveQueueexists in the proto but is a stub. - Provider count will grow. The path scheme and the
ProviderClienttrait are built for more than one provider, though only Tidal is implemented. - The provider is slow and unreliable; the audio device is fast and reliable. Timeouts, retries and error recovery are concentrated on the provider side.
Component structure
Five crates, split so that the wire contract and the provider abstraction do not depend on either the server or the client.
direction: right
core: crabidy-core {
explanation: |md
proto (tonic/prost)
ProviderClient trait
path helpers
|
}
server: crabidy-server {
explanation: |md
gRPC service
playback + provider loops
QueueManager
|
}
tidaldy: tidaldy {
explanation: |md
Tidal REST client
OAuth device flow
path parsing
|
}
audio: audio-player {
explanation: |md
rodio wrapper
engine thread
|
}
tui: cbd-tui {
explanation: |md
ratatui client
|
}
server -> core: proto + trait
tidaldy -> core: implements ProviderClient
server -> tidaldy: owns instance
server -> audio: commands
tui -> core: proto only
tui -> server: gRPC over TCP
crabidy-core is deliberately dependency-light: it holds the generated proto
types, the ProviderClient trait, and the path helpers (ROOT_PATH,
parent_path, join_path, path_segments). Both the server and the TUI
depend on it, which is what keeps the wire contract in one place.
Runtime structure
Inside the server process there are three concurrency domains, connected by channels rather than shared locks.
direction: down
grpc: tonic gRPC server {
handlers: RPC handlers
stream: GetUpdateStream
}
playback: Playback loop {
explanation: |md
tokio task
owns QueueManager
owns PlayState
|
}
provider: ProviderOrchestrator loop {
explanation: |md
tokio task
routes by path prefix
|
}
bridge: poll_play_bus {
explanation: |md
OS thread
PlayerMessage -> PlaybackCommand
|
}
engine: PlayerEngine {
explanation: |md
dedicated OS thread
rodio sink + decoder
|
}
tidal: Tidal REST API
device: Audio device
grpc.handlers -> playback: PlaybackMessage\nflume bounded(64)
grpc.handlers -> provider: ProviderMessage\nflume bounded(100)
playback -> provider: ResolveTracks / GetTrackUrls\nbounded(1) reply
provider -> tidal: HTTPS, 30s timeout
playback -> engine: PlayerEngineCommand\nflume bounded(16)
engine -> bridge: PlayerMessage
bridge -> playback: PlaybackCommand
engine -> device: PCM
playback -> grpc.stream: StreamUpdate\ntokio broadcast(2048)
Three properties of this layout matter:
The playback loop is the single writer of playback state. QueueManager
and PlayState sit behind mutexes inside Playback, but only the loop's own
handlers touch them, and no lock is ever held across an await. The mutexes
exist because handle_command takes &self, not because of contention.
The audio engine is a blocking OS thread, not a tokio task. rodio's sink is
synchronous and the decoder does real CPU work, so it would starve the async
runtime. The engine loop uses recv_timeout(250ms): a command wakes it,
otherwise the timeout fires and it emits an elapsed-position tick. It captures
a tokio::runtime::Handle at construction so it can block_on the HTTP stream
open, and falls back to building its own single-worker runtime if constructed
outside a tokio context.
End-of-stream is a callback, not an error. After the decoder, the engine
appends a rodio::source::EmptyCallback carrying a generation number. It fires
only when the decoder ahead of it drains naturally; reset() bumps the
generation, so an end-of-stream from a track that was already replaced is
recognised as stale and dropped. This replaced an earlier scheme that detected
end-of-stream by string-matching an io::Error message, which lost the signal
whenever the underlying error was anything else — the track would end and
nothing would advance the queue.
Path addressing
Library nodes and tracks are addressed by filesystem-like absolute paths whose first segment names the provider. The path encodes the position in the tree, so a parent is obtained by trimming a segment and no separate parent lookup is needed.
direction: right
root: "/" {
shape: page
}
tidal: "/tidal"
playlists: "/tidal/playlists"
playlist: "/tidal/playlists/<uuid>"
ptrack: "/tidal/playlists/<uuid>/<track-id>" {
style.fill: "#e8f4e8"
}
artists: "/tidal/artists"
artist: "/tidal/artists/<id>"
album: "/tidal/artists/<id>/<album-id>"
atrack: "/tidal/artists/<id>/<album-id>/<track-id>" {
style.fill: "#e8f4e8"
}
root -> tidal
tidal -> playlists
playlists -> playlist
playlist -> ptrack
tidal -> artists
artists -> artist
artist -> album
album -> atrack
Green nodes are track paths. Whether a path is a track or a node is decided by
its shape, not by a type tag: tidaldy::parse_path matches the segment
slice against a fixed set of variants (TidalPath::PlaylistTrack,
AlbumTrack, …) and is_track_path returns true for exactly the two track
variants. A path that matches no variant is rejected as MalformedPath rather
than being guessed at.
Routing is by prefix. ProviderOrchestrator serves / itself (a synthetic
root whose only child is /tidal) and delegates anything under /tidal to the
Tidal client. Adding a provider means adding a prefix arm and a
ProviderClient implementation.
The same path is the unit of queueing. ResolveTracks takes any path and
flattens it: a track path yields one track, a node path is walked breadth-first
over its queueable descendants. So "queue this playlist" and "queue this track"
are the same RPC with a different path.
Trade-off: paths versus opaque ids
The previous scheme used opaque ids (node:playlist:<id>, track:<id>). Paths
were chosen over keeping ids because the tree position is the thing the UI
actually needs — breadcrumbs, "go up", and knowing where a track came from all
fall out of the path for free, and LibraryNode.parent becomes derivable
rather than something the provider must remember to populate.
What this costs: a track's identity is now context-dependent. The same Tidal
track reached through a playlist and through an album has two different paths,
so de-duplication and "is this the track I'm already playing" comparisons
cannot be done by path equality alone. That has not bitten yet because the
queue stores resolved Track values, but it is a real constraint on any future
feature that needs stable track identity.
The proto migration kept field numbers and only renamed uuid → path and
uuids → paths, so the change was wire-compatible.
Tracing model
Commands cross channels, and a channel breaks the span parent-child relationship: by default the handler's events attach to whatever span happened to be current on the consumer task, which produced logs that attributed work to unrelated requests.
Every message is therefore an envelope pairing the command with the sender's span:
pub struct PlaybackMessage { pub span: Span, pub command: PlaybackCommand }
impl PlaybackMessage {
pub fn new(command: PlaybackCommand) -> Self {
Self { span: Span::current(), command }
}
}
The consumer creates a child of the captured span and instruments the whole handler future:
let handler_span = debug_span!(parent: &span, "playback_command", command = command.name());
self.handle_command(command).instrument(handler_span).await;
Instrumenting the future (rather than taking an enter() guard) is the part
that matters — a guard held across an await attributes the time another task
runs to this span.
The name() methods on PlaybackCommand and ProviderCommand exist to give
the span a low-cardinality label. Paths and positions are recorded as span
fields instead, so per-command spans stay groupable.
Server logs go to stderr through a non-blocking writer; the TUI cannot share
stderr with its own rendering, so it logs to a rolling daily file under the
state directory. Both honour RUST_LOG and default to debug for the workspace
crates, info for everything else.
Provider authentication
Tidal uses the OAuth 2.0 device authorization grant (RFC 8628). The client is
constructed, then init tries login_config (reuse stored tokens) and falls
back to login_web (print a link, poll until authorized).
shape: sequence_diagram
server: crabidy-server
tidaldy: tidaldy::Client
auth: auth.tidal.com
user: User { shape: person }
server -> tidaldy: init(toml)
tidaldy -> auth: POST /device_authorization
auth -> tidaldy: device_code, verification_uri, interval
tidaldy -> user: print link
loop: {
tidaldy -> auth: POST /token (secret in body)
auth -> tidaldy: 400 authorization_pending
}
user -> auth: authorizes in browser
tidaldy -> auth: POST /token
auth -> tidaldy: access_token, refresh_token, expires_in
tidaldy -> server: Ok(Client)
Two details are non-obvious and were the cause of real outages:
The client secret travels in the request body, not HTTP Basic auth. Tidal's
edge rejects Basic auth on /oauth2/token with a 403 HTML page. Because the
old poll loop treated every non-success response as "not yet authorized", that
403 was indistinguishable from a normal pending poll, and login appeared to
hang silently for the full five-minute window regardless of whether the user
authorized. The loop now classifies outcomes explicitly — authorization_pending
keeps polling, slow_down backs off by five seconds per RFC 8628, transport
errors retry, anything else aborts with the provider's actual error text.
Access tokens are refreshed proactively. ensure_fresh_token refreshes when
the token expires within five minutes; make_request additionally retries once
on a 401 in case the token was revoked or the clock is off. Without this, a
session that outlived its token failed every fetch, and playback died at the
next track boundary with no obvious cause.
Login state lives behind a std::sync::RwLock because the client is shared
immutably while tokens change at runtime. The lock is never held across an
await — a snapshot is cloned out, the request runs, then the result is stored.
Boundaries and interfaces
Client ↔ server — gRPC CrabidyService. Request/response, except
GetUpdateStream, which is server-streaming.
RPC → playback — flume::bounded(64) of PlaybackMessage.
Fire-and-forget, except Init, which carries a reply channel.
RPC and playback → provider — flume::bounded(100) of
ProviderMessage. Every command carries its own bounded(1) reply
channel.
Playback → clients — tokio::sync::broadcast(2048). Lossy by design:
a slow client gets Status::data_loss and must resubscribe.
Playback ↔ engine — flume::bounded(16) in both directions. Commands
carry reply channels; events flow back unsolicited.
Server ↔ provider API — HTTPS with a 30-second timeout. Failures are
classified into ProviderError.
The broadcast channel is the one place where delivery is deliberately lossy,
and that is surfaced rather than hidden: a lagging subscriber receives an
explicit data_loss status instead of quietly missing updates.
Reply channels are bounded(1) and awaited by the caller, which makes each
request/reply pair a rendezvous. This is why the provider loop must never block
indefinitely — the 30-second HTTP timeout is what bounds it.
Risks and known gaps
- No authentication or transport security. The gRPC surface is open on
0.0.0.0:50051. Fine on a trusted LAN, unsuitable for anything else. - The TUI uses unbounded channels between its UI thread and its RPC task, which contradicts the workspace's bounded-channel rule. The UI produces events at human speed so it has not caused problems, but it is unbounded buffering on a path that can block.
- The TUI's
orchestratetaskunwraps. A connection failure after startup panics that task rather than surfacing an error in the UI. - Refreshed tokens are not persisted.
tidaly.tomlis written once at startup; tokens refreshed during a session are lost on restart, forcing a new device login more often than necessary. - Mute is unimplemented.
ToggleMuteis accepted and logged, and theMuteChangedupdate exists, but the engine has no mute. SaveQueueis a stub that returns success without saving.- Single provider. The abstraction is there but has only one implementation, so its fit is unproven.
- The queue is memory-only. Process restart loses it.
Open questions
- Should track identity be decoupled from tree position, or is context-dependent identity acceptable long-term? This decides whether de-duplication and "already playing" checks are feasible.
- Does the queue need persistence, and if so is
SaveQueuethe right shape — named saved queues, or a single autosaved session? - Should the server authenticate clients, or is the trusted-network assumption permanent?
- When a second provider lands, does prefix routing stay in
ProviderOrchestrator, or should providers register themselves?
Next stage
This feeds api-design (stage 2). The most likely candidates for API work are
queue persistence and whatever contract a second provider needs.