Compare commits

..

No commits in common. "86ce63ae61c1ba781fc51d367dd5f7585ee253dd" and "21e1b10c0bc9255b5e97f33400f787b71657ce99" have entirely different histories.

4 changed files with 59 additions and 534 deletions

View File

@ -1,415 +0,0 @@
# 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.

View File

@ -11,7 +11,6 @@ let
commonLibs = with pkgs; [ alsa-lib ]; commonLibs = with pkgs; [ alsa-lib ];
extraPackages = with pkgs; [ extraPackages = with pkgs; [
d2
pkg-config pkg-config
protobuf protobuf
]; ];

View File

@ -577,58 +577,40 @@ impl Client {
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn login_web(&mut self) -> Result<(), ClientError> { pub async fn login_web(&mut self) -> Result<(), ClientError> {
let code_response = self.get_device_code().await?; let code_response = self.get_device_code().await?;
let started = Instant::now(); let now = Instant::now();
// The verification link must reach the user even without a log // The verification link must reach the user even without a log
// subscriber configured. // subscriber configured.
println!("https://{}", code_response.verification_uri_complete); println!("https://{}", code_response.verification_uri_complete);
info!( info!(
expires_in = code_response.expires_in,
interval = code_response.interval,
"waiting for device login at https://{}", "waiting for device login at https://{}",
code_response.verification_uri_complete code_response.verification_uri_complete
); );
// Poll no faster than the server asked for, and never busy-loop. while now.elapsed().as_secs() <= code_response.expires_in {
let mut interval = code_response.interval.max(1); let login = self.check_auth_status(&code_response.device_code).await;
while started.elapsed().as_secs() <= code_response.expires_in { if login.is_err() {
match self.poll_device_token(&code_response.device_code).await { sleep(Duration::from_secs(code_response.interval)).await;
Ok(Some(login_results)) => { continue;
let timestamp = chrono::Utc::now().timestamp() as u64;
{
let mut login = match self.login.write() {
Ok(login) => login,
Err(poisoned) => poisoned.into_inner(),
};
login.device_code = Some(code_response.device_code);
login.access_token = Some(login_results.access_token);
login.refresh_token = login_results.refresh_token;
login.expires_after = Some(login_results.expires_in + timestamp);
login.user_id = Some(login_results.user.user_id.to_string());
login.country_code = Some(login_results.user.country_code);
}
info!("device login succeeded");
return Ok(());
}
Ok(None) => {
debug!(interval, "authorization pending");
}
Err(PollError::SlowDown) => {
// Per RFC 8628 the client must back off by 5 seconds.
interval += 5;
debug!(interval, "server asked us to slow down");
}
Err(PollError::Transport(err)) => {
// Transient network problems shouldn't kill the login.
warn!("device token poll failed, retrying: {err}");
}
Err(PollError::Fatal(err)) => {
error!("device login failed: {err}");
return Err(err);
}
} }
sleep(Duration::from_secs(interval)).await; let timestamp = chrono::Utc::now().timestamp() as u64;
let login_results = login?;
{
let mut login = match self.login.write() {
Ok(login) => login,
Err(poisoned) => poisoned.into_inner(),
};
login.device_code = Some(code_response.device_code);
login.access_token = Some(login_results.access_token);
login.refresh_token = login_results.refresh_token;
login.expires_after = Some(login_results.expires_in + timestamp);
login.user_id = Some(login_results.user.user_id.to_string());
login.country_code = Some(login_results.user.country_code);
}
info!("device login succeeded");
return Ok(());
} }
warn!("device login attempt expired before it was authorized"); warn!("device login attempt expired");
Err(ClientError::AuthError("device login expired".to_string())) Err(ClientError::ConnectionError)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@ -673,11 +655,14 @@ impl Client {
}; };
let body = serde_urlencoded::to_string(&data)?; let body = serde_urlencoded::to_string(&data)?;
// Secret in the body, not Basic auth — see [`Self::poll_device_token`].
let req = self let req = self
.http_client .http_client
.post(format!("{}/token", self.settings.oauth.base_url)) .post("https://auth.tidal.com/v1/oauth2/token")
.body(body) .body(body)
.basic_auth(
self.settings.oauth.client_id.clone(),
Some(self.settings.oauth.client_secret.clone()),
)
.header("Content-Type", "application/x-www-form-urlencoded") .header("Content-Type", "application/x-www-form-urlencoded")
.send() .send()
.await .await
@ -685,16 +670,13 @@ impl Client {
error!("{:?}", e); error!("{:?}", e);
e e
})?; })?;
let status = req.status(); if req.status().is_success() {
if status.is_success() {
let res = req.json::<RefreshResponse>().await?; let res = req.json::<RefreshResponse>().await?;
Ok(res) Ok(res)
} else { } else {
let body = req.text().await.unwrap_or_default(); Err(ClientError::AuthError(
let snippet: String = body.chars().take(300).collect(); "Failed to refresh access token".to_string(),
Err(ClientError::AuthError(format!( ))
"token refresh returned {status}: {snippet}"
)))
} }
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@ -727,92 +709,51 @@ impl Client {
Ok(code) Ok(code)
} }
/// One poll of the device-flow token endpoint. #[instrument(skip(self))]
/// pub async fn check_auth_status(
/// `Ok(Some(_))` means the user authorized us, `Ok(None)` means the
/// authorization is still pending and the caller should keep polling.
#[instrument(skip(self, device_code))]
async fn poll_device_token(
&self, &self,
device_code: &str, device_code: &str,
) -> Result<Option<RefreshResponse>, PollError> { ) -> Result<RefreshResponse, ClientError> {
let req = DeviceAuthRequest { let req = DeviceAuthRequest {
client_id: self.settings.oauth.client_id.clone(), client_id: self.settings.oauth.client_id.clone(),
// The secret must travel in the body: Tidal's edge rejects HTTP
// Basic auth on this endpoint with an HTML 403.
client_secret: Some(self.settings.oauth.client_secret.clone()),
device_code: Some(device_code.to_string()), device_code: Some(device_code.to_string()),
scope: Some("r_usr+w_usr+w_sub".to_string()), scope: Some("r_usr+w_usr+w_sub".to_string()),
grant_type: Some("urn:ietf:params:oauth:grant-type:device_code".to_string()), grant_type: Some("urn:ietf:params:oauth:grant-type:device_code".to_string()),
..Default::default() ..Default::default()
}; };
let payload = let payload = serde_urlencoded::to_string(&req)?;
serde_urlencoded::to_string(&req).map_err(|err| PollError::Fatal(err.into()))?;
let res = self let res = self
.http_client .http_client
.post(format!("{}/token", self.settings.oauth.base_url)) .post(format!("{}/token", self.settings.oauth.base_url))
.basic_auth(
self.settings.oauth.client_id.clone(),
Some(self.settings.oauth.client_secret.clone()),
)
.body(payload) .body(payload)
.header("Content-Type", "application/x-www-form-urlencoded") .header("Content-Type", "application/x-www-form-urlencoded")
.send() .send()
.await .await
.map_err(|err| PollError::Transport(err.into()))?; .map_err(|e| {
error!("{:?}", e);
let status = res.status(); e
let body = res })?;
.text() if !res.status().is_success() {
.await if res.status().is_client_error() {
.map_err(|err| PollError::Transport(err.into()))?; return Err(ClientError::AuthError(format!(
"Failed to check auth status: {}",
if status.is_success() { res.status().canonical_reason().unwrap_or("")
// NB: don't log the body here, it contains the tokens. )));
return match serde_json::from_str::<RefreshResponse>(&body) { } else {
Ok(refresh) => Ok(Some(refresh)), return Err(ClientError::AuthError(
Err(err) => Err(PollError::Fatal(ClientError::AuthError(format!( "Failed to check auth status".to_string(),
"could not decode token response: {err}" ));
)))),
};
}
// OAuth error responses carry the reason in an `error` field
// (RFC 8628 §3.5); Tidal additionally sets `sub_status`.
let oauth_error = serde_json::from_str::<OauthErrorBody>(&body).unwrap_or_default();
match oauth_error.error.as_deref() {
Some("authorization_pending") => Ok(None),
Some("slow_down") => Err(PollError::SlowDown),
_ if status.is_server_error() => {
Err(PollError::Transport(ClientError::ApiError(status.as_u16())))
}
_ => {
// Error bodies carry no secrets, so quoting them is safe
// and beats guessing why the flow died.
let body_snippet: String = body.chars().take(300).collect();
Err(PollError::Fatal(ClientError::AuthError(format!(
"token endpoint returned {status}: {body_snippet}"
))))
} }
} }
let refresh = res.json::<RefreshResponse>().await?;
Ok(refresh)
} }
} }
/// Outcome classification for one device-flow token poll.
#[derive(Debug)]
enum PollError {
/// The server asked us to poll less often (RFC 8628 `slow_down`).
SlowDown,
/// A transient failure; keep polling.
Transport(ClientError),
/// The flow cannot succeed anymore; stop polling.
Fatal(ClientError),
}
/// Lenient shape of an OAuth token-endpoint error body: only `error` is
/// used to classify the failure; the full body is logged separately for
/// anything not otherwise handled.
#[derive(Debug, Default, serde::Deserialize)]
struct OauthErrorBody {
error: Option<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crabidy_core::ProviderClient; use crabidy_core::ProviderClient;

View File

@ -49,7 +49,7 @@ pub enum ClientError {
HttpClientError(#[from] reqwest::Error), HttpClientError(#[from] reqwest::Error),
#[error("internal serde url error")] #[error("internal serde url error")]
SerdeUrlError(#[from] serde_urlencoded::ser::Error), SerdeUrlError(#[from] serde_urlencoded::ser::Error),
#[error("authentication failed: {0}")] #[error("authentication failed")]
AuthError(String), AuthError(String),
#[error("tidal api returned status {0}")] #[error("tidal api returned status {0}")]
ApiError(u16), ApiError(u16),