Files
furumi-tui/src/api/client.rs
T

611 lines
19 KiB
Rust

use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::sync::Mutex;
use super::auth::{self, AuthSession};
use super::models::{
ApiErrorBody, ArtistDetail, ArtistsPage, DevicePlaybackState, DevicePollResponse,
LikesResponse, LoginResponse, MeResponse, PlaylistCard, PlaylistDetail, ReleaseDetail,
SearchResults, TokensResponse, TrackItem,
};
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("{0}")]
Server(String),
/// Refresh token rejected or expired — the user must sign in again.
#[error("session expired, please sign in again")]
SessionExpired,
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("{0}")]
Other(#[from] anyhow::Error),
}
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(format!(
"furumi-tui/{} ({})",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS
))
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client config is static")
}
pub fn device_name() -> String {
format!("furumi-tui ({})", std::env::consts::OS)
}
pub fn device_user_agent() -> String {
format!(
"FurumiTUI/{} {}",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS
)
}
#[derive(Serialize)]
struct PasswordLoginRequest<'a> {
username: &'a str,
password: &'a str,
device_name: String,
}
#[derive(Serialize)]
struct SsoExchangeRequest<'a> {
code: &'a str,
device_name: String,
}
#[derive(Serialize)]
struct RefreshRequest<'a> {
refresh_token: &'a str,
}
#[derive(Serialize)]
struct LogoutRequest<'a> {
refresh_token: &'a str,
}
pub async fn login_password(
http: &reqwest::Client,
base_url: &str,
username: &str,
password: &str,
) -> Result<AuthSession, ApiError> {
let response = http
.post(format!("{base_url}/api/auth/password"))
.json(&PasswordLoginRequest {
username,
password,
device_name: device_name(),
})
.send()
.await?;
let login: LoginResponse = parse_response(response).await?;
Ok(AuthSession::new(
base_url.to_string(),
login.user,
login.tokens,
))
}
pub async fn login_sso_exchange(
http: &reqwest::Client,
base_url: &str,
code: &str,
) -> Result<AuthSession, ApiError> {
let response = http
.post(format!("{base_url}/api/auth/sso/exchange"))
.json(&SsoExchangeRequest {
code,
device_name: device_name(),
})
.send()
.await?;
let login: LoginResponse = parse_response(response).await?;
Ok(AuthSession::new(
base_url.to_string(),
login.user,
login.tokens,
))
}
/// Browser entry point for SSO. redirect_uri is either our loopback
/// listener (`http://127.0.0.1:{port}/callback`) or the `furumi://` deep
/// link as a manual-paste fallback.
pub fn sso_start_url(base_url: &str, redirect_uri: &str) -> String {
let mut url = reqwest::Url::parse(&format!("{base_url}/auth/mobile/oidc/start"))
.expect("base_url is pre-validated");
url.query_pairs_mut()
.append_pair("redirect_uri", redirect_uri);
url.to_string()
}
async fn refresh_tokens(
http: &reqwest::Client,
base_url: &str,
refresh_token: &str,
) -> Result<TokensResponse, ApiError> {
let response = http
.post(format!("{base_url}/api/auth/refresh"))
.json(&RefreshRequest { refresh_token })
.send()
.await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err(ApiError::SessionExpired);
}
parse_response(response).await
}
/// Mirrors the backend's PlaybackStateDto.
#[derive(Debug, Serialize)]
pub struct PlaybackStateBody {
pub current_track_id: Option<i64>,
pub position_ms: i32,
pub queue: Vec<i64>,
pub queue_position: i32,
pub shuffle: bool,
pub repeat_mode: String,
pub volume: f64,
}
#[derive(Serialize)]
struct DevicePollRequest<'a> {
device_id: &'a str,
user_agent: String,
current_jam_id: Option<&'a str>,
playback_state: Option<DevicePlaybackState>,
}
#[derive(Serialize)]
struct DeviceActiveRequest<'a> {
device_id: &'a str,
current_device_id: &'a str,
}
#[derive(Serialize)]
struct DeviceCommandRequest<'a> {
target_device_id: Option<&'a str>,
jam_id: Option<&'a str>,
command: &'a str,
payload: &'a serde_json::Value,
}
/// Percent-encode a query-string value.
fn url_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, ApiError> {
let status = response.status();
if status.is_success() {
return Ok(response.json().await?);
}
let message = match response.json::<ApiErrorBody>().await {
Ok(body) => body.error,
Err(_) => format!("server returned {status}"),
};
Err(ApiError::Server(message))
}
/// Authenticated API client. Owns the session; refreshes the access token
/// proactively (60s skew) and once more on 401, persisting rotated tokens.
/// The session mutex makes concurrent refreshes single-flight.
pub struct ApiClient {
http: reqwest::Client,
base_url: String,
session: Mutex<AuthSession>,
}
impl ApiClient {
pub fn new(http: reqwest::Client, session: AuthSession) -> Self {
Self {
http,
base_url: session.server_base_url.clone(),
session: Mutex::new(session),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn me(&self) -> Result<MeResponse, ApiError> {
self.get_json("/api/player/me").await
}
pub async fn artists(&self, page: i64, limit: i64) -> Result<ArtistsPage, ApiError> {
self.get_json(&format!("/api/player/artists?page={page}&limit={limit}"))
.await
}
pub async fn artist(&self, id: i64) -> Result<ArtistDetail, ApiError> {
self.get_json(&format!("/api/player/artists/{id}")).await
}
pub async fn release(&self, id: i64) -> Result<ReleaseDetail, ApiError> {
self.get_json(&format!("/api/player/releases/{id}")).await
}
pub async fn search(&self, query: &str, limit: i64) -> Result<SearchResults, ApiError> {
self.get_json(&format!(
"/api/player/search?q={}&limit={limit}",
url_encode(query)
))
.await
}
/// Open an audio stream for playback: background download backed by a
/// temp file, exposing blocking Read+Seek for the decoder; seeking into
/// not-yet-downloaded ranges uses HTTP Range requests.
///
/// The download client carries the bearer token valid at start; on very
/// long tracks a Range request after token expiry (15 min) can fail —
/// acceptable for now, a refreshing middleware can replace this later.
pub async fn open_stream(
&self,
path: &str,
) -> Result<(crate::player::TrackReader, Option<u64>), ApiError> {
use stream_download::Settings;
use stream_download::http::HttpStream;
use stream_download::source::SourceStream as _;
use stream_download::storage::temp::TempStorageProvider;
let token = self.fresh_access_token().await?;
let mut headers = reqwest::header::HeaderMap::new();
let value = format!("Bearer {token}")
.parse()
.map_err(|_| ApiError::Server("invalid token header".to_string()))?;
headers.insert(reqwest::header::AUTHORIZATION, value);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(ApiError::Network)?;
let url = format!("{}{path}", self.base_url)
.parse()
.map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?;
let stream = HttpStream::new(client, url)
.await
.map_err(|err| ApiError::Server(format!("stream open failed: {err}")))?;
let byte_len = stream.content_length();
let reader = stream_download::StreamDownload::from_stream(
stream,
TempStorageProvider::new(),
Settings::default(),
)
.await
.map_err(|err| ApiError::Server(format!("stream start failed: {err}")))?;
Ok((reader, byte_len))
}
/// Raw bytes (cover art, artist images) from a server-relative path.
pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>, ApiError> {
let url = format!("{}{path}", self.base_url);
let response = self
.send_authed(&url, |client, url, token| {
client.get(url).bearer_auth(token)
})
.await?;
let status = response.status();
if !status.is_success() {
return Err(ApiError::Server(format!("server returned {status}")));
}
Ok(response.bytes().await?.to_vec())
}
pub async fn playlists(&self) -> Result<Vec<PlaylistCard>, ApiError> {
self.get_json("/api/player/playlists").await
}
pub async fn playlist(&self, id: i64) -> Result<PlaylistDetail, ApiError> {
self.get_json(&format!("/api/player/playlists/{id}")).await
}
pub async fn create_playlist(&self, title: &str) -> Result<PlaylistCard, ApiError> {
#[derive(Serialize)]
struct Body<'a> {
title: &'a str,
}
self.post_json("/api/player/playlists", &Body { title })
.await
}
pub async fn add_tracks_to_playlist(
&self,
playlist_id: i64,
track_ids: &[i64],
) -> Result<(), ApiError> {
#[derive(Serialize)]
struct Body<'a> {
track_ids: &'a [i64],
}
let _: serde_json::Value = self
.post_json(
&format!("/api/player/playlists/{playlist_id}/tracks"),
&Body { track_ids },
)
.await?;
Ok(())
}
pub async fn likes(&self) -> Result<Vec<i64>, ApiError> {
let response: LikesResponse = self.get_json("/api/player/likes").await?;
Ok(response.track_ids)
}
pub async fn toggle_like(&self, track_id: i64) -> Result<bool, ApiError> {
#[derive(serde::Deserialize)]
struct Body {
liked: bool,
}
let body: Body = self
.post_json(&format!("/api/player/likes/toggle/{track_id}"), &())
.await?;
Ok(body.liked)
}
#[allow(
dead_code,
reason = "device-sync state restore needs id→track resolution"
)]
pub async fn tracks_by_ids(&self, track_ids: &[i64]) -> Result<Vec<TrackItem>, ApiError> {
#[derive(Serialize)]
struct Body<'a> {
track_ids: &'a [i64],
}
self.post_json("/api/player/tracks-by-ids", &Body { track_ids })
.await
}
/// Tell last.fm (via the server) what is playing right now. Called at
/// track start; the completed-play scrobble goes through /history.
pub async fn lastfm_now_playing(&self, track_id: i64) -> Result<(), ApiError> {
#[derive(Serialize)]
struct Body {
track_id: i64,
}
let _: serde_json::Value = self
.post_json("/api/player/lastfm/now-playing", &Body { track_id })
.await?;
Ok(())
}
/// Report a finished/aborted listen to the play history.
/// Body shape is the backend's HistoryEntry; `completed` marks a full
/// play (vs a manual skip).
pub async fn report_history(
&self,
track_id: i64,
started_at: Option<i64>,
listened_seconds: i32,
completed: bool,
) -> Result<(), ApiError> {
#[derive(Serialize)]
struct Body {
track_id: i64,
started_at: Option<i64>,
duration_listened: Option<i32>,
completed: bool,
}
let _: serde_json::Value = self
.post_json(
"/api/player/history",
&Body {
track_id,
started_at,
duration_listened: Some(listened_seconds),
completed,
},
)
.await?;
Ok(())
}
/// Persist playback state server-side (used for cross-device restore).
pub async fn push_state(&self, state: &PlaybackStateBody) -> Result<(), ApiError> {
let _: serde_json::Value = self.put_json("/api/player/state", state).await?;
Ok(())
}
pub async fn poll_device(
&self,
device_id: &str,
playback_state: Option<DevicePlaybackState>,
) -> Result<DevicePollResponse, ApiError> {
self.post_json(
"/api/player/devices/poll",
&DevicePollRequest {
device_id,
user_agent: device_user_agent(),
current_jam_id: None,
playback_state,
},
)
.await
}
pub async fn select_device(
&self,
target_device_id: &str,
current_device_id: &str,
) -> Result<DevicePollResponse, ApiError> {
self.post_json(
"/api/player/devices/active",
&DeviceActiveRequest {
device_id: target_device_id,
current_device_id,
},
)
.await
}
pub async fn send_device_command(
&self,
target_device_id: Option<&str>,
command: &str,
payload: &serde_json::Value,
) -> Result<(), ApiError> {
let _: serde_json::Value = self
.post_json(
"/api/player/devices/command",
&DeviceCommandRequest {
target_device_id,
jam_id: None,
command,
payload,
},
)
.await?;
Ok(())
}
/// Revoke this device's session server-side. Best effort: local
/// credentials are deleted regardless of the outcome.
pub async fn logout(&self) -> Result<bool, ApiError> {
let (access_token, refresh_token) = {
let session = self.session.lock().await;
(session.access_token.clone(), session.refresh_token.clone())
};
let response = self
.http
.post(format!("{}/api/auth/logout", self.base_url))
.bearer_auth(access_token)
.json(&LogoutRequest {
refresh_token: &refresh_token,
})
.send()
.await?;
#[derive(serde::Deserialize)]
struct LogoutResponse {
revoked: bool,
}
let body: LogoutResponse = parse_response(response).await?;
Ok(body.revoked)
}
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
self.json_request::<(), T>(reqwest::Method::GET, path, None)
.await
}
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
self.json_request(reqwest::Method::POST, path, Some(body))
.await
}
pub async fn put_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
self.json_request(reqwest::Method::PUT, path, Some(body))
.await
}
async fn json_request<B: Serialize, T: DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&B>,
) -> Result<T, ApiError> {
let url = format!("{}{path}", self.base_url);
let response = self
.send_authed(&url, |client, url, token| {
let mut request = client.request(method.clone(), url).bearer_auth(token);
if let Some(body) = body {
request = request.json(body);
}
request
})
.await;
let response = match response {
Ok(response) => response,
Err(err) => {
tracing::warn!(%err, %method, path, "api request failed");
return Err(err);
}
};
let status = response.status();
let result = parse_response(response).await;
if let Err(err) = &result {
tracing::warn!(%err, %status, %method, path, "api response error");
} else {
tracing::debug!(%status, %method, path, "api ok");
}
result
}
/// Send a request with a fresh bearer token; on 401, refresh once and
/// retry. `build` is called per attempt because RequestBuilder is not
/// reusable after send.
async fn send_authed<F>(&self, url: &str, build: F) -> Result<reqwest::Response, ApiError>
where
F: Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder,
{
let token = self.fresh_access_token().await?;
let response = build(&self.http, url, &token).send().await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
let token = self.refresh_after_rejection(&token).await?;
return Ok(build(&self.http, url, &token).send().await?);
}
Ok(response)
}
async fn fresh_access_token(&self) -> Result<String, ApiError> {
let mut session = self.session.lock().await;
if session.access_token_expired() {
self.refresh_locked(&mut session).await?;
}
Ok(session.access_token.clone())
}
/// A 401 with a token another task already rotated just retries with the
/// current token; otherwise this task performs the refresh itself.
async fn refresh_after_rejection(&self, rejected_token: &str) -> Result<String, ApiError> {
let mut session = self.session.lock().await;
if session.access_token != rejected_token {
return Ok(session.access_token.clone());
}
self.refresh_locked(&mut session).await?;
Ok(session.access_token.clone())
}
async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> {
let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await;
match result {
Ok(tokens) => {
session.apply_tokens(tokens);
if let Err(err) = auth::save_session(session) {
tracing::warn!(%err, "failed to persist rotated tokens");
}
tracing::debug!("access token refreshed");
Ok(())
}
Err(ApiError::SessionExpired) => {
auth::delete_session();
Err(ApiError::SessionExpired)
}
Err(err) => Err(err),
}
}
}