Compare commits
No commits in common. "86ce63ae61c1ba781fc51d367dd5f7585ee253dd" and "21e1b10c0bc9255b5e97f33400f787b71657ce99" have entirely different histories.
86ce63ae61
...
21e1b10c0b
|
|
@ -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.
|
||||
|
|
@ -11,7 +11,6 @@ let
|
|||
commonLibs = with pkgs; [ alsa-lib ];
|
||||
|
||||
extraPackages = with pkgs; [
|
||||
d2
|
||||
pkg-config
|
||||
protobuf
|
||||
];
|
||||
|
|
|
|||
|
|
@ -577,22 +577,23 @@ impl Client {
|
|||
#[instrument(skip(self))]
|
||||
pub async fn login_web(&mut self) -> Result<(), ClientError> {
|
||||
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
|
||||
// subscriber configured.
|
||||
println!("https://{}", code_response.verification_uri_complete);
|
||||
info!(
|
||||
expires_in = code_response.expires_in,
|
||||
interval = code_response.interval,
|
||||
"waiting for device login at https://{}",
|
||||
code_response.verification_uri_complete
|
||||
);
|
||||
// Poll no faster than the server asked for, and never busy-loop.
|
||||
let mut interval = code_response.interval.max(1);
|
||||
while started.elapsed().as_secs() <= code_response.expires_in {
|
||||
match self.poll_device_token(&code_response.device_code).await {
|
||||
Ok(Some(login_results)) => {
|
||||
while now.elapsed().as_secs() <= code_response.expires_in {
|
||||
let login = self.check_auth_status(&code_response.device_code).await;
|
||||
if login.is_err() {
|
||||
sleep(Duration::from_secs(code_response.interval)).await;
|
||||
continue;
|
||||
}
|
||||
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||
|
||||
let login_results = login?;
|
||||
{
|
||||
let mut login = match self.login.write() {
|
||||
Ok(login) => login,
|
||||
|
|
@ -608,27 +609,8 @@ impl Client {
|
|||
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;
|
||||
}
|
||||
warn!("device login attempt expired before it was authorized");
|
||||
Err(ClientError::AuthError("device login expired".to_string()))
|
||||
warn!("device login attempt expired");
|
||||
Err(ClientError::ConnectionError)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
|
|
@ -673,11 +655,14 @@ impl Client {
|
|||
};
|
||||
let body = serde_urlencoded::to_string(&data)?;
|
||||
|
||||
// Secret in the body, not Basic auth — see [`Self::poll_device_token`].
|
||||
let req = self
|
||||
.http_client
|
||||
.post(format!("{}/token", self.settings.oauth.base_url))
|
||||
.post("https://auth.tidal.com/v1/oauth2/token")
|
||||
.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")
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -685,16 +670,13 @@ impl Client {
|
|||
error!("{:?}", e);
|
||||
e
|
||||
})?;
|
||||
let status = req.status();
|
||||
if status.is_success() {
|
||||
if req.status().is_success() {
|
||||
let res = req.json::<RefreshResponse>().await?;
|
||||
Ok(res)
|
||||
} else {
|
||||
let body = req.text().await.unwrap_or_default();
|
||||
let snippet: String = body.chars().take(300).collect();
|
||||
Err(ClientError::AuthError(format!(
|
||||
"token refresh returned {status}: {snippet}"
|
||||
)))
|
||||
Err(ClientError::AuthError(
|
||||
"Failed to refresh access token".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
#[instrument(skip(self))]
|
||||
|
|
@ -727,92 +709,51 @@ impl Client {
|
|||
Ok(code)
|
||||
}
|
||||
|
||||
/// One poll of the device-flow token endpoint.
|
||||
///
|
||||
/// `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(
|
||||
#[instrument(skip(self))]
|
||||
pub async fn check_auth_status(
|
||||
&self,
|
||||
device_code: &str,
|
||||
) -> Result<Option<RefreshResponse>, PollError> {
|
||||
) -> Result<RefreshResponse, ClientError> {
|
||||
let req = DeviceAuthRequest {
|
||||
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()),
|
||||
scope: Some("r_usr+w_usr+w_sub".to_string()),
|
||||
grant_type: Some("urn:ietf:params:oauth:grant-type:device_code".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let payload =
|
||||
serde_urlencoded::to_string(&req).map_err(|err| PollError::Fatal(err.into()))?;
|
||||
let payload = serde_urlencoded::to_string(&req)?;
|
||||
let res = self
|
||||
.http_client
|
||||
.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)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| PollError::Transport(err.into()))?;
|
||||
|
||||
let status = res.status();
|
||||
let body = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| PollError::Transport(err.into()))?;
|
||||
|
||||
if status.is_success() {
|
||||
// NB: don't log the body here, it contains the tokens.
|
||||
return match serde_json::from_str::<RefreshResponse>(&body) {
|
||||
Ok(refresh) => Ok(Some(refresh)),
|
||||
Err(err) => Err(PollError::Fatal(ClientError::AuthError(format!(
|
||||
"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}"
|
||||
))))
|
||||
.map_err(|e| {
|
||||
error!("{:?}", e);
|
||||
e
|
||||
})?;
|
||||
if !res.status().is_success() {
|
||||
if res.status().is_client_error() {
|
||||
return Err(ClientError::AuthError(format!(
|
||||
"Failed to check auth status: {}",
|
||||
res.status().canonical_reason().unwrap_or("")
|
||||
)));
|
||||
} else {
|
||||
return Err(ClientError::AuthError(
|
||||
"Failed to check auth status".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
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)]
|
||||
mod tests {
|
||||
use crabidy_core::ProviderClient;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ pub enum ClientError {
|
|||
HttpClientError(#[from] reqwest::Error),
|
||||
#[error("internal serde url error")]
|
||||
SerdeUrlError(#[from] serde_urlencoded::ser::Error),
|
||||
#[error("authentication failed: {0}")]
|
||||
#[error("authentication failed")]
|
||||
AuthError(String),
|
||||
#[error("tidal api returned status {0}")]
|
||||
ApiError(u16),
|
||||
|
|
|
|||
Loading…
Reference in New Issue