416 lines
15 KiB
Markdown
416 lines
15 KiB
Markdown
# 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.
|
|
|
|
```d2
|
|
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.
|
|
|
|
```d2
|
|
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.
|
|
|
|
```d2
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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).
|
|
|
|
```d2
|
|
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 `orchestrate` task `unwrap`s.** 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.
|