Init
This commit is contained in:
+227
@@ -0,0 +1,227 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::models::{TokensResponse, User};
|
||||
|
||||
/// Margin before access-token expiry at which we refresh proactively,
|
||||
/// mirroring the Android/macOS clients.
|
||||
pub const EXPIRY_SKEW_SECONDS: i64 = 60;
|
||||
|
||||
/// Persisted session, same shape as the macOS client's AuthSession.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthSession {
|
||||
pub server_base_url: String,
|
||||
pub user: User,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_at_epoch_seconds: i64,
|
||||
}
|
||||
|
||||
impl AuthSession {
|
||||
pub fn new(server_base_url: String, user: User, tokens: TokensResponse) -> Self {
|
||||
Self {
|
||||
server_base_url,
|
||||
user,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_type: tokens.token_type,
|
||||
expires_at_epoch_seconds: now_epoch_seconds() + tokens.expires_in_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_tokens(&mut self, tokens: TokensResponse) {
|
||||
self.access_token = tokens.access_token;
|
||||
self.refresh_token = tokens.refresh_token;
|
||||
self.token_type = tokens.token_type;
|
||||
self.expires_at_epoch_seconds = now_epoch_seconds() + tokens.expires_in_seconds;
|
||||
}
|
||||
|
||||
pub fn access_token_expired(&self) -> bool {
|
||||
now_epoch_seconds() + EXPIRY_SKEW_SECONDS >= self.expires_at_epoch_seconds
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now_epoch_seconds() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn session_path() -> Option<PathBuf> {
|
||||
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("credentials.json"))
|
||||
}
|
||||
|
||||
pub fn load_session() -> Option<AuthSession> {
|
||||
let path = session_path()?;
|
||||
let text = fs::read_to_string(&path).ok()?;
|
||||
match serde_json::from_str(&text) {
|
||||
Ok(session) => Some(session),
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %path.display(), %err, "ignoring unreadable credentials file");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_session(session: &AuthSession) -> Result<()> {
|
||||
let path = session_path().context("cannot determine config directory")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(session)?;
|
||||
write_private(&path, &text).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn delete_session() {
|
||||
if let Some(path) = session_path() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
file.write_all(text.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> {
|
||||
fs::write(path, text)
|
||||
}
|
||||
|
||||
/// Same normalization rules as the Android client's ServerConfig:
|
||||
/// add https:// when no scheme, require http(s) with a host, reject
|
||||
/// credentials/query/fragment, lowercase the host, trim trailing slashes.
|
||||
pub fn normalize_base_url(raw: &str) -> Result<String> {
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
bail!("server URL is empty");
|
||||
}
|
||||
let with_scheme = if trimmed.contains("://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("https://{trimmed}")
|
||||
};
|
||||
let url = reqwest::Url::parse(&with_scheme).context("invalid server URL")?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
bail!("server URL must use http or https");
|
||||
}
|
||||
let host = url.host_str().filter(|h| !h.is_empty());
|
||||
let Some(host) = host else {
|
||||
bail!("server URL has no host");
|
||||
};
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
bail!("server URL must not contain credentials");
|
||||
}
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
bail!("server URL must not contain a query or fragment");
|
||||
}
|
||||
let mut normalized = format!("{}://{}", url.scheme(), host.to_ascii_lowercase());
|
||||
if let Some(port) = url.port() {
|
||||
normalized.push_str(&format!(":{port}"));
|
||||
}
|
||||
let path = url.path().trim_end_matches('/');
|
||||
normalized.push_str(path);
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Accepts what the user pastes after browser SSO: either the full
|
||||
/// `furumi://auth/callback?code=furu_mx_...` link (copied from the
|
||||
/// "Open Furumi" button) or the bare `furu_mx_...` code.
|
||||
pub fn extract_sso_code(input: &str) -> Result<String> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
bail!("paste the link or code first");
|
||||
}
|
||||
if input.starts_with("furu_mx_") {
|
||||
return Ok(input.to_string());
|
||||
}
|
||||
if let Ok(url) = reqwest::Url::parse(input) {
|
||||
if let Some((_, error)) = url.query_pairs().find(|(k, _)| k == "error") {
|
||||
bail!("SSO failed: {error}");
|
||||
}
|
||||
if let Some((_, code)) = url.query_pairs().find(|(k, _)| k == "code") {
|
||||
return Ok(code.into_owned());
|
||||
}
|
||||
}
|
||||
bail!("no furu_mx_ code found in the pasted text");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_adds_https_and_strips_slash() {
|
||||
assert_eq!(
|
||||
normalize_base_url(" Music.Hexor.cy/ ").unwrap(),
|
||||
"https://music.hexor.cy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_keeps_port_and_path() {
|
||||
assert_eq!(
|
||||
normalize_base_url("http://localhost:8000/furumi/").unwrap(),
|
||||
"http://localhost:8000/furumi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_bad_urls() {
|
||||
assert!(normalize_base_url("").is_err());
|
||||
assert!(normalize_base_url("ftp://x").is_err());
|
||||
assert!(normalize_base_url("https://user:pw@host").is_err());
|
||||
assert!(normalize_base_url("https://host?x=1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sso_code_from_deep_link() {
|
||||
let code =
|
||||
extract_sso_code("furumi://auth/callback?code=furu_mx_abc123").unwrap();
|
||||
assert_eq!(code, "furu_mx_abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sso_code_bare() {
|
||||
assert_eq!(extract_sso_code(" furu_mx_x ").unwrap(), "furu_mx_x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sso_error_is_reported() {
|
||||
let err = extract_sso_code("furumi://auth/callback?error=provider_denied")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("provider_denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_uses_skew() {
|
||||
let session = AuthSession {
|
||||
server_base_url: "https://x".into(),
|
||||
user: User {
|
||||
id: 1,
|
||||
name: "n".into(),
|
||||
role: "user".into(),
|
||||
},
|
||||
access_token: "a".into(),
|
||||
refresh_token: "r".into(),
|
||||
token_type: "Bearer".into(),
|
||||
expires_at_epoch_seconds: now_epoch_seconds() + 30,
|
||||
};
|
||||
assert!(session.access_token_expired());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::auth::{self, AuthSession};
|
||||
use super::models::{
|
||||
ApiErrorBody, ArtistDetail, ArtistsPage, 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-cli/{} ({})",
|
||||
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-cli ({})", 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,
|
||||
}
|
||||
|
||||
/// 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 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
|
||||
}
|
||||
|
||||
/// Report a finished/aborted listen to the play history.
|
||||
pub async fn report_history(
|
||||
&self,
|
||||
track_id: i64,
|
||||
started_at: Option<i64>,
|
||||
listened_seconds: i32,
|
||||
) -> Result<(), ApiError> {
|
||||
#[derive(Serialize)]
|
||||
struct Body {
|
||||
track_id: i64,
|
||||
started_at: Option<i64>,
|
||||
listened_seconds: i32,
|
||||
}
|
||||
let _: serde_json::Value = self
|
||||
.post_json(
|
||||
"/api/player/history",
|
||||
&Body {
|
||||
track_id,
|
||||
started_at,
|
||||
listened_seconds,
|
||||
},
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
parse_response(response).await
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
@@ -0,0 +1,232 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TokensResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub user: User,
|
||||
pub tokens: TokensResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code, reason = "rendered by the profile view in a later milestone")]
|
||||
pub struct MeStats {
|
||||
pub liked_tracks: i64,
|
||||
pub playlists: i64,
|
||||
pub plays: i64,
|
||||
pub listened_minutes: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code, reason = "rendered by the profile view in a later milestone")]
|
||||
pub struct MeResponse {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub stats: MeStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiErrorBody {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ArtistCard {
|
||||
#[allow(dead_code, reason = "opens the artist view in the next milestone")]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Relative path like `/api/player/cover/{file_id}/medium`.
|
||||
pub image_url: Option<String>,
|
||||
pub release_count: i64,
|
||||
pub track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ArtistRef {
|
||||
#[allow(dead_code, reason = "navigation to artists from track rows later")]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TrackItem {
|
||||
#[allow(dead_code, reason = "playback engine consumes this in milestone 3")]
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub track_number: Option<i32>,
|
||||
pub duration_seconds: f64,
|
||||
pub artists: Vec<ArtistRef>,
|
||||
pub featured_artists: Vec<ArtistRef>,
|
||||
#[allow(dead_code, reason = "jump-to-release navigation later")]
|
||||
pub release_id: i64,
|
||||
pub release_title: String,
|
||||
#[allow(dead_code, reason = "shown in queue/now-playing later")]
|
||||
pub release_year: Option<i32>,
|
||||
/// Server-relative path to `/api/player/stream/{id}`.
|
||||
pub stream_url: String,
|
||||
#[allow(dead_code, reason = "now-playing artwork in milestone 3")]
|
||||
pub cover_url: Option<String>,
|
||||
pub audio_format: Option<String>,
|
||||
pub audio_bitrate: Option<i32>,
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
pub file_size_bytes: Option<i64>,
|
||||
#[allow(dead_code, reason = "popularity column later")]
|
||||
pub lastfm_playcount: Option<i64>,
|
||||
}
|
||||
|
||||
impl TrackItem {
|
||||
pub fn artist_line(&self) -> String {
|
||||
let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect();
|
||||
if !self.featured_artists.is_empty() {
|
||||
names.push("feat.");
|
||||
names.extend(self.featured_artists.iter().map(|a| a.name.as_str()));
|
||||
}
|
||||
names.join(", ")
|
||||
}
|
||||
|
||||
pub fn duration_label(&self) -> String {
|
||||
let total = self.duration_seconds.round() as i64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
|
||||
/// Compact "FLAC 929k 24.3MB" suffix for track rows.
|
||||
pub fn tech_label_short(&self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(format) = &self.audio_format {
|
||||
parts.push(format.to_uppercase());
|
||||
}
|
||||
if let Some(bitrate) = self.audio_bitrate {
|
||||
parts.push(format!("{bitrate}k"));
|
||||
}
|
||||
if let Some(bytes) = self.file_size_bytes {
|
||||
parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// Full tech line for the status bar, including the sample rate.
|
||||
pub fn tech_label_full(&self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(format) = &self.audio_format {
|
||||
parts.push(format.to_uppercase());
|
||||
}
|
||||
if let Some(bitrate) = self.audio_bitrate {
|
||||
parts.push(format!("{bitrate}kbps"));
|
||||
}
|
||||
if let Some(rate) = self.audio_sample_rate {
|
||||
parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0));
|
||||
}
|
||||
if let Some(bytes) = self.file_size_bytes {
|
||||
parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReleaseCard {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub cover_url: Option<String>,
|
||||
pub track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub image_url: Option<String>,
|
||||
pub total_track_count: i64,
|
||||
pub total_play_count: i64,
|
||||
pub top_tracks: Vec<TrackItem>,
|
||||
pub releases: Vec<ReleaseCard>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UploaderSummary {
|
||||
pub name: String,
|
||||
#[allow(dead_code, reason = "per-uploader stats for a later detail popup")]
|
||||
pub track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub cover_url: Option<String>,
|
||||
pub artists: Vec<ArtistRef>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
pub uploaders: Vec<UploaderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PlaylistCard {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub track_count: i64,
|
||||
pub is_own: bool,
|
||||
pub owner_name: Option<String>,
|
||||
pub is_public: bool,
|
||||
#[allow(dead_code, reason = "save/unsave playlists later")]
|
||||
pub is_saved: bool,
|
||||
#[allow(dead_code, reason = "playlist kinds get distinct icons later")]
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlaylistDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
#[allow(dead_code, reason = "shown in a detail header later")]
|
||||
pub description: Option<String>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LikesResponse {
|
||||
pub track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct SearchResults {
|
||||
pub artists: Vec<ArtistCard>,
|
||||
pub releases: Vec<ReleaseCard>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
impl SearchResults {
|
||||
pub fn len(&self) -> usize {
|
||||
self.artists.len() + self.releases.len() + self.tracks.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistsPage {
|
||||
pub items: Vec<ArtistCard>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
#[allow(dead_code, reason = "part of the server pagination envelope")]
|
||||
pub per_page: i64,
|
||||
pub has_more: bool,
|
||||
}
|
||||
Reference in New Issue
Block a user