This commit is contained in:
Ultradesu
2026-06-10 16:11:09 +01:00
commit 39b955b6e7
31 changed files with 11526 additions and 0 deletions
+227
View File
@@ -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());
}
}
+455
View File
@@ -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),
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod auth;
pub mod client;
pub mod models;
+232
View File
@@ -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,
}