crabidy/architecture/overview.md

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.

  1. 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:50051 unauthenticated, so it assumes a trusted network.
  2. 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.
  3. State is ephemeral. The queue lives in memory and dies with the process. SaveQueue exists in the proto but is a stub.
  4. Provider count will grow. The path scheme and the ProviderClient trait are built for more than one provider, though only Tidal is implemented.
  5. 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 uuidpath and uuidspaths, 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 → playbackflume::bounded(64) of PlaybackMessage. Fire-and-forget, except Init, which carries a reply channel.

RPC and playback → providerflume::bounded(100) of ProviderMessage. Every command carries its own bounded(1) reply channel.

Playback → clientstokio::sync::broadcast(2048). Lossy by design: a slow client gets Status::data_loss and must resubscribe.

Playback ↔ engineflume::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 orchestrate task unwraps. A connection failure after startup panics that task rather than surfacing an error in the UI.
  • Refreshed tokens are not persisted. tidaly.toml is 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. ToggleMute is accepted and logged, and the MuteChanged update exists, but the engine has no mute.
  • SaveQueue is 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

  1. 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.
  2. Does the queue need persistence, and if so is SaveQueue the right shape — named saved queues, or a single autosaved session?
  3. Should the server authenticate clients, or is the trusted-network assumption permanent?
  4. 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.