init federation
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Local development override for the frid workspace; delete after pushing frid.
|
||||
[patch.'https://gt.hexor.cy/ab/frid.git']
|
||||
music-dht = { path = "../../frid/crates/music-dht" }
|
||||
Generated
+1976
-483
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -9,22 +9,23 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
crokey = "1.4.0"
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
directories = "6.0.0"
|
||||
futures-util = "0.3.32"
|
||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
||||
open = "5.3.5"
|
||||
lofty = "0.22"
|
||||
# P2P federation: library index in a shared DHT + audio streaming between
|
||||
# peers (same protocol as furumi-fd).
|
||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
|
||||
ratatui = "0.30.1"
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
|
||||
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
||||
stream-download = { version = "0.24.1", default-features = false, features = ["reqwest-rustls", "temp-storage"] }
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
|
||||
toml = "1.1.2"
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
|
||||
-281
@@ -1,281 +0,0 @@
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
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 seconds_until_access_expiry(&self) -> i64 {
|
||||
self.expires_at_epoch_seconds - now_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 now_epoch_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() 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 Some(path) = session_path() else {
|
||||
tracing::warn!("cannot determine config directory; no stored auth session loaded");
|
||||
return None;
|
||||
};
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||
tracing::debug!(path = %path.display(), "credentials file not found");
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %path.display(), %err, "failed to read credentials file");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_str::<AuthSession>(&text) {
|
||||
Ok(session) => {
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
server = %session.server_base_url,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"loaded stored auth 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)?;
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
server = %session.server_base_url,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"persisting auth session"
|
||||
);
|
||||
write_private(&path, &text).with_context(|| format!("writing {}", path.display()))?;
|
||||
tracing::debug!(path = %path.display(), "auth session persisted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_session() {
|
||||
if let Some(path) = session_path() {
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => tracing::info!(path = %path.display(), "deleted stored auth session"),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||
tracing::debug!(path = %path.display(), "stored auth session already absent");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %path.display(), %err, "failed to delete stored auth session")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -1,778 +0,0 @@
|
||||
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 device_name = device_name();
|
||||
tracing::info!(%base_url, %device_name, "password login request started");
|
||||
let response = match http
|
||||
.post(format!("{base_url}/api/auth/password"))
|
||||
.json(&PasswordLoginRequest {
|
||||
username,
|
||||
password,
|
||||
device_name,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %err, "password login request failed");
|
||||
return Err(ApiError::Network(err));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
let login: LoginResponse = match parse_response(response).await {
|
||||
Ok(login) => login,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %status, %err, "password login rejected");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let session = AuthSession::new(base_url.to_string(), login.user, login.tokens);
|
||||
tracing::info!(
|
||||
%base_url,
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"password login succeeded"
|
||||
);
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn login_sso_exchange(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
code: &str,
|
||||
) -> Result<AuthSession, ApiError> {
|
||||
let device_name = device_name();
|
||||
tracing::info!(%base_url, %device_name, "SSO exchange request started");
|
||||
let response = match http
|
||||
.post(format!("{base_url}/api/auth/sso/exchange"))
|
||||
.json(&SsoExchangeRequest { code, device_name })
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %err, "SSO exchange request failed");
|
||||
return Err(ApiError::Network(err));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
let login: LoginResponse = match parse_response(response).await {
|
||||
Ok(login) => login,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %status, %err, "SSO exchange rejected");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let session = AuthSession::new(base_url.to_string(), login.user, login.tokens);
|
||||
tracing::info!(
|
||||
%base_url,
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"SSO exchange succeeded"
|
||||
);
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
tracing::info!(%base_url, "refresh token request started");
|
||||
let response = match http
|
||||
.post(format!("{base_url}/api/auth/refresh"))
|
||||
.json(&RefreshRequest { refresh_token })
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %err, "refresh token request failed");
|
||||
return Err(ApiError::Network(err));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
tracing::warn!(%base_url, %status, "refresh token rejected by server");
|
||||
return Err(ApiError::SessionExpired);
|
||||
}
|
||||
let tokens: TokensResponse = match parse_response(response).await {
|
||||
Ok(tokens) => tokens,
|
||||
Err(err) => {
|
||||
tracing::warn!(%base_url, %status, %err, "refresh token request returned error");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
%base_url,
|
||||
%status,
|
||||
expires_in_seconds = tokens.expires_in_seconds,
|
||||
"refresh token request succeeded"
|
||||
);
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
fn stream_error_is_auth_related(err: &ApiError) -> bool {
|
||||
let ApiError::Server(message) = err else {
|
||||
return false;
|
||||
};
|
||||
let message = message.to_ascii_lowercase();
|
||||
message.contains("401")
|
||||
|| message.contains("unauthorized")
|
||||
|| message.contains("authentication")
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let url: reqwest::Url = format!("{}{path}", self.base_url)
|
||||
.parse()
|
||||
.map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?;
|
||||
tracing::info!(path, "opening authenticated stream");
|
||||
let token = self.fresh_access_token().await?;
|
||||
match self.open_stream_with_token(url.clone(), &token).await {
|
||||
Ok(stream) => {
|
||||
tracing::debug!(path, "authenticated stream opened");
|
||||
Ok(stream)
|
||||
}
|
||||
Err(err) if stream_error_is_auth_related(&err) => {
|
||||
tracing::warn!(path, %err, "stream rejected bearer token; refreshing and retrying");
|
||||
let retry_token = self.refresh_after_rejection(&token).await?;
|
||||
let result = self.open_stream_with_token(url, &retry_token).await;
|
||||
if let Err(retry_err) = &result {
|
||||
tracing::warn!(path, %retry_err, "stream open failed after auth refresh");
|
||||
}
|
||||
result
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(path, %err, "stream open failed");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_stream_with_token(
|
||||
&self,
|
||||
url: reqwest::Url,
|
||||
token: &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 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 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())
|
||||
};
|
||||
tracing::info!(base_url = %self.base_url, "logout request started");
|
||||
let response = self
|
||||
.http
|
||||
.post(format!("{}/api/auth/logout", self.base_url))
|
||||
.bearer_auth(access_token)
|
||||
.json(&LogoutRequest {
|
||||
refresh_token: &refresh_token,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LogoutResponse {
|
||||
revoked: bool,
|
||||
}
|
||||
let body: LogoutResponse = match parse_response(response).await {
|
||||
Ok(body) => body,
|
||||
Err(err) => {
|
||||
tracing::warn!(base_url = %self.base_url, %status, %err, "logout request failed");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
tracing::info!(base_url = %self.base_url, %status, revoked = body.revoked, "logout request succeeded");
|
||||
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 {
|
||||
tracing::warn!(%url, "authenticated request returned 401; refreshing token and retrying");
|
||||
let token = self.refresh_after_rejection(&token).await?;
|
||||
let response = build(&self.http, url, &token).send().await?;
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
tracing::warn!(%url, "authenticated request still returned 401 after token refresh");
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn fresh_access_token(&self) -> Result<String, ApiError> {
|
||||
let mut session = self.session.lock().await;
|
||||
let seconds_until_expiry = session.seconds_until_access_expiry();
|
||||
if session.access_token_expired() {
|
||||
tracing::info!(
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry,
|
||||
"access token expired or near expiry; refreshing"
|
||||
);
|
||||
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 {
|
||||
tracing::info!(
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
"rejected access token was already rotated by another task"
|
||||
);
|
||||
return Ok(session.access_token.clone());
|
||||
}
|
||||
tracing::warn!(
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"access token was rejected; refreshing"
|
||||
);
|
||||
self.refresh_locked(&mut session).await?;
|
||||
Ok(session.access_token.clone())
|
||||
}
|
||||
|
||||
async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> {
|
||||
let user_id = session.user.id;
|
||||
let user = session.user.name.clone();
|
||||
let previous_expires_at = session.expires_at_epoch_seconds;
|
||||
let previous_seconds_until_expiry = session.seconds_until_access_expiry();
|
||||
tracing::info!(
|
||||
user_id,
|
||||
user = %user,
|
||||
server = %self.base_url,
|
||||
previous_expires_at_epoch_seconds = previous_expires_at,
|
||||
previous_seconds_until_expiry,
|
||||
"refreshing access token"
|
||||
);
|
||||
let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await;
|
||||
match result {
|
||||
Ok(tokens) => {
|
||||
let expires_in_seconds = tokens.expires_in_seconds;
|
||||
session.apply_tokens(tokens);
|
||||
if let Err(err) = auth::save_session(session) {
|
||||
tracing::warn!(%err, "failed to persist rotated tokens");
|
||||
}
|
||||
tracing::info!(
|
||||
user_id = session.user.id,
|
||||
user = %session.user.name,
|
||||
server = %self.base_url,
|
||||
expires_in_seconds,
|
||||
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
|
||||
seconds_until_expiry = session.seconds_until_access_expiry(),
|
||||
"access token refreshed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(ApiError::SessionExpired) => {
|
||||
tracing::warn!(
|
||||
user_id,
|
||||
user = %user,
|
||||
server = %self.base_url,
|
||||
"refresh token expired or rejected; clearing stored session"
|
||||
);
|
||||
auth::delete_session();
|
||||
Err(ApiError::SessionExpired)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
user_id,
|
||||
user = %user,
|
||||
server = %self.base_url,
|
||||
%err,
|
||||
"access token refresh failed"
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
@@ -1,314 +0,0 @@
|
||||
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, Serialize, Deserialize)]
|
||||
pub struct ArtistRef {
|
||||
#[allow(dead_code, reason = "navigation to artists from track rows later")]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Serialize keeps every field the backend sent us, so device-sync payloads
|
||||
/// (play_from_index, queue_add) carry full track objects like the web does.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrackItem {
|
||||
#[allow(dead_code, reason = "playback engine consumes this in milestone 3")]
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
/// Absent in the artist-appearance variant of track payloads.
|
||||
#[serde(default)]
|
||||
pub track_number: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub disc_number: Option<i32>,
|
||||
pub duration_seconds: f64,
|
||||
#[serde(default)]
|
||||
pub artists: Vec<ArtistRef>,
|
||||
#[serde(default)]
|
||||
pub featured_artists: Vec<ArtistRef>,
|
||||
#[allow(dead_code, reason = "jump-to-release navigation later")]
|
||||
#[serde(default)]
|
||||
pub release_id: i64,
|
||||
#[serde(default)]
|
||||
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}`.
|
||||
#[serde(default)]
|
||||
pub stream_url: String,
|
||||
#[allow(dead_code, reason = "now-playing artwork in milestone 3")]
|
||||
pub cover_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub uploader_name: String,
|
||||
pub audio_format: Option<String>,
|
||||
pub audio_bitrate: Option<i32>,
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
pub file_size_bytes: Option<i64>,
|
||||
pub lastfm_listeners: Option<i64>,
|
||||
#[allow(dead_code, reason = "popularity column later")]
|
||||
pub lastfm_playcount: Option<i64>,
|
||||
pub lastfm_rating: Option<f64>,
|
||||
pub lastfm_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Tracks where this artist is featured (the only content for artists
|
||||
/// without own releases).
|
||||
#[serde(default)]
|
||||
pub featured_tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[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, Clone, Deserialize)]
|
||||
pub struct DeviceDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
#[allow(dead_code, reason = "server-side flag; we compare ids directly")]
|
||||
pub is_current: bool,
|
||||
pub is_active: bool,
|
||||
#[allow(dead_code, reason = "freshness display later")]
|
||||
pub last_seen_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceCommandDto {
|
||||
#[allow(dead_code, reason = "commands are applied in poll order")]
|
||||
pub id: Option<String>,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Mirrors the backend's PlayerDevicePlaybackStateDto; tracks stay raw JSON
|
||||
/// so unknown fields survive the round trip between clients.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DevicePlaybackState {
|
||||
#[serde(default)]
|
||||
pub track: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub tracks: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub index: i32,
|
||||
#[serde(default)]
|
||||
pub position_seconds: f64,
|
||||
#[serde(default)]
|
||||
pub duration_seconds: f64,
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
#[serde(default)]
|
||||
pub shuffle: bool,
|
||||
#[serde(default = "default_repeat_mode")]
|
||||
pub repeat_mode: String,
|
||||
#[serde(default = "default_volume")]
|
||||
pub volume: f64,
|
||||
#[serde(default)]
|
||||
pub updated_at_ms: i64,
|
||||
}
|
||||
|
||||
fn default_repeat_mode() -> String {
|
||||
"off".to_string()
|
||||
}
|
||||
|
||||
fn default_volume() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DevicePollResponse {
|
||||
#[allow(dead_code, reason = "echo of our own id")]
|
||||
pub device_id: String,
|
||||
pub active_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub devices: Vec<DeviceDto>,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<DeviceCommandDto>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code, reason = "Jam control is out of scope for the TUI v1")]
|
||||
pub current_jam_id: Option<String>,
|
||||
pub playback_state: Option<DevicePlaybackState>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
+11
-9
@@ -39,10 +39,10 @@ pub enum Action {
|
||||
NewPlaylist,
|
||||
ToggleHelp,
|
||||
ToggleViewMode,
|
||||
OpenDevices,
|
||||
OpenCommandLine,
|
||||
OpenSearch,
|
||||
Logout,
|
||||
EditSelected,
|
||||
DeleteSelected,
|
||||
}
|
||||
|
||||
/// Help-window sections, in display order.
|
||||
@@ -50,15 +50,17 @@ pub enum Action {
|
||||
pub enum Category {
|
||||
Playback,
|
||||
Queue,
|
||||
Library,
|
||||
Navigation,
|
||||
Search,
|
||||
System,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
pub const ALL: [Category; 5] = [
|
||||
pub const ALL: [Category; 6] = [
|
||||
Category::Playback,
|
||||
Category::Queue,
|
||||
Category::Library,
|
||||
Category::Navigation,
|
||||
Category::Search,
|
||||
Category::System,
|
||||
@@ -68,6 +70,7 @@ impl Category {
|
||||
match self {
|
||||
Category::Playback => "Playback",
|
||||
Category::Queue => "Queue & playlists",
|
||||
Category::Library => "Library",
|
||||
Category::Navigation => "Navigation",
|
||||
Category::Search => "Search & commands",
|
||||
Category::System => "System",
|
||||
@@ -111,9 +114,9 @@ impl Action {
|
||||
| Action::GoToTab(_)
|
||||
| Action::GoToRelease
|
||||
| Action::ToggleViewMode => Category::Navigation,
|
||||
Action::EditSelected | Action::DeleteSelected => Category::Library,
|
||||
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
|
||||
Action::OpenDevices => Category::System,
|
||||
Action::ToggleHelp | Action::Logout | Action::Quit => Category::System,
|
||||
Action::ToggleHelp | Action::Quit => Category::System,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +124,7 @@ impl Action {
|
||||
pub fn command_hint(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Action::Quit => Some(":q"),
|
||||
Action::Logout => Some(":logout"),
|
||||
Action::EditSelected => Some(":import <path> adds files"),
|
||||
Action::PlayPause => Some(":play"),
|
||||
Action::NextTrack => Some(":next"),
|
||||
Action::PrevTrack => Some(":prev"),
|
||||
@@ -130,7 +133,6 @@ impl Action {
|
||||
Action::ToggleShuffle => Some(":shuffle"),
|
||||
Action::CycleRepeat => Some(":repeat [off|one|all]"),
|
||||
Action::ClearQueue => Some(":clear"),
|
||||
Action::OpenDevices => Some(":devices"),
|
||||
Action::ToggleHelp => Some(":help"),
|
||||
Action::OpenSearch => Some("/text"),
|
||||
_ => None,
|
||||
@@ -174,10 +176,10 @@ impl Action {
|
||||
Action::NewPlaylist => "Create a playlist".into(),
|
||||
Action::ToggleHelp => "Show / hide keybindings".into(),
|
||||
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
||||
Action::OpenDevices => "Connected devices".into(),
|
||||
Action::OpenCommandLine => "Command line (:help for commands)".into(),
|
||||
Action::OpenSearch => "Search artists, releases, tracks".into(),
|
||||
Action::Logout => "Sign out".into(),
|
||||
Action::EditSelected => "Edit the selected item".into(),
|
||||
Action::DeleteSelected => "Delete the selected item".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-21
@@ -4,7 +4,6 @@ use std::time::Duration;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::api::client::ApiError;
|
||||
use crate::app::Runtime;
|
||||
use crate::app::command::{self, Command, Parsed};
|
||||
use crate::app::event::AppEvent;
|
||||
@@ -59,7 +58,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
||||
match command {
|
||||
// One-shot commands have no live effect.
|
||||
Command::Quit
|
||||
| Command::Logout
|
||||
| Command::Import(_)
|
||||
| Command::Volume(_)
|
||||
| Command::Seek(_)
|
||||
| Command::SeekTo(_)
|
||||
@@ -70,7 +69,6 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
||||
| Command::Prev
|
||||
| Command::PlayPause
|
||||
| Command::Help
|
||||
| Command::Devices
|
||||
| Command::Logs(_) => {}
|
||||
Command::Search(query) => {
|
||||
state.active_tab = Tab::Global;
|
||||
@@ -96,17 +94,17 @@ fn set_view_cursor_zero(state: &mut AppState) {
|
||||
/// Debounced, race-free search: every edit bumps the global sequence; the
|
||||
/// spawned task only queries if it is still the latest after the debounce,
|
||||
/// and the receiver drops responses that arrive out of date.
|
||||
fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let query = state.search.query.clone();
|
||||
if query.is_empty() {
|
||||
state.search.loading = false;
|
||||
state.search.results = None;
|
||||
state.search.fed_tracks.clear();
|
||||
state.search.fed_loading = false;
|
||||
return;
|
||||
}
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
let library = Arc::clone(&runtime.library);
|
||||
state.search.loading = true;
|
||||
let tx = runtime.event_tx.clone();
|
||||
let latest = Arc::clone(&runtime.search_seq);
|
||||
@@ -115,19 +113,32 @@ fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
if latest.load(Ordering::SeqCst) != seq {
|
||||
return;
|
||||
}
|
||||
let event = match api.search(&query, SEARCH_LIMIT).await {
|
||||
Ok(results) => AppEvent::SearchLoaded {
|
||||
seq,
|
||||
result: Ok(results),
|
||||
},
|
||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::SearchLoaded {
|
||||
seq,
|
||||
result: Err(err.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|result| result.map_err(|err| format!("{err:#}")));
|
||||
let _ = tx.send(AppEvent::SearchLoaded { seq, result });
|
||||
});
|
||||
|
||||
// The same query also runs against the federated network (when the
|
||||
// node is up); its results render as a separate, marked section.
|
||||
state.search.fed_tracks.clear();
|
||||
state.search.fed_loading = false;
|
||||
if runtime.federation.settings().enabled {
|
||||
state.search.fed_loading = true;
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let query = state.search.query.clone();
|
||||
let tx = runtime.event_tx.clone();
|
||||
let latest = Arc::clone(&runtime.search_seq);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(SEARCH_DEBOUNCE).await;
|
||||
if latest.load(Ordering::SeqCst) != seq {
|
||||
return;
|
||||
}
|
||||
let result = fed.search(&query).await.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::FedSearchLoaded { seq, result });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter: close the line. Live commands already took effect (their view
|
||||
@@ -161,7 +172,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
||||
match command {
|
||||
Command::Search(_) => {}
|
||||
Command::Quit => state.should_quit = true,
|
||||
Command::Logout => super::perform_logout(state, runtime),
|
||||
Command::Import(path) => super::spawn_import(state, runtime, &path),
|
||||
Command::Volume(value) => {
|
||||
state.player.volume = value;
|
||||
super::perform_effect(state, runtime, Effect::SetVolume(value));
|
||||
@@ -194,7 +205,6 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
||||
Command::Prev => run_action(state, runtime, Action::PrevTrack),
|
||||
Command::PlayPause => run_action(state, runtime, Action::PlayPause),
|
||||
Command::Help => state.help_visible = true,
|
||||
Command::Devices => run_action(state, runtime, Action::OpenDevices),
|
||||
Command::Logs(level) => {
|
||||
if let Some(index) = level {
|
||||
state.logs.level_index = index.min(LOG_LEVELS.len() - 1);
|
||||
|
||||
+17
-8
@@ -22,8 +22,9 @@ pub enum Command {
|
||||
/// `:q` / `:quit` — exit immediately (explicit enough to skip the
|
||||
/// double-press confirmation).
|
||||
Quit,
|
||||
/// `:logout` — sign out and return to the login screen.
|
||||
Logout,
|
||||
/// `:import <path>` — import an audio file or a directory into the
|
||||
/// library.
|
||||
Import(String),
|
||||
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
||||
Volume(u8),
|
||||
/// `:seek +30` / `:seek -10` — relative seek in seconds.
|
||||
@@ -43,8 +44,6 @@ pub enum Command {
|
||||
PlayPause,
|
||||
/// `:help` — open the keybinding help.
|
||||
Help,
|
||||
/// `:devices` — open the connected-devices picker.
|
||||
Devices,
|
||||
/// `:logs [error|warn|info|debug|trace]` — jump to the Logs tab,
|
||||
/// optionally setting the severity filter.
|
||||
Logs(Option<usize>),
|
||||
@@ -74,7 +73,15 @@ pub fn parse(input: &str) -> Parsed {
|
||||
let arg = parts.next();
|
||||
match name {
|
||||
"q" | "quit" => Parsed::Command(Command::Quit),
|
||||
"logout" => Parsed::Command(Command::Logout),
|
||||
"import" | "add" => {
|
||||
let path = input.trim_start().split_once(char::is_whitespace);
|
||||
match path.map(|(_, rest)| rest.trim()) {
|
||||
Some(path) if !path.is_empty() => {
|
||||
Parsed::Command(Command::Import(path.to_string()))
|
||||
}
|
||||
_ => Parsed::Invalid("usage: :import <file or directory>".to_string()),
|
||||
}
|
||||
}
|
||||
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
|
||||
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
||||
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
||||
@@ -96,7 +103,6 @@ pub fn parse(input: &str) -> Parsed {
|
||||
"prev" => Parsed::Command(Command::Prev),
|
||||
"pause" | "play" => Parsed::Command(Command::PlayPause),
|
||||
"help" => Parsed::Command(Command::Help),
|
||||
"devices" | "device" => Parsed::Command(Command::Devices),
|
||||
"logs" => match arg {
|
||||
None => Parsed::Command(Command::Logs(None)),
|
||||
Some(level) => match ["error", "warn", "info", "debug", "trace"]
|
||||
@@ -152,7 +158,11 @@ mod tests {
|
||||
fn parses_word_commands() {
|
||||
assert_eq!(parse("q"), Parsed::Command(Command::Quit));
|
||||
assert_eq!(parse("quit"), Parsed::Command(Command::Quit));
|
||||
assert_eq!(parse("logout"), Parsed::Command(Command::Logout));
|
||||
assert_eq!(
|
||||
parse("import ~/Music/My Album"),
|
||||
Parsed::Command(Command::Import("~/Music/My Album".to_string()))
|
||||
);
|
||||
assert!(matches!(parse("import"), Parsed::Invalid(_)));
|
||||
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
|
||||
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
||||
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
|
||||
@@ -162,7 +172,6 @@ mod tests {
|
||||
Parsed::Command(Command::Repeat(Some(RepeatArg::All)))
|
||||
);
|
||||
assert_eq!(parse("clear"), Parsed::Command(Command::ClearQueue));
|
||||
assert_eq!(parse("devices"), Parsed::Command(Command::Devices));
|
||||
assert_eq!(parse("logs debug"), Parsed::Command(Command::Logs(Some(3))));
|
||||
}
|
||||
|
||||
|
||||
+44
-21
@@ -1,25 +1,24 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::auth::AuthSession;
|
||||
use crate::api::models::{
|
||||
ArtistDetail, ArtistsPage, DevicePollResponse, PlaylistCard, PlaylistDetail, ReleaseDetail,
|
||||
SearchResults, TrackItem,
|
||||
};
|
||||
use crate::art::ArtImage;
|
||||
use crate::library::models::{
|
||||
ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults,
|
||||
TrackItem,
|
||||
};
|
||||
|
||||
/// Events delivered to the main loop by background tasks (API fetches, the
|
||||
/// playback engine, device sync). Tasks never touch AppState directly.
|
||||
/// Events delivered to the main loop by background tasks (library queries,
|
||||
/// the playback engine, imports). Tasks never touch AppState directly.
|
||||
#[derive(Debug)]
|
||||
pub enum AppEvent {
|
||||
StatusMessage(String),
|
||||
LoginSucceeded(Box<AuthSession>),
|
||||
LoginFailed(String),
|
||||
/// Loopback listener received the browser SSO callback.
|
||||
SsoCallback(Result<String, String>),
|
||||
/// Refresh token rejected — stored credentials were deleted.
|
||||
SessionExpired,
|
||||
/// A page of the Global artists list arrived (or failed).
|
||||
/// A page of the artists list arrived (or failed).
|
||||
ArtistsLoaded(Result<ArtistsPage, String>),
|
||||
/// A full reload after a library change: replaces the loaded artist
|
||||
/// list wholesale, so the grid never flashes empty.
|
||||
ArtistsReloaded {
|
||||
page: ArtistsPage,
|
||||
limit: i64,
|
||||
},
|
||||
ArtistViewLoaded {
|
||||
id: i64,
|
||||
result: Result<ArtistDetail, String>,
|
||||
@@ -33,7 +32,7 @@ pub enum AppEvent {
|
||||
seq: u64,
|
||||
result: Result<SearchResults, String>,
|
||||
},
|
||||
/// Artwork fetched and decoded for the shared art cache.
|
||||
/// Artwork loaded and decoded for the shared art cache.
|
||||
ArtLoaded {
|
||||
key: String,
|
||||
art: Option<Arc<ArtImage>>,
|
||||
@@ -41,7 +40,7 @@ pub enum AppEvent {
|
||||
Player(crate::player::PlayerEvent),
|
||||
/// A command from the OS media keys.
|
||||
Media(crate::media::MediaCommand),
|
||||
/// Gapless prefetch could not open the stream; the normal track-switch
|
||||
/// Gapless prefetch could not open the file; the normal track-switch
|
||||
/// path takes over when the current track ends.
|
||||
PrefetchFailed {
|
||||
pos: usize,
|
||||
@@ -57,11 +56,6 @@ pub enum AppEvent {
|
||||
track_id: i64,
|
||||
liked: bool,
|
||||
},
|
||||
/// Connected-devices poll result; carries device list, active id,
|
||||
/// remote playback state and commands for this TUI.
|
||||
DevicesPolled(Result<DevicePollResponse, String>),
|
||||
/// Response from switching the active device.
|
||||
DeviceActivated(Result<DevicePollResponse, String>),
|
||||
/// A release fetched for queueing (a / shift-a on a release).
|
||||
EnqueueTracks {
|
||||
tracks: Vec<TrackItem>,
|
||||
@@ -77,4 +71,33 @@ pub enum AppEvent {
|
||||
playlist_title: String,
|
||||
result: Result<(), String>,
|
||||
},
|
||||
/// The library was mutated (import, edit, delete): cached views must be
|
||||
/// dropped and reloaded lazily.
|
||||
LibraryChanged {
|
||||
message: Option<String>,
|
||||
},
|
||||
/// Progress of a running import, shown in the status bar.
|
||||
ImportProgress {
|
||||
done: usize,
|
||||
total: usize,
|
||||
current: String,
|
||||
},
|
||||
/// Fresh copies of the queued tracks after a library change. Tracks
|
||||
/// missing from the result were deleted and leave the queue.
|
||||
QueueTracksRefreshed {
|
||||
tracks: Vec<TrackItem>,
|
||||
},
|
||||
/// A status snapshot for the Federation tab.
|
||||
FederationStatus(crate::federation::FedStatus),
|
||||
/// Tracks found on the federated network for the live search.
|
||||
FedSearchLoaded {
|
||||
seq: u64,
|
||||
result: Result<Vec<crate::federation::FedTrack>, String>,
|
||||
},
|
||||
/// A federated track finished downloading and is ready to play.
|
||||
FedPlayReady {
|
||||
result: Result<crate::federation::FedPlayable, String>,
|
||||
},
|
||||
/// This peer's connection ticket, requested from the Federation tab.
|
||||
FedTicket(Result<String, String>),
|
||||
}
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::api::{auth, client};
|
||||
use crate::app::Runtime;
|
||||
use crate::app::event::AppEvent;
|
||||
use crate::app::sso;
|
||||
use crate::app::state::{AppState, LoginField, LoginForm, LoginMode};
|
||||
|
||||
pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
state.should_quit = true;
|
||||
return;
|
||||
}
|
||||
let form = &mut state.login;
|
||||
if form.busy {
|
||||
return;
|
||||
}
|
||||
match form.mode {
|
||||
LoginMode::Form => handle_form_key(form, runtime, key),
|
||||
LoginMode::SsoPending => handle_sso_key(form, runtime, key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracketed paste goes into whichever text field is focused.
|
||||
pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
||||
let form = &mut state.login;
|
||||
if form.busy {
|
||||
return;
|
||||
}
|
||||
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
||||
if let Some(field) = focused_text(form) {
|
||||
field.push_str(&cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_form_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(),
|
||||
KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(),
|
||||
KeyCode::Backspace => {
|
||||
if let Some(field) = focused_text(form) {
|
||||
field.pop();
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => match form.focus {
|
||||
LoginField::ServerUrl | LoginField::Username => form.focus = form.focus.next(),
|
||||
LoginField::Password | LoginField::SignInButton => {
|
||||
submit_password(form, runtime);
|
||||
}
|
||||
LoginField::SsoButton => start_sso(form, runtime),
|
||||
},
|
||||
KeyCode::Char(c) if is_typing(key) => {
|
||||
if let Some(field) = focused_text(form) {
|
||||
field.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
|
||||
// Ctrl-shortcuts first: plain letters belong to the paste field.
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
match key.code {
|
||||
// Copy the full SSO URL — terminals can't copy a wrapped link
|
||||
// in one piece, the clipboard can.
|
||||
KeyCode::Char('l') => {
|
||||
form.error = None;
|
||||
match copy_to_clipboard(&form.sso_url) {
|
||||
Ok(()) => form.error = Some("link copied to clipboard".to_string()),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "clipboard copy failed");
|
||||
form.error = Some(format!("copy failed: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('o') => {
|
||||
if let Err(err) = open::that_detached(&form.sso_url) {
|
||||
tracing::warn!(%err, "failed to reopen browser");
|
||||
form.error = Some("couldn't open a browser".to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
if let Some(listener) = runtime.sso.take() {
|
||||
listener.abort();
|
||||
}
|
||||
form.mode = LoginMode::Form;
|
||||
form.sso_paste.clear();
|
||||
form.error = None;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
form.sso_paste.pop();
|
||||
}
|
||||
KeyCode::Enter => submit_sso_code(form, runtime),
|
||||
KeyCode::Char(c) if is_typing(key) => form.sso_paste.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(text: &str) -> Result<(), arboard::Error> {
|
||||
arboard::Clipboard::new()?.set_text(text.to_string())
|
||||
}
|
||||
|
||||
fn is_typing(key: KeyEvent) -> bool {
|
||||
key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
|
||||
}
|
||||
|
||||
fn focused_text(form: &mut LoginForm) -> Option<&mut String> {
|
||||
if form.mode == LoginMode::SsoPending {
|
||||
return Some(&mut form.sso_paste);
|
||||
}
|
||||
match form.focus {
|
||||
LoginField::ServerUrl => Some(&mut form.server_url),
|
||||
LoginField::Username => Some(&mut form.username),
|
||||
LoginField::Password => Some(&mut form.password),
|
||||
LoginField::SignInButton | LoginField::SsoButton => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_password(form: &mut LoginForm, runtime: &Runtime) {
|
||||
form.error = None;
|
||||
let base_url = match auth::normalize_base_url(&form.server_url) {
|
||||
Ok(url) => url,
|
||||
Err(err) => return form.error = Some(err.to_string()),
|
||||
};
|
||||
let username = form.username.trim().to_string();
|
||||
if username.is_empty() {
|
||||
return form.error = Some("enter a username".to_string());
|
||||
}
|
||||
if form.password.is_empty() {
|
||||
return form.error = Some("enter a password".to_string());
|
||||
}
|
||||
form.server_url = base_url.clone();
|
||||
form.busy = true;
|
||||
|
||||
let password = form.password.clone();
|
||||
let http = runtime.http.clone();
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = client::login_password(&http, &base_url, &username, &password).await;
|
||||
let _ = tx.send(login_event(result));
|
||||
});
|
||||
}
|
||||
|
||||
fn start_sso(form: &mut LoginForm, runtime: &mut Runtime) {
|
||||
form.error = None;
|
||||
let base_url = match auth::normalize_base_url(&form.server_url) {
|
||||
Ok(url) => url,
|
||||
Err(err) => return form.error = Some(err.to_string()),
|
||||
};
|
||||
form.server_url = base_url.clone();
|
||||
form.sso_paste.clear();
|
||||
|
||||
// Preferred flow: loopback listener, the browser redirect finishes the
|
||||
// login hands-free. Fallback: furumi:// deep link + manual code paste.
|
||||
if let Some(listener) = runtime.sso.take() {
|
||||
listener.abort();
|
||||
}
|
||||
match sso::start(runtime.event_tx.clone()) {
|
||||
Ok(listener) => {
|
||||
let redirect = format!("http://127.0.0.1:{}/callback", listener.port);
|
||||
form.sso_url = client::sso_start_url(&base_url, &redirect);
|
||||
form.sso_port = Some(listener.port);
|
||||
runtime.sso = Some(listener);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "loopback listener unavailable, falling back to manual paste");
|
||||
form.sso_url = client::sso_start_url(&base_url, "furumi://auth/callback");
|
||||
form.sso_port = None;
|
||||
}
|
||||
}
|
||||
|
||||
form.mode = LoginMode::SsoPending;
|
||||
if let Err(err) = open::that_detached(&form.sso_url) {
|
||||
tracing::warn!(%err, "failed to open browser for SSO");
|
||||
form.error = Some("couldn't open a browser — use the URL below".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_sso_code(form: &mut LoginForm, runtime: &Runtime) {
|
||||
form.error = None;
|
||||
let code = match auth::extract_sso_code(&form.sso_paste) {
|
||||
Ok(code) => code,
|
||||
Err(err) => return form.error = Some(err.to_string()),
|
||||
};
|
||||
spawn_sso_exchange(form, runtime, code);
|
||||
}
|
||||
|
||||
/// Used by both the manual paste path and the loopback callback event.
|
||||
pub fn spawn_sso_exchange(form: &mut LoginForm, runtime: &Runtime, code: String) {
|
||||
let base_url = form.server_url.clone();
|
||||
form.busy = true;
|
||||
|
||||
let http = runtime.http.clone();
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = client::login_sso_exchange(&http, &base_url, &code).await;
|
||||
let _ = tx.send(login_event(result));
|
||||
});
|
||||
}
|
||||
|
||||
fn login_event(result: Result<auth::AuthSession, client::ApiError>) -> AppEvent {
|
||||
match result {
|
||||
Ok(session) => {
|
||||
tracing::info!(user = %session.user.name, server = %session.server_base_url, "signed in");
|
||||
if let Err(err) = auth::save_session(&session) {
|
||||
tracing::warn!(%err, "failed to persist credentials");
|
||||
}
|
||||
AppEvent::LoginSucceeded(Box::new(session))
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "login failed");
|
||||
AppEvent::LoginFailed(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
+514
-975
File diff suppressed because it is too large
Load Diff
+285
-74
@@ -1,13 +1,18 @@
|
||||
//! Modal dialog input: the add-to-playlist picker and new-playlist name
|
||||
//! entry. The popup is taken out of the state, handled as an owned value
|
||||
//! and put back unless the action closed it.
|
||||
//! Modal dialog input: the add-to-playlist picker, new-playlist name entry,
|
||||
//! metadata edit forms and delete confirmations. The popup is taken out of
|
||||
//! the state, handled as an owned value and put back unless the action
|
||||
//! closed it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::api::models::TrackItem;
|
||||
use crate::app::Runtime;
|
||||
use crate::app::event::AppEvent;
|
||||
use crate::app::state::{AppState, Popup, addable_playlists};
|
||||
use crate::app::state::{
|
||||
AppState, DeleteTarget, EditField, EditTarget, FedInputField, Popup, addable_playlists,
|
||||
};
|
||||
use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem};
|
||||
|
||||
pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
|
||||
let Some(popup) = state.popup.take() else {
|
||||
@@ -22,7 +27,16 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
|
||||
input,
|
||||
busy,
|
||||
} => handle_name_entry(state, runtime, for_track, input, busy, key),
|
||||
Popup::Devices { cursor } => handle_devices(state, runtime, cursor, key),
|
||||
Popup::Edit {
|
||||
target,
|
||||
title,
|
||||
fields,
|
||||
focus,
|
||||
error,
|
||||
} => handle_edit(state, runtime, target, title, fields, focus, error, key),
|
||||
Popup::ConfirmDelete { target, label } => {
|
||||
handle_confirm_delete(state, runtime, target, label, key);
|
||||
}
|
||||
Popup::TrackInfo {
|
||||
tracks,
|
||||
cursor,
|
||||
@@ -32,54 +46,269 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
|
||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
||||
_ => state.popup = Some(Popup::LogDetail(entry)),
|
||||
},
|
||||
Popup::FedInput { field, input } => handle_fed_input(state, runtime, field, input, key),
|
||||
Popup::FedText { title, text } => match key.code {
|
||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
||||
_ => state.popup = Some(Popup::FedText { title, text }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Pasted text goes into the name field when it is open.
|
||||
pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
||||
if let Some(Popup::NewPlaylist { input, busy, .. }) = &mut state.popup {
|
||||
if !*busy {
|
||||
input.extend(pasted.chars().filter(|c| !c.is_control()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_devices(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) {
|
||||
let len = state.devices.devices.len();
|
||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||
fn handle_fed_input(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
field: FedInputField,
|
||||
mut input: String,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
state.popup = Some(Popup::Devices {
|
||||
cursor: cursor.saturating_sub(1),
|
||||
});
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
state.popup = Some(Popup::Devices {
|
||||
cursor: if len == 0 {
|
||||
0
|
||||
} else {
|
||||
(cursor + 1).min(len - 1)
|
||||
},
|
||||
});
|
||||
}
|
||||
KeyCode::Esc => {}
|
||||
KeyCode::Enter => {
|
||||
if let Some(device) = state.devices.devices.get(cursor.min(len.saturating_sub(1))) {
|
||||
let target = device.id.clone();
|
||||
state.devices.switching_to = Some(target.clone());
|
||||
state.popup = Some(Popup::Devices { cursor });
|
||||
spawn_select_device(runtime, target);
|
||||
} else {
|
||||
state.popup = Some(Popup::Devices { cursor: 0 });
|
||||
let value = input.trim().to_string();
|
||||
match field {
|
||||
FedInputField::NetworkId => {
|
||||
state.federation.settings.network_id = value;
|
||||
// An empty id turns federation off rather than leaving a
|
||||
// node bound to an unnamed network; a freshly set id
|
||||
// enables it right away.
|
||||
state.federation.settings.enabled =
|
||||
!state.federation.settings.network_id.is_empty();
|
||||
super::fed_apply_settings(state, runtime);
|
||||
}
|
||||
FedInputField::ConnectTicket => {
|
||||
if value.is_empty() {
|
||||
state.status_message = Some("ticket is empty".into());
|
||||
} else {
|
||||
super::fed_connect(runtime, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
state.popup = Some(Popup::Devices {
|
||||
cursor: cursor.min(len.saturating_sub(1)),
|
||||
})
|
||||
KeyCode::Backspace => {
|
||||
input.pop();
|
||||
state.popup = Some(Popup::FedInput { field, input });
|
||||
}
|
||||
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
|
||||
input.push(c);
|
||||
state.popup = Some(Popup::FedInput { field, input });
|
||||
}
|
||||
_ => state.popup = Some(Popup::FedInput { field, input }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pasted text goes into the focused text field when one is open.
|
||||
pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
||||
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
||||
match &mut state.popup {
|
||||
Some(Popup::NewPlaylist { input, busy, .. }) if !*busy => input.push_str(&cleaned),
|
||||
Some(Popup::FedInput { input, .. }) => input.push_str(&cleaned),
|
||||
Some(Popup::Edit { fields, focus, .. }) => {
|
||||
if let Some(field) = fields.get_mut(*focus) {
|
||||
field.value.push_str(&cleaned);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments, reason = "owned popup state passed back in")]
|
||||
fn handle_edit(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
target: EditTarget,
|
||||
title: String,
|
||||
mut fields: Vec<EditField>,
|
||||
mut focus: usize,
|
||||
error: Option<String>,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
match key.code {
|
||||
KeyCode::Esc => return,
|
||||
KeyCode::Enter => {
|
||||
match save_edit(runtime, target, &fields) {
|
||||
Ok(message) => {
|
||||
state.status_message = Some(message);
|
||||
return;
|
||||
}
|
||||
Err(message) => {
|
||||
state.popup = Some(Popup::Edit {
|
||||
target,
|
||||
title,
|
||||
fields,
|
||||
focus,
|
||||
error: Some(message),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => focus = (focus + 1) % fields.len().max(1),
|
||||
KeyCode::BackTab | KeyCode::Up => {
|
||||
let len = fields.len().max(1);
|
||||
focus = (focus + len - 1) % len;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let Some(field) = fields.get_mut(focus) {
|
||||
field.value.pop();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
|
||||
if let Some(field) = fields.get_mut(focus) {
|
||||
field.value.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
state.popup = Some(Popup::Edit {
|
||||
target,
|
||||
title,
|
||||
fields,
|
||||
focus,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
/// Validate the form and write it to the library. Returns the status
|
||||
/// message on success, the error text to show in the form otherwise.
|
||||
fn save_edit(
|
||||
runtime: &Runtime,
|
||||
target: EditTarget,
|
||||
fields: &[EditField],
|
||||
) -> Result<String, String> {
|
||||
let value = |label: &str| {
|
||||
fields
|
||||
.iter()
|
||||
.find(|field| field.label == label)
|
||||
.map(|field| field.value.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let number = |label: &str| -> Result<Option<i32>, String> {
|
||||
let raw = value(label);
|
||||
if raw.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
raw.parse::<i32>()
|
||||
.map(Some)
|
||||
.map_err(|_| format!("{label} must be a number"))
|
||||
};
|
||||
let names = |label: &str| -> Vec<String> {
|
||||
value(label)
|
||||
.split(';')
|
||||
.map(|name| name.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect()
|
||||
};
|
||||
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let result = match target {
|
||||
EditTarget::Track(id) => {
|
||||
let title = value("Title");
|
||||
if title.is_empty() {
|
||||
return Err("title is empty".to_string());
|
||||
}
|
||||
let artists = names("Artists");
|
||||
if artists.is_empty() {
|
||||
return Err("at least one artist is required".to_string());
|
||||
}
|
||||
let edit = TrackEdit {
|
||||
title,
|
||||
artists,
|
||||
featured_artists: names("Featured"),
|
||||
track_number: number("Track #")?,
|
||||
disc_number: number("Disc #")?,
|
||||
};
|
||||
library.update_track(id, &edit)
|
||||
}
|
||||
EditTarget::Release(id) => {
|
||||
let title = value("Title");
|
||||
if title.is_empty() {
|
||||
return Err("title is empty".to_string());
|
||||
}
|
||||
let release_type = value("Type").to_lowercase();
|
||||
let release_type = if release_type.is_empty() {
|
||||
"album".to_string()
|
||||
} else {
|
||||
release_type
|
||||
};
|
||||
let edit = ReleaseEdit {
|
||||
title,
|
||||
release_type,
|
||||
year: number("Year")?,
|
||||
artists: Vec::new(),
|
||||
};
|
||||
library.update_release(id, &edit)
|
||||
}
|
||||
EditTarget::Artist(id) => {
|
||||
let name = value("Name");
|
||||
if name.is_empty() {
|
||||
return Err("name is empty".to_string());
|
||||
}
|
||||
let image = value("Image path");
|
||||
let image = (!image.is_empty()).then_some(image);
|
||||
library.update_artist(id, &name, image.as_deref())
|
||||
}
|
||||
EditTarget::Playlist(id) => {
|
||||
let title = value("Title");
|
||||
if title.is_empty() {
|
||||
return Err("title is empty".to_string());
|
||||
}
|
||||
library.update_playlist(id, &title, None)
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
|
||||
message: Some("saved".to_string()),
|
||||
});
|
||||
Ok("saved".to_string())
|
||||
}
|
||||
Err(err) => Err(format!("{err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete confirmation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_confirm_delete(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
target: DeleteTarget,
|
||||
label: String,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {}
|
||||
KeyCode::Enter | KeyCode::Char('y') => {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let result = match target {
|
||||
DeleteTarget::Track(id) => library.delete_track(id),
|
||||
DeleteTarget::Release(id) => library.delete_release(id),
|
||||
DeleteTarget::Artist(id) => library.delete_artist(id),
|
||||
DeleteTarget::Playlist(id) => library.delete_playlist(id),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
|
||||
message: Some(format!("deleted {label}")),
|
||||
});
|
||||
}
|
||||
Err(err) => state.status_message = Some(format!("delete failed: {err:#}")),
|
||||
}
|
||||
}
|
||||
_ => state.popup = Some(Popup::ConfirmDelete { target, label }),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track info
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_track_info(
|
||||
state: &mut AppState,
|
||||
tracks: Vec<TrackItem>,
|
||||
@@ -132,6 +361,10 @@ fn handle_track_info(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add-to-playlist picker & new playlist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_picker(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
@@ -236,35 +469,13 @@ fn handle_name_entry(
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_select_device(runtime: &Runtime, target_device_id: String) {
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
let current_device_id = runtime.device_id.clone();
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let event = match api
|
||||
.select_device(&target_device_id, ¤t_device_id)
|
||||
.await
|
||||
{
|
||||
Ok(response) => AppEvent::DeviceActivated(Ok(response)),
|
||||
Err(crate::api::client::ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||
Err(err) => AppEvent::DeviceActivated(Err(err.to_string())),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, track: TrackItem) {
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = api
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = library
|
||||
.add_tracks_to_playlist(playlist_id, &[track.id])
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::PlaylistTracksAdded {
|
||||
playlist_id,
|
||||
playlist_title,
|
||||
@@ -274,12 +485,12 @@ fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String,
|
||||
}
|
||||
|
||||
fn spawn_create_playlist(runtime: &Runtime, title: String, add_track: Option<TrackItem>) {
|
||||
let Some(api) = runtime.api.clone() else {
|
||||
return;
|
||||
};
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = api.create_playlist(&title).await.map_err(|e| e.to_string());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = library
|
||||
.create_playlist(&title)
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::PlaylistCreated { result, add_track });
|
||||
});
|
||||
}
|
||||
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
use std::io;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::app::event::AppEvent;
|
||||
|
||||
/// Loopback callback listener for browser SSO (RFC 8252 native-app flow).
|
||||
/// The backend 303-redirects the browser to `http://127.0.0.1:{port}/callback`
|
||||
/// with the exchange code; the listener delivers it as an AppEvent and exits.
|
||||
pub struct SsoListener {
|
||||
pub port: u16,
|
||||
handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SsoListener {
|
||||
pub fn abort(&self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(tx: UnboundedSender<AppEvent>) -> io::Result<SsoListener> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let handle = tokio::spawn(async move {
|
||||
let result = match serve_one(listener).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => Err(format!("callback listener failed: {err}")),
|
||||
};
|
||||
let _ = tx.send(AppEvent::SsoCallback(result));
|
||||
});
|
||||
Ok(SsoListener { port, handle })
|
||||
}
|
||||
|
||||
async fn serve_one(listener: std::net::TcpListener) -> io::Result<Result<String, String>> {
|
||||
let listener = tokio::net::TcpListener::from_std(listener)?;
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await?;
|
||||
let request_line = read_request_line(&mut stream).await?;
|
||||
let Some(result) = parse_request_line(&request_line) else {
|
||||
// Stray request (favicon, prefetch) — keep waiting for the code.
|
||||
let _ = stream.write_all(&response(404, "Not Found")).await;
|
||||
continue;
|
||||
};
|
||||
let page = match &result {
|
||||
Ok(_) => "Sign-in complete. You can close this window and return to the terminal.",
|
||||
Err(_) => "Sign-in failed. Return to the terminal to see the error.",
|
||||
};
|
||||
let _ = stream.write_all(&response(200, page)).await;
|
||||
let _ = stream.shutdown().await;
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_request_line(stream: &mut tokio::net::TcpStream) -> io::Result<String> {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut len = 0;
|
||||
while len < buf.len() {
|
||||
let n = stream.read(&mut buf[len..]).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
len += n;
|
||||
if buf[..len].windows(2).any(|w| w == b"\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&buf[..len]);
|
||||
Ok(text.lines().next().unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
/// `GET /callback?code=furu_mx_... HTTP/1.1` → Ok(code) / Err(error).
|
||||
/// Values are plain tokens (no percent-encoded characters expected).
|
||||
fn parse_request_line(line: &str) -> Option<Result<String, String>> {
|
||||
let path = line.split_whitespace().nth(1)?;
|
||||
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||
let mut code = None;
|
||||
let mut error = None;
|
||||
for pair in query.split('&') {
|
||||
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
match key {
|
||||
"code" if !value.is_empty() => code = Some(value.to_string()),
|
||||
"error" if !value.is_empty() => error = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(error) = error {
|
||||
return Some(Err(format!("SSO failed: {error}")));
|
||||
}
|
||||
code.map(Ok)
|
||||
}
|
||||
|
||||
fn response(status: u16, body: &str) -> Vec<u8> {
|
||||
let reason = if status == 200 { "OK" } else { "Not Found" };
|
||||
let body = format!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>furumi</title></head>\
|
||||
<body style=\"font-family:sans-serif;background:#101114;color:#f5f2ea;\
|
||||
display:grid;place-items:center;min-height:100vh;margin:0\"><p>{body}</p></body></html>"
|
||||
);
|
||||
format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_code_from_request_line() {
|
||||
assert_eq!(
|
||||
parse_request_line("GET /callback?code=furu_mx_abc HTTP/1.1"),
|
||||
Some(Ok("furu_mx_abc".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_error_from_request_line() {
|
||||
assert_eq!(
|
||||
parse_request_line("GET /callback?error=provider_denied HTTP/1.1"),
|
||||
Some(Err("SSO failed: provider_denied".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unrelated_requests() {
|
||||
assert_eq!(parse_request_line("GET /favicon.ico HTTP/1.1"), None);
|
||||
assert_eq!(parse_request_line("GET /callback HTTP/1.1"), None);
|
||||
}
|
||||
}
|
||||
+120
-122
@@ -1,12 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::models::{
|
||||
ArtistCard, ArtistDetail, DeviceDto, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
|
||||
SearchResults, TrackItem, User,
|
||||
};
|
||||
use crate::art::ArtImage;
|
||||
use crate::config::keymap::KeyContext;
|
||||
use crate::library::models::{
|
||||
ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
|
||||
SearchResults, TrackItem,
|
||||
};
|
||||
|
||||
/// Remote data that a view renders: spinner, content, or error.
|
||||
#[derive(Debug)]
|
||||
@@ -83,9 +83,12 @@ pub struct GlobalTab {
|
||||
pub selected: usize,
|
||||
pub view: ViewMode,
|
||||
pub stack: Vec<GlobalView>,
|
||||
/// Page size, fixed at the first request — the server's offset is
|
||||
/// Page size, fixed at the first request — the offset is
|
||||
/// `(page-1) * limit`, so it must not change between pages.
|
||||
pub page_limit: Option<i64>,
|
||||
/// A full atomic reload is in flight (after a library change); incoming
|
||||
/// pages of the old pagination are dropped until it lands.
|
||||
pub reloading: bool,
|
||||
}
|
||||
|
||||
impl Default for GlobalTab {
|
||||
@@ -101,6 +104,7 @@ impl Default for GlobalTab {
|
||||
view: ViewMode::default(),
|
||||
stack: Vec::new(),
|
||||
page_limit: None,
|
||||
reloading: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,8 +165,8 @@ pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>>
|
||||
rows
|
||||
}
|
||||
|
||||
/// The virtual server-side Likes playlist id (`kind == "likes"`).
|
||||
pub const LIKES_PLAYLIST_ID: i64 = -1;
|
||||
/// The virtual Likes playlist id (`kind == "likes"`).
|
||||
pub use crate::library::LIKES_PLAYLIST_ID;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OpenedPlaylist {
|
||||
@@ -282,42 +286,45 @@ impl Default for LogsTab {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DevicesState {
|
||||
pub device_id: String,
|
||||
pub active_device_id: Option<String>,
|
||||
pub devices: Vec<DeviceDto>,
|
||||
pub poll_error: Option<String>,
|
||||
pub switching_to: Option<String>,
|
||||
/// What an open edit form writes to when saved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EditTarget {
|
||||
Track(i64),
|
||||
Release(i64),
|
||||
Artist(i64),
|
||||
Playlist(i64),
|
||||
}
|
||||
|
||||
impl DevicesState {
|
||||
pub fn is_playback_device(&self) -> bool {
|
||||
self.active_device_id
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == self.device_id)
|
||||
}
|
||||
/// What a confirmed delete removes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeleteTarget {
|
||||
Track(i64),
|
||||
Release(i64),
|
||||
Artist(i64),
|
||||
Playlist(i64),
|
||||
}
|
||||
|
||||
pub fn remote_target_id(&self) -> Option<&str> {
|
||||
self.active_device_id
|
||||
.as_deref()
|
||||
.filter(|active| *active != self.device_id)
|
||||
}
|
||||
/// One text field of an edit form.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditField {
|
||||
pub label: &'static str,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub fn active_device_name(&self) -> Option<&str> {
|
||||
let active = self.active_device_id.as_deref()?;
|
||||
self.devices
|
||||
.iter()
|
||||
.find(|device| device.id == active)
|
||||
.map(|device| device.name.as_str())
|
||||
impl EditField {
|
||||
pub fn new(label: &'static str, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal dialog over the main screen.
|
||||
#[derive(Debug)]
|
||||
pub enum Popup {
|
||||
/// Pick one of the user's playlists (row 0 = "create new"); the track
|
||||
/// is added on Enter.
|
||||
/// Pick one of the playlists (row 0 = "create new"); the track is added
|
||||
/// on Enter.
|
||||
AddToPlaylist { track: TrackItem, cursor: usize },
|
||||
/// Name input for a new playlist; when `for_track` is set, the track is
|
||||
/// added to it right after creation.
|
||||
@@ -326,8 +333,16 @@ pub enum Popup {
|
||||
input: String,
|
||||
busy: bool,
|
||||
},
|
||||
/// Connected devices list; Enter transfers active playback to the row.
|
||||
Devices { cursor: usize },
|
||||
/// Metadata edit form for a track, release, artist or playlist.
|
||||
Edit {
|
||||
target: EditTarget,
|
||||
title: String,
|
||||
fields: Vec<EditField>,
|
||||
focus: usize,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
|
||||
ConfirmDelete { target: DeleteTarget, label: String },
|
||||
/// Track metadata viewer; left/right switch between selected tracks.
|
||||
TrackInfo {
|
||||
tracks: Vec<TrackItem>,
|
||||
@@ -336,15 +351,67 @@ pub enum Popup {
|
||||
},
|
||||
/// Full, wrapped view of one log entry (Enter on the Logs tab).
|
||||
LogDetail(crate::config::logging::LogEntry),
|
||||
/// One-line text entry on the Federation tab (network id, peer ticket).
|
||||
FedInput {
|
||||
field: FedInputField,
|
||||
input: String,
|
||||
},
|
||||
/// Wrapped read-only text (this peer's connection ticket).
|
||||
FedText { title: String, text: String },
|
||||
}
|
||||
|
||||
/// User's own playlists eligible as add-targets (the virtual Likes playlist
|
||||
/// is managed through likes, not direct adds).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FedInputField {
|
||||
NetworkId,
|
||||
ConnectTicket,
|
||||
}
|
||||
|
||||
impl FedInputField {
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
FedInputField::NetworkId => "Network ID",
|
||||
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows of the Federation tab, in display order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FedRow {
|
||||
Toggle,
|
||||
NetworkId,
|
||||
SaveOnListen,
|
||||
SyncNow,
|
||||
ShowTicket,
|
||||
Connect,
|
||||
}
|
||||
|
||||
impl FedRow {
|
||||
pub const ALL: [FedRow; 6] = [
|
||||
FedRow::Toggle,
|
||||
FedRow::NetworkId,
|
||||
FedRow::SaveOnListen,
|
||||
FedRow::SyncNow,
|
||||
FedRow::ShowTicket,
|
||||
FedRow::Connect,
|
||||
];
|
||||
}
|
||||
|
||||
/// The Federation tab: settings mirror + the latest status snapshot.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FederationTab {
|
||||
pub cursor: usize,
|
||||
pub settings: crate::federation::FedSettings,
|
||||
pub status: Option<crate::federation::FedStatus>,
|
||||
}
|
||||
|
||||
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
||||
/// through likes, not direct adds).
|
||||
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
|
||||
match &state.playlists.list {
|
||||
Some(Loadable::Ready(list)) => list
|
||||
.iter()
|
||||
.filter(|p| p.is_own && p.kind != "likes")
|
||||
.filter(|p| p.kind != "likes")
|
||||
.map(|p| (p.id, p.title.clone()))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
@@ -367,85 +434,10 @@ pub struct SearchState {
|
||||
pub query: String,
|
||||
pub loading: bool,
|
||||
pub results: Option<SearchResults>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Screen {
|
||||
#[default]
|
||||
Main,
|
||||
Login,
|
||||
}
|
||||
|
||||
/// SSO is the primary sign-in path, so it sits right under the server URL;
|
||||
/// the password fields below are the rare fallback.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LoginField {
|
||||
#[default]
|
||||
ServerUrl,
|
||||
SsoButton,
|
||||
Username,
|
||||
Password,
|
||||
SignInButton,
|
||||
}
|
||||
|
||||
impl LoginField {
|
||||
const ORDER: [LoginField; 5] = [
|
||||
LoginField::ServerUrl,
|
||||
LoginField::SsoButton,
|
||||
LoginField::Username,
|
||||
LoginField::Password,
|
||||
LoginField::SignInButton,
|
||||
];
|
||||
|
||||
pub fn next(self) -> LoginField {
|
||||
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
|
||||
Self::ORDER[(i + 1) % Self::ORDER.len()]
|
||||
}
|
||||
|
||||
pub fn prev(self) -> LoginField {
|
||||
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
|
||||
Self::ORDER[(i + Self::ORDER.len() - 1) % Self::ORDER.len()]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LoginMode {
|
||||
/// Server / username / password fields plus the SSO button.
|
||||
#[default]
|
||||
Form,
|
||||
/// Browser SSO started; waiting for the pasted callback link or code.
|
||||
SsoPending,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoginForm {
|
||||
pub server_url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub sso_paste: String,
|
||||
pub sso_url: String,
|
||||
pub sso_port: Option<u16>,
|
||||
pub focus: LoginField,
|
||||
pub mode: LoginMode,
|
||||
pub busy: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for LoginForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_url: "https://music.hexor.cy".to_string(),
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
sso_paste: String::new(),
|
||||
sso_url: String::new(),
|
||||
sso_port: None,
|
||||
focus: LoginField::default(),
|
||||
mode: LoginMode::default(),
|
||||
busy: false,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
/// Tracks found on the federated network (empty while federation is
|
||||
/// off); rendered as a separate, marked section.
|
||||
pub fed_tracks: Vec<crate::federation::FedTrack>,
|
||||
pub fed_loading: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -454,17 +446,25 @@ pub enum Tab {
|
||||
Global,
|
||||
Playlists,
|
||||
Queue,
|
||||
Federation,
|
||||
Logs,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Logs];
|
||||
pub const ALL: [Tab; 5] = [
|
||||
Tab::Global,
|
||||
Tab::Playlists,
|
||||
Tab::Queue,
|
||||
Tab::Federation,
|
||||
Tab::Logs,
|
||||
];
|
||||
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Tab::Global => "Global",
|
||||
Tab::Playlists => "Playlists",
|
||||
Tab::Queue => "Queue",
|
||||
Tab::Federation => "Federation",
|
||||
Tab::Logs => "Logs",
|
||||
}
|
||||
}
|
||||
@@ -490,6 +490,7 @@ impl Tab {
|
||||
Tab::Global => KeyContext::Library,
|
||||
Tab::Playlists => KeyContext::Playlists,
|
||||
Tab::Queue => KeyContext::Queue,
|
||||
Tab::Federation => KeyContext::Federation,
|
||||
Tab::Logs => KeyContext::Logs,
|
||||
}
|
||||
}
|
||||
@@ -567,7 +568,6 @@ impl Default for PlayerBar {
|
||||
/// event handlers in the main loop; views render from `&AppState`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AppState {
|
||||
pub screen: Screen,
|
||||
pub active_tab: Tab,
|
||||
pub should_quit: bool,
|
||||
/// Double-press quit confirmation: set by the first Quit press, expires
|
||||
@@ -577,8 +577,6 @@ pub struct AppState {
|
||||
pub pending_keys: Option<String>,
|
||||
pub status_message: Option<String>,
|
||||
pub player: PlayerBar,
|
||||
pub login: LoginForm,
|
||||
pub user: Option<User>,
|
||||
pub global: GlobalTab,
|
||||
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
|
||||
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
||||
@@ -588,8 +586,8 @@ pub struct AppState {
|
||||
pub likes: std::collections::HashSet<i64>,
|
||||
pub likes_loaded: bool,
|
||||
pub logs: LogsTab,
|
||||
pub devices: DevicesState,
|
||||
pub queue_tab: QueueTab,
|
||||
pub federation: FederationTab,
|
||||
pub track_selection: TrackSelection,
|
||||
/// Shift-J jump in flight: focus this (release, track) once the release
|
||||
/// view finishes loading.
|
||||
|
||||
+370
-87
@@ -1,7 +1,7 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::action::Action;
|
||||
use crate::api::models::TrackItem;
|
||||
use crate::library::models::TrackItem;
|
||||
|
||||
use super::state::{
|
||||
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
||||
@@ -31,11 +31,24 @@ pub enum Effect {
|
||||
ToggleLikes {
|
||||
track_ids: Vec<i64>,
|
||||
},
|
||||
/// Remove tracks from a stored playlist in the library.
|
||||
RemoveFromPlaylist {
|
||||
playlist_id: i64,
|
||||
track_ids: Vec<i64>,
|
||||
},
|
||||
RemoveQueueIndices {
|
||||
indices: Vec<usize>,
|
||||
restart_paused: Option<bool>,
|
||||
stop: bool,
|
||||
},
|
||||
/// Persist the Federation-tab settings and start/stop the node.
|
||||
FedApplySettings,
|
||||
/// Force an immediate library publish into the DHT.
|
||||
FedSyncNow,
|
||||
/// Fetch this peer's ticket and show it in a popup.
|
||||
FedShowTicket,
|
||||
/// Download (or resolve) a federated track and play it.
|
||||
FedPlay(crate::federation::FedTrack),
|
||||
}
|
||||
|
||||
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
@@ -195,18 +208,6 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
}
|
||||
None
|
||||
}
|
||||
Action::OpenDevices => {
|
||||
let cursor = state
|
||||
.devices
|
||||
.devices
|
||||
.iter()
|
||||
.position(|device| {
|
||||
device.id == state.devices.active_device_id.clone().unwrap_or_default()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
state.popup = Some(super::state::Popup::Devices { cursor });
|
||||
None
|
||||
}
|
||||
Action::Select => select_current(state),
|
||||
Action::Back if state.track_selection.is_active() => {
|
||||
state.track_selection.clear();
|
||||
@@ -310,9 +311,243 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
None
|
||||
}
|
||||
}
|
||||
// Needs the Runtime, so it is intercepted in app::handle_main_key
|
||||
// before reaching update().
|
||||
Action::Logout => None,
|
||||
Action::EditSelected => {
|
||||
open_edit_popup(state);
|
||||
None
|
||||
}
|
||||
Action::DeleteSelected => delete_selected(state),
|
||||
}
|
||||
}
|
||||
|
||||
/// `e`: open the metadata edit form for whatever is under the cursor —
|
||||
/// an artist tile, a release, a track or a playlist.
|
||||
fn open_edit_popup(state: &mut AppState) {
|
||||
use super::state::{EditField, EditTarget, Popup};
|
||||
|
||||
if state.active_tab == Tab::Playlists && state.playlists.opened.is_none() {
|
||||
let card = match &state.playlists.list {
|
||||
Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(card) = card else {
|
||||
return;
|
||||
};
|
||||
if card.kind == "likes" {
|
||||
state.status_message = Some("the Likes playlist cannot be edited".into());
|
||||
return;
|
||||
}
|
||||
state.popup = Some(Popup::Edit {
|
||||
target: EditTarget::Playlist(card.id),
|
||||
title: format!("Edit playlist — {}", card.title),
|
||||
fields: vec![EditField::new("Title", card.title.clone())],
|
||||
focus: 0,
|
||||
error: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if state.active_tab == Tab::Global && state.global.stack.is_empty() {
|
||||
let Some(artist) = state.global.artists.get(state.global.selected).cloned() else {
|
||||
return;
|
||||
};
|
||||
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
|
||||
return;
|
||||
}
|
||||
if let Some(artist) = selected_search_artist(state) {
|
||||
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
|
||||
return;
|
||||
}
|
||||
if let Some(release) = selected_release_card(state) {
|
||||
state.popup = Some(Popup::Edit {
|
||||
target: EditTarget::Release(release.id),
|
||||
title: format!("Edit release — {}", release.title),
|
||||
fields: vec![
|
||||
EditField::new("Title", release.title.clone()),
|
||||
EditField::new("Type", release.release_type.clone()),
|
||||
EditField::new(
|
||||
"Year",
|
||||
release.year.map(|y| y.to_string()).unwrap_or_default(),
|
||||
),
|
||||
],
|
||||
focus: 0,
|
||||
error: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Some(track) = selected_track(state).or_else(|| state.player.current.clone()) {
|
||||
state.popup = Some(track_edit_popup(&track));
|
||||
return;
|
||||
}
|
||||
state.status_message = Some("nothing to edit here".into());
|
||||
}
|
||||
|
||||
fn artist_edit_popup(
|
||||
id: i64,
|
||||
name: &str,
|
||||
image_path: Option<String>,
|
||||
) -> super::state::Popup {
|
||||
use super::state::{EditField, EditTarget, Popup};
|
||||
Popup::Edit {
|
||||
target: EditTarget::Artist(id),
|
||||
title: format!("Edit artist — {name}"),
|
||||
fields: vec![
|
||||
EditField::new("Name", name),
|
||||
EditField::new("Image path", image_path.unwrap_or_default()),
|
||||
],
|
||||
focus: 0,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn track_edit_popup(track: &TrackItem) -> super::state::Popup {
|
||||
use super::state::{EditField, EditTarget, Popup};
|
||||
let join = |artists: &[crate::library::models::ArtistRef]| {
|
||||
artists
|
||||
.iter()
|
||||
.map(|a| a.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
};
|
||||
Popup::Edit {
|
||||
target: EditTarget::Track(track.id),
|
||||
title: format!("Edit track — {}", track.title),
|
||||
fields: vec![
|
||||
EditField::new("Title", track.title.clone()),
|
||||
EditField::new("Artists", join(&track.artists)),
|
||||
EditField::new("Featured", join(&track.featured_artists)),
|
||||
EditField::new(
|
||||
"Track #",
|
||||
track.track_number.map(|n| n.to_string()).unwrap_or_default(),
|
||||
),
|
||||
EditField::new(
|
||||
"Disc #",
|
||||
track.disc_number.map(|n| n.to_string()).unwrap_or_default(),
|
||||
),
|
||||
],
|
||||
focus: 0,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// shift-d: delete whatever is under the cursor. Library entities ask for
|
||||
/// confirmation; playlist/queue rows are removed directly.
|
||||
fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
||||
use super::state::{DeleteTarget, Popup};
|
||||
|
||||
if state.active_tab == Tab::Queue {
|
||||
return remove_selected_from_queue(state);
|
||||
}
|
||||
if state.active_tab == Tab::Playlists {
|
||||
match state.playlists.opened {
|
||||
None => {
|
||||
let card = match &state.playlists.list {
|
||||
Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
let card = card?;
|
||||
if card.kind == "likes" {
|
||||
state.status_message = Some("the Likes playlist cannot be deleted".into());
|
||||
return None;
|
||||
}
|
||||
state.popup = Some(Popup::ConfirmDelete {
|
||||
target: DeleteTarget::Playlist(card.id),
|
||||
label: format!("playlist \"{}\"", card.title),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
Some(opened) => {
|
||||
let tracks = selected_tracks(state);
|
||||
if tracks.is_empty() {
|
||||
state.status_message = Some("no track selected".into());
|
||||
return None;
|
||||
}
|
||||
let track_ids: Vec<i64> = tracks.iter().map(|track| track.id).collect();
|
||||
state.track_selection.clear();
|
||||
if opened.id == super::state::LIKES_PLAYLIST_ID {
|
||||
let liked: Vec<i64> = track_ids
|
||||
.into_iter()
|
||||
.filter(|id| state.likes.contains(id))
|
||||
.collect();
|
||||
state.status_message = Some(format!("removing {} like(s)", liked.len()));
|
||||
return Some(Effect::ToggleLikes { track_ids: liked });
|
||||
}
|
||||
state.status_message =
|
||||
Some(format!("removing {} track(s) from playlist", track_ids.len()));
|
||||
return Some(Effect::RemoveFromPlaylist {
|
||||
playlist_id: opened.id,
|
||||
track_ids,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if state.active_tab != Tab::Global {
|
||||
return None;
|
||||
}
|
||||
if state.global.stack.is_empty() {
|
||||
let artist = state.global.artists.get(state.global.selected).cloned()?;
|
||||
state.popup = Some(Popup::ConfirmDelete {
|
||||
target: DeleteTarget::Artist(artist.id),
|
||||
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
if let Some(artist) = selected_search_artist(state) {
|
||||
state.popup = Some(Popup::ConfirmDelete {
|
||||
target: DeleteTarget::Artist(artist.id),
|
||||
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
if let Some(release) = selected_release_card(state) {
|
||||
state.popup = Some(Popup::ConfirmDelete {
|
||||
target: DeleteTarget::Release(release.id),
|
||||
label: format!("release \"{}\" with all its tracks", release.title),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
if let Some(track) = selected_track(state) {
|
||||
state.popup = Some(Popup::ConfirmDelete {
|
||||
target: DeleteTarget::Track(track.id),
|
||||
label: format!("track \"{}\" (the audio file stays on disk)", track.title),
|
||||
});
|
||||
} else {
|
||||
state.status_message = Some("nothing to delete here".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The artist under the cursor in the search view, if any.
|
||||
fn selected_search_artist(state: &AppState) -> Option<crate::library::models::ArtistCard> {
|
||||
if state.active_tab != Tab::Global {
|
||||
return None;
|
||||
}
|
||||
let Some(GlobalView::Search { cursor }) = state.global.stack.last() else {
|
||||
return None;
|
||||
};
|
||||
state.search.results.as_ref()?.artists.get(*cursor).cloned()
|
||||
}
|
||||
|
||||
/// The release card under the cursor (artist view tiles/rows or search).
|
||||
fn selected_release_card(state: &AppState) -> Option<crate::library::models::ReleaseCard> {
|
||||
if state.active_tab != Tab::Global {
|
||||
return None;
|
||||
}
|
||||
match state.global.stack.last()? {
|
||||
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
|
||||
Loadable::Ready(detail) => {
|
||||
let position = cursor.checked_sub(detail.top_tracks.len())?;
|
||||
let order = release_display_order(&detail.releases);
|
||||
order
|
||||
.get(position)
|
||||
.map(|&index| detail.releases[index].clone())
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
GlobalView::Search { cursor } => {
|
||||
let results = state.search.results.as_ref()?;
|
||||
let offset = cursor.checked_sub(results.artists.len())?;
|
||||
results.releases.get(offset).cloned()
|
||||
}
|
||||
GlobalView::Release { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,11 +621,10 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
||||
}
|
||||
}
|
||||
TrackSelectionScope::Playlist(id) => {
|
||||
if let Some(opened) = &mut state.playlists.opened {
|
||||
if opened.id == *id {
|
||||
if let Some(opened) = &mut state.playlists.opened
|
||||
&& opened.id == *id {
|
||||
opened.cursor = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackSelectionScope::Queue => {
|
||||
state.queue_tab.cursor = value;
|
||||
@@ -438,7 +672,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
||||
state.queue_tab.cursor,
|
||||
state.player.queue.len(),
|
||||
)),
|
||||
Tab::Logs => None,
|
||||
Tab::Federation | Tab::Logs => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +721,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
|
||||
state.queue_tab.cursor,
|
||||
&state.player.queue,
|
||||
)),
|
||||
Tab::Logs => None,
|
||||
Tab::Federation | Tab::Logs => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,7 +887,7 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||
.cloned()
|
||||
}
|
||||
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
|
||||
Tab::Logs => None,
|
||||
Tab::Federation | Tab::Logs => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,11 +1003,10 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
|
||||
for (offset, track) in tracks.into_iter().enumerate() {
|
||||
player.queue.insert(insert_at + offset, track);
|
||||
}
|
||||
if let Some(prefetched) = &mut player.prefetched_pos {
|
||||
if insert_at <= *prefetched {
|
||||
if let Some(prefetched) = &mut player.prefetched_pos
|
||||
&& insert_at <= *prefetched {
|
||||
*prefetched += count;
|
||||
}
|
||||
}
|
||||
if insert_at <= player.queue_pos && player.current.is_some() {
|
||||
player.queue_pos += count;
|
||||
}
|
||||
@@ -885,7 +1118,7 @@ pub fn restore_queue_order(player: &mut super::state::PlayerBar) {
|
||||
}
|
||||
let tail = player.queue.split_off(start);
|
||||
let mut used = vec![false; order.len()];
|
||||
let mut keyed: Vec<(usize, usize, crate::api::models::TrackItem)> = tail
|
||||
let mut keyed: Vec<(usize, usize, crate::library::models::TrackItem)> = tail
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(position, track)| {
|
||||
@@ -954,6 +1187,14 @@ fn page_step(state: &AppState) -> isize {
|
||||
}
|
||||
|
||||
fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
||||
if state.active_tab == Tab::Federation {
|
||||
if dy != 0 {
|
||||
let last = super::state::FedRow::ALL.len() as isize - 1;
|
||||
state.federation.cursor =
|
||||
(state.federation.cursor as isize + dy).clamp(0, last) as usize;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if state.active_tab == Tab::Logs {
|
||||
// The cursor anchors to an entry's seq, so freshly appended log
|
||||
// lines (including ones caused by this very keypress) don't shift
|
||||
@@ -1138,6 +1379,9 @@ fn current_view_len(state: &AppState) -> usize {
|
||||
if state.active_tab == Tab::Queue {
|
||||
return state.player.queue.len();
|
||||
}
|
||||
if state.active_tab == Tab::Federation {
|
||||
return super::state::FedRow::ALL.len();
|
||||
}
|
||||
match state.global.stack.last() {
|
||||
None => state.global.artists.len(),
|
||||
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
|
||||
@@ -1150,7 +1394,9 @@ fn current_view_len(state: &AppState) -> usize {
|
||||
Some(Loadable::Ready(d)) => d.tracks.len(),
|
||||
_ => 0,
|
||||
},
|
||||
Some(GlobalView::Search { .. }) => state.search.results.as_ref().map_or(0, |r| r.len()),
|
||||
Some(GlobalView::Search { .. }) => {
|
||||
state.search.results.as_ref().map_or(0, |r| r.len()) + state.search.fed_tracks.len()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1173,15 +1419,19 @@ fn jump_selection(state: &mut AppState, first: bool) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if state.active_tab == Tab::Federation {
|
||||
let last = super::state::FedRow::ALL.len() - 1;
|
||||
state.federation.cursor = if first { 0 } else { last };
|
||||
return;
|
||||
}
|
||||
if state.active_tab == Tab::Logs {
|
||||
if first {
|
||||
let level = super::state::LOG_LEVELS[state.logs.level_index];
|
||||
if let Some(buffer) = crate::config::logging::buffer() {
|
||||
if let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
|
||||
if let Some(buffer) = crate::config::logging::buffer()
|
||||
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
|
||||
state.logs.selected_seq = Some(seq);
|
||||
state.logs.follow = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.logs.follow = true;
|
||||
state.logs.selected_seq = None;
|
||||
@@ -1257,6 +1507,9 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if state.active_tab == Tab::Federation {
|
||||
return federation_select(state);
|
||||
}
|
||||
// Queue: jump playback to the track under the cursor. Earlier tracks
|
||||
// stay in the queue as "played"; picking one of them just moves the
|
||||
// playing position back.
|
||||
@@ -1274,9 +1527,10 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
enum Outcome {
|
||||
Push(GlobalView),
|
||||
Play {
|
||||
tracks: Vec<crate::api::models::TrackItem>,
|
||||
tracks: Vec<crate::library::models::TrackItem>,
|
||||
start: usize,
|
||||
},
|
||||
PlayFed(crate::federation::FedTrack),
|
||||
Nothing,
|
||||
}
|
||||
let outcome = match state.global.stack.last().copied() {
|
||||
@@ -1347,10 +1601,17 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
start: cursor - artists - releases,
|
||||
}
|
||||
} else {
|
||||
Outcome::Nothing
|
||||
let fed_index = cursor - artists - releases - results.tracks.len();
|
||||
match state.search.fed_tracks.get(fed_index) {
|
||||
Some(fed) => Outcome::PlayFed(fed.clone()),
|
||||
None => Outcome::Nothing,
|
||||
}
|
||||
}
|
||||
}
|
||||
None => Outcome::Nothing,
|
||||
None => match state.search.fed_tracks.get(cursor) {
|
||||
Some(fed) => Outcome::PlayFed(fed.clone()),
|
||||
None => Outcome::Nothing,
|
||||
},
|
||||
},
|
||||
};
|
||||
match outcome {
|
||||
@@ -1364,13 +1625,57 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
on_new_queue(state);
|
||||
Some(Effect::PlayCurrent)
|
||||
}
|
||||
Outcome::PlayFed(fed) => {
|
||||
state.status_message = Some(format!("federation: fetching \"{}\"…", fed.title));
|
||||
Some(Effect::FedPlay(fed))
|
||||
}
|
||||
Outcome::Nothing => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter on the Federation tab: toggle switches, open text inputs, run
|
||||
/// one-shot operations. The heavy lifting happens in perform_effect().
|
||||
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||
use super::state::{FedInputField, FedRow, Popup};
|
||||
match FedRow::ALL.get(state.federation.cursor)? {
|
||||
FedRow::Toggle => {
|
||||
let settings = &mut state.federation.settings;
|
||||
if !settings.enabled && settings.network_id.trim().is_empty() {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::NetworkId,
|
||||
input: String::new(),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
settings.enabled = !settings.enabled;
|
||||
Some(Effect::FedApplySettings)
|
||||
}
|
||||
FedRow::NetworkId => {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::NetworkId,
|
||||
input: state.federation.settings.network_id.clone(),
|
||||
});
|
||||
None
|
||||
}
|
||||
FedRow::SaveOnListen => {
|
||||
state.federation.settings.save_on_listen = !state.federation.settings.save_on_listen;
|
||||
Some(Effect::FedApplySettings)
|
||||
}
|
||||
FedRow::SyncNow => Some(Effect::FedSyncNow),
|
||||
FedRow::ShowTicket => Some(Effect::FedShowTicket),
|
||||
FedRow::Connect => {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::ConnectTicket,
|
||||
input: String::new(),
|
||||
});
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A freshly created play context: drop the stale pre-shuffle snapshot and,
|
||||
/// if shuffle is on, shuffle everything after the chosen track right away.
|
||||
fn on_new_queue(state: &mut AppState) {
|
||||
pub(super) fn on_new_queue(state: &mut AppState) {
|
||||
let player = &mut state.player;
|
||||
player.original_order = None;
|
||||
if player.shuffle && !player.queue.is_empty() {
|
||||
@@ -1409,19 +1714,17 @@ fn go_back(state: &mut AppState) {
|
||||
Tab::Global => {
|
||||
// Esc on a view opened by Shift-J from another tab goes back to
|
||||
// that tab, not down the Global stack.
|
||||
if let Some((origin, depth)) = state.jump_origin {
|
||||
if state.global.stack.len() == depth + 1 {
|
||||
if let Some((origin, depth)) = state.jump_origin
|
||||
&& state.global.stack.len() == depth + 1 {
|
||||
state.global.stack.pop();
|
||||
state.jump_origin = None;
|
||||
state.active_tab = origin;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(popped) = state.global.stack.pop() {
|
||||
if matches!(popped, GlobalView::Search { .. }) {
|
||||
if let Some(popped) = state.global.stack.pop()
|
||||
&& matches!(popped, GlobalView::Search { .. }) {
|
||||
state.search = SearchState::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1450,6 +1753,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
|
||||
state.global.stack.clear();
|
||||
}
|
||||
Tab::Playlists => state.playlists.opened = None,
|
||||
Tab::Federation => state.federation.cursor = 0,
|
||||
Tab::Logs => {
|
||||
state.logs.follow = true;
|
||||
state.logs.selected_seq = None;
|
||||
@@ -1465,7 +1769,7 @@ fn not_yet(state: &mut AppState, what: &str) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::models::{ArtistCard, ArtistDetail, TrackItem};
|
||||
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem};
|
||||
|
||||
fn with_artists(n: usize) -> AppState {
|
||||
let mut state = AppState::default();
|
||||
@@ -1473,7 +1777,7 @@ mod tests {
|
||||
.map(|i| ArtistCard {
|
||||
id: i as i64,
|
||||
name: format!("artist {i}"),
|
||||
image_url: None,
|
||||
image_path: None,
|
||||
release_count: 1,
|
||||
track_count: 2,
|
||||
})
|
||||
@@ -1493,18 +1797,14 @@ mod tests {
|
||||
release_id: 1,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_url: None,
|
||||
stream_url: format!("/s/{id}"),
|
||||
uploader_name: String::new(),
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
lastfm_listeners: None,
|
||||
lastfm_playcount: None,
|
||||
lastfm_rating: None,
|
||||
lastfm_updated_at: None,
|
||||
play_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1619,14 +1919,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn artist_tiles_move_by_visual_rows_across_groups() {
|
||||
use crate::api::models::{ArtistDetail, ReleaseCard};
|
||||
use crate::library::models::{ArtistDetail, ReleaseCard};
|
||||
|
||||
let release = |id: i64, kind: &str| ReleaseCard {
|
||||
id,
|
||||
title: format!("r{id}"),
|
||||
release_type: kind.to_string(),
|
||||
year: None,
|
||||
cover_url: None,
|
||||
cover_path: None,
|
||||
track_count: 1,
|
||||
};
|
||||
// columns = 3 in tests (no tty → 80 wide): albums rows [0,1,2],[3],
|
||||
@@ -1634,7 +1934,7 @@ mod tests {
|
||||
let detail = ArtistDetail {
|
||||
id: 1,
|
||||
name: "a".into(),
|
||||
image_url: None,
|
||||
image_path: None,
|
||||
total_track_count: 0,
|
||||
total_play_count: 0,
|
||||
top_tracks: vec![],
|
||||
@@ -1707,7 +2007,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn queue_advances_and_respects_repeat() {
|
||||
use crate::api::models::TrackItem;
|
||||
use crate::library::models::TrackItem;
|
||||
use crate::app::state::RepeatMode;
|
||||
|
||||
let track = |id: i64| TrackItem {
|
||||
@@ -1721,18 +2021,14 @@ mod tests {
|
||||
release_id: 1,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_url: None,
|
||||
stream_url: format!("/api/player/stream/{id}"),
|
||||
uploader_name: String::new(),
|
||||
cover_path: None,
|
||||
file_path: format!("/api/player/stream/{id}"),
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
lastfm_listeners: None,
|
||||
lastfm_playcount: None,
|
||||
lastfm_rating: None,
|
||||
lastfm_updated_at: None,
|
||||
play_count: 0,
|
||||
};
|
||||
let mut state = AppState::default();
|
||||
state.player.queue = vec![track(1), track(2)];
|
||||
@@ -1776,7 +2072,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn queue_tab_select_and_clear() {
|
||||
use crate::api::models::TrackItem;
|
||||
use crate::library::models::TrackItem;
|
||||
let track = |id: i64| TrackItem {
|
||||
id,
|
||||
title: format!("t{id}"),
|
||||
@@ -1788,18 +2084,14 @@ mod tests {
|
||||
release_id: 1,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_url: None,
|
||||
stream_url: format!("/s/{id}"),
|
||||
uploader_name: String::new(),
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
lastfm_listeners: None,
|
||||
lastfm_playcount: None,
|
||||
lastfm_rating: None,
|
||||
lastfm_updated_at: None,
|
||||
play_count: 0,
|
||||
};
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
@@ -1870,7 +2162,7 @@ mod tests {
|
||||
Loadable::Ready(ArtistDetail {
|
||||
id: 9,
|
||||
name: "artist".into(),
|
||||
image_url: None,
|
||||
image_path: None,
|
||||
total_track_count: 3,
|
||||
total_play_count: 0,
|
||||
top_tracks: (1..=3).map(test_track).collect(),
|
||||
@@ -1942,7 +2234,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn shuffle_reorders_tail_and_restores() {
|
||||
use crate::api::models::TrackItem;
|
||||
use crate::library::models::TrackItem;
|
||||
let track = |id: i64| TrackItem {
|
||||
id,
|
||||
title: format!("t{id}"),
|
||||
@@ -1954,18 +2246,14 @@ mod tests {
|
||||
release_id: 1,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_url: None,
|
||||
stream_url: format!("/s/{id}"),
|
||||
uploader_name: String::new(),
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
lastfm_listeners: None,
|
||||
lastfm_playcount: None,
|
||||
lastfm_rating: None,
|
||||
lastfm_updated_at: None,
|
||||
play_count: 0,
|
||||
};
|
||||
let mut state = AppState::default();
|
||||
state.player.queue = (1..=8).map(track).collect();
|
||||
@@ -1991,7 +2279,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn shift_j_opens_release_from_queue() {
|
||||
use crate::api::models::{ReleaseDetail, TrackItem};
|
||||
use crate::library::models::{ReleaseDetail, TrackItem};
|
||||
let track = |id: i64, release_id: i64| TrackItem {
|
||||
id,
|
||||
title: format!("t{id}"),
|
||||
@@ -2003,18 +2291,14 @@ mod tests {
|
||||
release_id,
|
||||
release_title: "r".into(),
|
||||
release_year: None,
|
||||
cover_url: None,
|
||||
stream_url: format!("/s/{id}"),
|
||||
uploader_name: String::new(),
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
lastfm_listeners: None,
|
||||
lastfm_playcount: None,
|
||||
lastfm_rating: None,
|
||||
lastfm_updated_at: None,
|
||||
play_count: 0,
|
||||
};
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
@@ -2048,10 +2332,9 @@ mod tests {
|
||||
title: "r".into(),
|
||||
release_type: "album".into(),
|
||||
year: None,
|
||||
cover_url: None,
|
||||
cover_path: None,
|
||||
artists: vec![],
|
||||
tracks: vec![track(1, 7), track(2, 7)],
|
||||
uploaders: vec![],
|
||||
}),
|
||||
);
|
||||
state.active_tab = Tab::Queue;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# command: an Action name, optionally with parameters:
|
||||
# command = { SeekForward = { seconds = 30 } }
|
||||
# context: optional view filter — global (default), library, search,
|
||||
# playlists, queue, devices.
|
||||
# playlists, queue, logs.
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "q"
|
||||
@@ -47,6 +47,10 @@ command = { GoToTab = 2 }
|
||||
key_sequence = "4"
|
||||
command = { GoToTab = 3 }
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "5"
|
||||
command = { GoToTab = 4 }
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "a"
|
||||
command = "QueueAddNext"
|
||||
@@ -204,12 +208,12 @@ key_sequence = "i"
|
||||
command = "OpenTrackInfo"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-l"
|
||||
command = "Logout"
|
||||
key_sequence = "e"
|
||||
command = "EditSelected"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "shift-d"
|
||||
command = "OpenDevices"
|
||||
command = "DeleteSelected"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "v"
|
||||
|
||||
+8
-11
@@ -20,7 +20,7 @@ pub enum KeyContext {
|
||||
Search,
|
||||
Playlists,
|
||||
Queue,
|
||||
Devices,
|
||||
Federation,
|
||||
Logs,
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ impl KeyContext {
|
||||
KeyContext::Search => "search",
|
||||
KeyContext::Playlists => "playlists",
|
||||
KeyContext::Queue => "queue",
|
||||
KeyContext::Devices => "devices",
|
||||
KeyContext::Federation => "federation",
|
||||
KeyContext::Logs => "logs",
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,8 @@ impl Keymap {
|
||||
let mut bindings =
|
||||
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
|
||||
let mut warning = None;
|
||||
if let Some(path) = user_keymap_path() {
|
||||
if path.exists() {
|
||||
if let Some(path) = user_keymap_path()
|
||||
&& path.exists() {
|
||||
match fs::read_to_string(&path)
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|text| parse_bindings(&text))
|
||||
@@ -96,7 +96,6 @@ impl Keymap {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let keymap = Self {
|
||||
bindings,
|
||||
pending: Vec::new(),
|
||||
@@ -219,11 +218,10 @@ enum Lookup {
|
||||
/// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works);
|
||||
/// symbols drop it so a "?" binding matches everywhere.
|
||||
fn normalize(key: KeyCombination) -> KeyCombination {
|
||||
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes {
|
||||
if !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes
|
||||
&& !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
|
||||
}
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
@@ -303,8 +301,8 @@ fn parse_chord(chord: &str) -> Result<KeyCombination> {
|
||||
// crokey only parses single-byte characters; non-ASCII keys (Cyrillic
|
||||
// bindings) are built directly.
|
||||
let mut chars = chord.chars();
|
||||
if let (Some(c), None) = (chars.next(), chars.next()) {
|
||||
if !c.is_ascii() {
|
||||
if let (Some(c), None) = (chars.next(), chars.next())
|
||||
&& !c.is_ascii() {
|
||||
let modifiers = if c.is_uppercase() {
|
||||
KeyModifiers::SHIFT
|
||||
} else {
|
||||
@@ -312,7 +310,6 @@ fn parse_chord(chord: &str) -> Result<KeyCombination> {
|
||||
};
|
||||
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
|
||||
}
|
||||
}
|
||||
KeyCombination::from_str(chord)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.map(normalize)
|
||||
|
||||
@@ -2,54 +2,7 @@ pub mod keymap;
|
||||
pub mod logging;
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub fn project_dirs() -> Option<ProjectDirs> {
|
||||
ProjectDirs::from("", "", "furumi")
|
||||
}
|
||||
|
||||
pub fn device_id_path() -> Option<PathBuf> {
|
||||
project_dirs().map(|dirs| dirs.config_dir().join("device_id"))
|
||||
}
|
||||
|
||||
pub fn load_or_create_device_id() -> String {
|
||||
if let Some(path) = device_id_path() {
|
||||
if let Ok(raw) = fs::read_to_string(&path) {
|
||||
let id = raw.trim();
|
||||
if valid_device_id(id) {
|
||||
return id.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let id = generate_device_id();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(err) = fs::create_dir_all(parent) {
|
||||
tracing::warn!(path = %parent.display(), %err, "failed to create config directory");
|
||||
return id;
|
||||
}
|
||||
}
|
||||
if let Err(err) = fs::write(&path, &id) {
|
||||
tracing::warn!(path = %path.display(), %err, "failed to persist device id");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
generate_device_id()
|
||||
}
|
||||
|
||||
fn valid_device_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& id
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
}
|
||||
|
||||
fn generate_device_id() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("tui-{nanos:x}-{:x}", std::process::id())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! The peer-to-peer audio protocol, wire compatible with furumi-fd.
|
||||
//!
|
||||
//! One byte stream per request: the requester sends one JSON line
|
||||
//! ([`AudioRequest`]) and receives one JSON line ([`AudioResponseHeader`])
|
||||
//! followed by the raw file bytes from the requested offset.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
|
||||
use crate::library::Library;
|
||||
|
||||
/// ALPN of the audio streaming protocol (shared with furumi-fd).
|
||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||
|
||||
/// Maximum size of a JSON protocol line (request or response header).
|
||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AudioRequest {
|
||||
/// Hex-encoded [`ItemId`] of the track.
|
||||
item_id: String,
|
||||
/// Byte offset to start streaming from.
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AudioResponseHeader {
|
||||
ok: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
mime_type: String,
|
||||
#[serde(default)]
|
||||
total_size: u64,
|
||||
#[serde(default)]
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
pub fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn hex_decode_item_id(value: &str) -> Option<ItemId> {
|
||||
if value.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(ItemId::from_bytes(bytes))
|
||||
}
|
||||
|
||||
fn guess_mime(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"mp3" => "audio/mpeg",
|
||||
"flac" => "audio/flac",
|
||||
"ogg" | "oga" => "audio/ogg",
|
||||
"opus" => "audio/opus",
|
||||
"wav" => "audio/wav",
|
||||
"m4a" | "mp4" | "alac" => "audio/mp4",
|
||||
"aac" => "audio/aac",
|
||||
"aiff" | "aif" => "audio/aiff",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension for a downloaded file, from the mime type the peer reported.
|
||||
fn extension_for_mime(mime: &str) -> &'static str {
|
||||
match mime {
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/flac" | "audio/x-flac" => "flac",
|
||||
"audio/ogg" => "ogg",
|
||||
"audio/opus" => "opus",
|
||||
"audio/wav" | "audio/x-wav" => "wav",
|
||||
"audio/mp4" | "audio/x-m4a" => "m4a",
|
||||
"audio/aac" => "aac",
|
||||
"audio/aiff" => "aiff",
|
||||
_ => "bin",
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one `\n`-terminated line, bounded by [`MAX_PROTOCOL_LINE`].
|
||||
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = reader.read(&mut byte).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("stream ended before the protocol line was complete");
|
||||
}
|
||||
if byte[0] == b'\n' {
|
||||
return Ok(line);
|
||||
}
|
||||
line.push(byte[0]);
|
||||
if line.len() > MAX_PROTOCOL_LINE {
|
||||
anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_line<W: AsyncWriteExt + Unpin>(writer: &mut W, value: &impl Serialize) -> Result<()> {
|
||||
let mut line = serde_json::to_vec(value)?;
|
||||
line.push(b'\n');
|
||||
writer.write_all(&line).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requesting side: download a track from its owner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Downloads a whole track from `owner` into `dir/<stem>.<ext>`; returns
|
||||
/// the file path and the mime type the peer reported. An already complete
|
||||
/// cached file is reused.
|
||||
pub async fn download_track(
|
||||
service: &MusicDhtService,
|
||||
owner: EndpointId,
|
||||
item_id_hex: &str,
|
||||
dir: &Path,
|
||||
stem: &str,
|
||||
) -> Result<(PathBuf, String)> {
|
||||
let mut stream = service
|
||||
.open_stream(owner, AUDIO_ALPN)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("cannot reach the owner peer: {err}"))?;
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioRequest {
|
||||
item_id: item_id_hex.to_string(),
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let header: AudioResponseHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
|
||||
.context("malformed response header")?;
|
||||
if !header.ok {
|
||||
anyhow::bail!(
|
||||
"peer refused the stream: {}",
|
||||
header.error.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
let extension = extension_for_mime(&header.mime_type);
|
||||
let path = dir.join(format!("{stem}.{extension}"));
|
||||
if let Ok(metadata) = tokio::fs::metadata(&path).await
|
||||
&& metadata.len() == header.total_size
|
||||
&& header.total_size > 0
|
||||
{
|
||||
// Already fully downloaded earlier; no need to fetch again.
|
||||
return Ok((path, header.mime_type));
|
||||
}
|
||||
|
||||
let temp_path = dir.join(format!(".{stem}.{extension}.part"));
|
||||
let mut file = tokio::fs::File::create(&temp_path).await?;
|
||||
let mut received: u64 = 0;
|
||||
let mut chunk = vec![0u8; 64 * 1024];
|
||||
// quinn's inherent read returns None when the peer finished the stream.
|
||||
while let Some(n) = stream.recv.read(&mut chunk).await? {
|
||||
file.write_all(&chunk[..n]).await?;
|
||||
received += n as u64;
|
||||
}
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
if header.total_size > 0 && received != header.total_size {
|
||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||
anyhow::bail!(
|
||||
"download incomplete: got {received} of {} bytes",
|
||||
header.total_size
|
||||
);
|
||||
}
|
||||
tokio::fs::rename(&temp_path, &path).await?;
|
||||
Ok((path, header.mime_type))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serving side: answer audio requests from other peers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Finds the local track whose derived DHT item id matches `item_id`.
|
||||
pub fn resolve_local_track_id(
|
||||
library: &Library,
|
||||
own: EndpointId,
|
||||
item_id: ItemId,
|
||||
) -> Result<Option<i64>> {
|
||||
let export = library.federation_export()?;
|
||||
for track in export.tracks {
|
||||
let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{}", track.id));
|
||||
if derived == item_id {
|
||||
return Ok(Some(track.id));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn resolve_local_file(
|
||||
library: &Library,
|
||||
own: EndpointId,
|
||||
item_id: ItemId,
|
||||
) -> Result<Option<String>> {
|
||||
let export = library.federation_export()?;
|
||||
for track in export.tracks {
|
||||
let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{}", track.id));
|
||||
if derived == item_id {
|
||||
return Ok(Some(track.file_path));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Runs the accept loop of the audio protocol until the acceptor closes.
|
||||
/// Every track of the local library is downloadable by every peer of the
|
||||
/// network — the libraries of all participants are equal.
|
||||
pub async fn serve_peers(mut acceptor: StreamAcceptor, library: Arc<Library>, own: EndpointId) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let library = Arc::clone(&library);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_one(stream, library, own).await {
|
||||
tracing::warn!(peer = %peer, "audio stream failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointId) -> Result<()> {
|
||||
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
item = %request.item_id,
|
||||
offset = request.offset,
|
||||
"peer requested audio"
|
||||
);
|
||||
|
||||
let resolved = match hex_decode_item_id(&request.item_id) {
|
||||
Some(item_id) => {
|
||||
let library = Arc::clone(&library);
|
||||
match tokio::task::spawn_blocking(move || resolve_local_file(&library, own, item_id))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(path))) => Ok(path),
|
||||
Ok(Ok(None)) => Err("track not found in the library".to_string()),
|
||||
Ok(Err(err)) => Err(format!("library lookup failed: {err:#}")),
|
||||
Err(err) => Err(format!("lookup task failed: {err}")),
|
||||
}
|
||||
}
|
||||
None => Err("malformed item_id".to_string()),
|
||||
};
|
||||
let file_path = match resolved {
|
||||
Ok(path) => path,
|
||||
Err(message) => return refuse(stream, message).await,
|
||||
};
|
||||
|
||||
let path = PathBuf::from(&file_path);
|
||||
let mut file = match tokio::fs::File::open(&path).await {
|
||||
Ok(file) => file,
|
||||
Err(err) => return refuse(stream, format!("audio file is not readable: {err}")).await,
|
||||
};
|
||||
let total_size = file.metadata().await?.len();
|
||||
let offset = request.offset.min(total_size);
|
||||
if offset > 0 {
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||
}
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: true,
|
||||
error: None,
|
||||
mime_type: guess_mime(&path).to_string(),
|
||||
total_size,
|
||||
offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tokio::io::copy(&mut file, &mut stream.send).await?;
|
||||
stream.send.finish()?;
|
||||
// Wait until the peer read everything (or gave up) before dropping the
|
||||
// stream, otherwise the tail of the file is lost.
|
||||
let _ = stream.send.stopped().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a refusal header and waits until the peer read it.
|
||||
async fn refuse(mut stream: ByteStream, message: String) -> Result<()> {
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: false,
|
||||
error: Some(message.clone()),
|
||||
mime_type: String::new(),
|
||||
total_size: 0,
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
anyhow::bail!("refused audio request: {message}");
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
//! P2P federation for the TUI player.
|
||||
//!
|
||||
//! The player stays fully local, but can join a federated network of
|
||||
//! furumi instances (TUI or furumi-fd): it publishes its library index
|
||||
//! (names and small metadata, never files) into a shared DHT, searches the
|
||||
//! other participants' libraries and streams their audio over the same
|
||||
//! `furumi-fd/audio/1` protocol furumi-fd speaks — the two are wire
|
||||
//! compatible.
|
||||
//!
|
||||
//! Federated tracks are downloaded before playback: into a cache file, or —
|
||||
//! with "save on listen" enabled — straight into the local library (the
|
||||
//! file is imported like any local file, so this peer then serves it to
|
||||
//! the network too).
|
||||
|
||||
mod audio;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::{
|
||||
EndpointId, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket,
|
||||
RendezvousConfig,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::library::Library;
|
||||
use crate::library::models::{ArtistRef, TrackItem};
|
||||
|
||||
pub use audio::AUDIO_ALPN;
|
||||
|
||||
/// How often the published library is re-synchronized with the local index.
|
||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Ephemeral (not-in-library) tracks get negative ids so the rest of the
|
||||
/// app can tell them apart from library rows (history, likes and release
|
||||
/// navigation skip them).
|
||||
static NEXT_EPHEMERAL_ID: AtomicI64 = AtomicI64::new(-1);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings (persisted in <config dir>/federation.toml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct FedSettings {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub network_id: String,
|
||||
/// Downloaded-for-playback federated tracks are imported into the local
|
||||
/// library (replication) instead of a throwaway cache.
|
||||
#[serde(default)]
|
||||
pub save_on_listen: bool,
|
||||
}
|
||||
|
||||
fn settings_path() -> Option<PathBuf> {
|
||||
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("federation.toml"))
|
||||
}
|
||||
|
||||
pub fn load_settings() -> FedSettings {
|
||||
let Some(path) = settings_path() else {
|
||||
return FedSettings::default();
|
||||
};
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => toml::from_str(&text).unwrap_or_else(|err| {
|
||||
tracing::warn!(%err, "federation.toml is malformed; using defaults");
|
||||
FedSettings::default()
|
||||
}),
|
||||
Err(_) => FedSettings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_settings(settings: &FedSettings) -> Result<()> {
|
||||
let path = settings_path().context("cannot determine the config directory")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, toml::to_string_pretty(settings)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data shapes for the UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A track found through federated search.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FedTrack {
|
||||
/// Hex item id in the DHT (the key audio is requested by).
|
||||
pub item_id: String,
|
||||
/// Hex endpoint id of the owning peer.
|
||||
pub owner: String,
|
||||
/// The item is published by this very instance.
|
||||
pub own: bool,
|
||||
pub title: String,
|
||||
pub artist_names: Vec<String>,
|
||||
pub year: Option<i32>,
|
||||
pub duration_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
impl FedTrack {
|
||||
pub fn artist_line(&self) -> String {
|
||||
self.artist_names.join(", ")
|
||||
}
|
||||
|
||||
pub fn owner_short(&self) -> String {
|
||||
self.owner.chars().take(10).collect()
|
||||
}
|
||||
|
||||
pub fn duration_label(&self) -> String {
|
||||
match self.duration_seconds {
|
||||
Some(total) => format!("{}:{:02}", total / 60, total % 60),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live status snapshot rendered on the Federation tab.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FedStatus {
|
||||
pub running: bool,
|
||||
pub network: String,
|
||||
pub endpoint_id: String,
|
||||
pub connected_peers: Vec<String>,
|
||||
pub known_contacts: usize,
|
||||
pub published_items: usize,
|
||||
pub last_sync: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Outcome of preparing a federated track for playback.
|
||||
#[derive(Debug)]
|
||||
pub struct FedPlayable {
|
||||
pub track: TrackItem,
|
||||
/// The file was imported into the local library (save-on-listen).
|
||||
pub imported: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The federation manager (lives in Runtime, not AppState)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Running {
|
||||
service: Arc<MusicDhtService>,
|
||||
network_name: String,
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub struct Federation {
|
||||
library: Arc<Library>,
|
||||
data_dir: PathBuf,
|
||||
cache_dir: PathBuf,
|
||||
media_dir: PathBuf,
|
||||
settings: std::sync::Mutex<FedSettings>,
|
||||
running: tokio::sync::Mutex<Option<Running>>,
|
||||
last_sync: std::sync::Mutex<Option<String>>,
|
||||
last_error: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn now_label() -> String {
|
||||
// Seconds since start of the day are enough for a status line without
|
||||
// pulling in a date-time crate.
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"{:02}:{:02}:{:02} UTC",
|
||||
secs / 3600 % 24,
|
||||
secs / 60 % 60,
|
||||
secs % 60
|
||||
)
|
||||
}
|
||||
|
||||
impl Federation {
|
||||
pub fn new(library: Arc<Library>) -> Arc<Self> {
|
||||
let dirs = crate::config::project_dirs();
|
||||
let data_dir = dirs
|
||||
.as_ref()
|
||||
.map(|d| d.data_dir().join("federation"))
|
||||
.unwrap_or_else(|| PathBuf::from("federation"));
|
||||
let cache_dir = dirs
|
||||
.as_ref()
|
||||
.map(|d| d.cache_dir().join("fedcache"))
|
||||
.unwrap_or_else(|| PathBuf::from("fedcache"));
|
||||
let media_dir = dirs
|
||||
.as_ref()
|
||||
.map(|d| d.data_dir().join("federation-media"))
|
||||
.unwrap_or_else(|| PathBuf::from("federation-media"));
|
||||
Arc::new(Self {
|
||||
library,
|
||||
data_dir,
|
||||
cache_dir,
|
||||
media_dir,
|
||||
settings: std::sync::Mutex::new(load_settings()),
|
||||
running: tokio::sync::Mutex::new(None),
|
||||
last_sync: std::sync::Mutex::new(None),
|
||||
last_error: std::sync::Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> FedSettings {
|
||||
lock(&self.settings).clone()
|
||||
}
|
||||
|
||||
fn set_error(&self, message: Option<String>) {
|
||||
*lock(&self.last_error) = message;
|
||||
}
|
||||
|
||||
/// Persists new settings and starts/stops/restarts the node to match.
|
||||
pub async fn apply_settings(self: &Arc<Self>, settings: FedSettings) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!settings.enabled || !settings.network_id.trim().is_empty(),
|
||||
"set a network id before enabling federation"
|
||||
);
|
||||
if let Err(err) = save_settings(&settings) {
|
||||
tracing::warn!(%err, "saving federation settings failed");
|
||||
}
|
||||
*lock(&self.settings) = settings.clone();
|
||||
if settings.enabled {
|
||||
self.start(settings.network_id.trim().to_string()).await?;
|
||||
self.spawn_sync_soon().await;
|
||||
} else {
|
||||
self.stop().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_if_enabled(self: &Arc<Self>) {
|
||||
let settings = self.settings();
|
||||
if settings.enabled && !settings.network_id.trim().is_empty() {
|
||||
if let Err(err) = self.start(settings.network_id.trim().to_string()).await {
|
||||
tracing::error!("federation autostart failed: {err:#}");
|
||||
self.set_error(Some(format!("autostart failed: {err}")));
|
||||
} else {
|
||||
self.spawn_sync_soon().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the DHT node. Idempotent per network name.
|
||||
async fn start(self: &Arc<Self>, network_name: String) -> Result<()> {
|
||||
let mut guard = self.running.lock().await;
|
||||
if let Some(running) = guard.as_ref() {
|
||||
if running.network_name == network_name {
|
||||
return Ok(());
|
||||
}
|
||||
stop_running(guard.take()).await;
|
||||
}
|
||||
|
||||
let config = MusicDhtConfig::builder()
|
||||
.data_dir(&self.data_dir)
|
||||
.network_id(NetworkId::from_name(&network_name))
|
||||
// Peers of the network find each other knowing only its name.
|
||||
.rendezvous(RendezvousConfig::default())
|
||||
// Peers stream each other's audio over this protocol.
|
||||
.stream_protocol(AUDIO_ALPN)
|
||||
.build()
|
||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||
let (service, mut events) = MusicDhtService::start(config)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to start the DHT node: {err}"))?;
|
||||
let service = Arc::new(service);
|
||||
tracing::info!(
|
||||
endpoint_id = %service.endpoint_id(),
|
||||
network = %network_name,
|
||||
"federation started"
|
||||
);
|
||||
|
||||
// Drain DHT events into the log; the channel is bounded.
|
||||
let event_task = tokio::spawn(async move {
|
||||
while let Some(event) = events.recv().await {
|
||||
tracing::debug!("federation event: {event:?}");
|
||||
}
|
||||
});
|
||||
// Keep the published library in sync with the local index.
|
||||
let sync_self = Arc::clone(self);
|
||||
let sync_service = Arc::clone(&service);
|
||||
let sync_task = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
sync_self.sync_once(&sync_service).await;
|
||||
}
|
||||
});
|
||||
// Serve audio requests from other peers of the network.
|
||||
let audio_acceptor = service
|
||||
.stream_acceptor(AUDIO_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the audio acceptor: {err}"))?;
|
||||
let audio_task = tokio::spawn(audio::serve_peers(
|
||||
audio_acceptor,
|
||||
Arc::clone(&self.library),
|
||||
service.endpoint_id(),
|
||||
));
|
||||
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
network_name,
|
||||
tasks: vec![event_task, sync_task, audio_task],
|
||||
});
|
||||
self.set_error(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) {
|
||||
let mut guard = self.running.lock().await;
|
||||
stop_running(guard.take()).await;
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
self.stop().await;
|
||||
}
|
||||
|
||||
async fn service(&self) -> Result<Arc<MusicDhtService>> {
|
||||
self.running
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|running| Arc::clone(&running.service))
|
||||
.context("federation is not running")
|
||||
}
|
||||
|
||||
/// Publishes the library immediately (used right after start/settings).
|
||||
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||||
if let Ok(service) = self.service().await {
|
||||
let fed = Arc::clone(self);
|
||||
tokio::spawn(async move { fed.sync_once(&service).await });
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sync_now(self: &Arc<Self>) -> Result<()> {
|
||||
let service = self.service().await?;
|
||||
self.sync_once(&service).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_once(&self, service: &MusicDhtService) {
|
||||
let library = Arc::clone(&self.library);
|
||||
let specs = tokio::task::spawn_blocking(move || collect_specs(&library)).await;
|
||||
let specs = match specs {
|
||||
Ok(Ok(specs)) => specs,
|
||||
Ok(Err(err)) => {
|
||||
tracing::warn!("federation sync: library read failed: {err:#}");
|
||||
self.set_error(Some(format!("library read failed: {err}")));
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("federation sync task failed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match service.sync_library(specs).await {
|
||||
Ok(stats) => {
|
||||
*lock(&self.last_sync) = Some(format!(
|
||||
"{} (+{} ~{} −{}, unchanged {})",
|
||||
now_label(),
|
||||
stats.added,
|
||||
stats.updated,
|
||||
stats.removed,
|
||||
stats.unchanged
|
||||
));
|
||||
self.set_error(None);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("federation sync failed: {err}");
|
||||
self.set_error(Some(format!("sync failed: {err}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> FedStatus {
|
||||
let settings = self.settings();
|
||||
let guard = self.running.lock().await;
|
||||
let mut status = FedStatus {
|
||||
network: settings.network_id,
|
||||
last_sync: lock(&self.last_sync).clone(),
|
||||
last_error: lock(&self.last_error).clone(),
|
||||
..FedStatus::default()
|
||||
};
|
||||
if let Some(running) = guard.as_ref() {
|
||||
let service = &running.service;
|
||||
status.running = true;
|
||||
status.network = running.network_name.clone();
|
||||
status.endpoint_id = service.endpoint_id().to_string();
|
||||
status.connected_peers = service
|
||||
.connected_peers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
status.known_contacts = service.known_peers().len();
|
||||
status.published_items = service
|
||||
.list_local_items()
|
||||
.await
|
||||
.map(|items| items.len())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
/// Searches the federated network for tracks matching `query`.
|
||||
pub async fn search(&self, query: &str) -> Result<Vec<FedTrack>> {
|
||||
let service = self.service().await?;
|
||||
let outcome = service
|
||||
.search_network(query)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||||
let own = service.endpoint_id();
|
||||
Ok(outcome
|
||||
.network_results
|
||||
.iter()
|
||||
.filter(|item| item.kind == ItemKind::Track)
|
||||
.map(|item| FedTrack {
|
||||
item_id: audio::hex_encode(item.id.as_bytes()),
|
||||
owner: item.owner.to_string(),
|
||||
own: item.owner == own,
|
||||
title: item.name.clone(),
|
||||
artist_names: item.artist_names.clone(),
|
||||
year: item.year,
|
||||
duration_seconds: item.duration_seconds.map(|d| d.round() as i64),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn ticket(&self) -> Result<String> {
|
||||
let service = self.service().await?;
|
||||
let ticket = service
|
||||
.ticket()
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("cannot create a ticket: {err}"))?;
|
||||
Ok(ticket.to_string())
|
||||
}
|
||||
|
||||
pub async fn connect(&self, ticket: &str) -> Result<String> {
|
||||
let service = self.service().await?;
|
||||
let ticket: PeerTicket = ticket
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|err| anyhow::anyhow!("malformed ticket: {err}"))?;
|
||||
let peer = service
|
||||
.connect(ticket)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("connect failed: {err}"))?;
|
||||
Ok(peer.to_string())
|
||||
}
|
||||
|
||||
/// Prepares a federated track for playback: local tracks resolve
|
||||
/// straight to the library; remote tracks are downloaded — into the
|
||||
/// library when save-on-listen is enabled, into the cache otherwise.
|
||||
pub async fn prepare_playback(self: &Arc<Self>, fed: &FedTrack) -> Result<FedPlayable> {
|
||||
let service = self.service().await?;
|
||||
let item_id =
|
||||
audio::hex_decode_item_id(&fed.item_id).context("malformed item id in the result")?;
|
||||
|
||||
if fed.own {
|
||||
let library = Arc::clone(&self.library);
|
||||
let own_id = service.endpoint_id();
|
||||
let track = tokio::task::spawn_blocking(move || -> Result<Option<TrackItem>> {
|
||||
let Some(track_id) = audio::resolve_local_track_id(&library, own_id, item_id)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(library.tracks_by_ids(&[track_id])?.into_iter().next())
|
||||
})
|
||||
.await??
|
||||
.context("this track is no longer in the local library")?;
|
||||
return Ok(FedPlayable {
|
||||
track,
|
||||
imported: false,
|
||||
});
|
||||
}
|
||||
|
||||
let owner = EndpointId::from_str(&fed.owner)
|
||||
.map_err(|_| anyhow::anyhow!("malformed owner id '{}'", fed.owner))?;
|
||||
let save = self.settings().save_on_listen;
|
||||
let dir = if save { &self.media_dir } else { &self.cache_dir };
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
|
||||
let (path, mime) =
|
||||
audio::download_track(&service, owner, &fed.item_id, dir, &download_stem(fed)).await?;
|
||||
tracing::info!(path = %path.display(), %mime, "federated track downloaded");
|
||||
|
||||
if save {
|
||||
let library = Arc::clone(&self.library);
|
||||
let import_path = path.clone();
|
||||
let imported = tokio::task::spawn_blocking(move || -> Result<Option<TrackItem>> {
|
||||
let import = crate::library::import::read_file(&import_path)?;
|
||||
let (track_id, _) = crate::library::import::upsert_track(&library, &import)?;
|
||||
Ok(library.tracks_by_ids(&[track_id])?.into_iter().next())
|
||||
})
|
||||
.await?;
|
||||
match imported {
|
||||
Ok(Some(track)) => {
|
||||
return Ok(FedPlayable {
|
||||
track,
|
||||
imported: true,
|
||||
});
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!("importing the downloaded track failed: {err:#}; playing from the file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FedPlayable {
|
||||
track: ephemeral_track(fed, &path),
|
||||
imported: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_running(running: Option<Running>) {
|
||||
let Some(running) = running else { return };
|
||||
for task in &running.tasks {
|
||||
task.abort();
|
||||
}
|
||||
if let Err(err) = running.service.shutdown().await {
|
||||
tracing::warn!("federation node shutdown reported an error: {err}");
|
||||
}
|
||||
tracing::info!("federation stopped");
|
||||
}
|
||||
|
||||
/// Reads the local library and converts it into DHT item specs. Only names
|
||||
/// and small metadata are shared — never file paths or the files themselves.
|
||||
fn collect_specs(library: &Library) -> Result<Vec<ItemSpec>> {
|
||||
let export = library.federation_export()?;
|
||||
let mut specs = Vec::new();
|
||||
for (id, name) in export.artists {
|
||||
specs.push(ItemSpec {
|
||||
local_key: format!("artist:{id}"),
|
||||
kind: ItemKind::Artist,
|
||||
name,
|
||||
artist_names: Vec::new(),
|
||||
year: None,
|
||||
release_type: None,
|
||||
duration_seconds: None,
|
||||
});
|
||||
}
|
||||
for release in export.releases {
|
||||
specs.push(ItemSpec {
|
||||
local_key: format!("release:{}", release.id),
|
||||
kind: ItemKind::Release,
|
||||
name: release.title,
|
||||
artist_names: release.artist_names,
|
||||
year: release.year,
|
||||
release_type: Some(release.release_type),
|
||||
duration_seconds: None,
|
||||
});
|
||||
}
|
||||
for track in export.tracks {
|
||||
specs.push(ItemSpec {
|
||||
local_key: format!("track:{}", track.id),
|
||||
kind: ItemKind::Track,
|
||||
name: track.title,
|
||||
artist_names: track.artist_names,
|
||||
year: track.year,
|
||||
release_type: None,
|
||||
duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds),
|
||||
});
|
||||
}
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
fn sanitize_file_stem(value: &str) -> String {
|
||||
let cleaned: String = value
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let trimmed = cleaned.trim().trim_matches('.');
|
||||
let mut stem: String = trimmed.chars().take(120).collect();
|
||||
if stem.is_empty() {
|
||||
stem.push_str("track");
|
||||
}
|
||||
stem
|
||||
}
|
||||
|
||||
fn download_stem(fed: &FedTrack) -> String {
|
||||
let artists = fed.artist_line();
|
||||
if artists.is_empty() {
|
||||
sanitize_file_stem(&fed.title)
|
||||
} else {
|
||||
sanitize_file_stem(&format!("{artists} - {}", fed.title))
|
||||
}
|
||||
}
|
||||
|
||||
/// A playable TrackItem for a downloaded-but-not-imported federated track.
|
||||
fn ephemeral_track(fed: &FedTrack, path: &std::path::Path) -> TrackItem {
|
||||
let id = NEXT_EPHEMERAL_ID.fetch_sub(1, Ordering::Relaxed);
|
||||
let file_size = std::fs::metadata(path).map(|m| m.len() as i64).ok();
|
||||
TrackItem {
|
||||
id,
|
||||
title: fed.title.clone(),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: fed.duration_seconds.unwrap_or(0) as f64,
|
||||
artists: fed
|
||||
.artist_names
|
||||
.iter()
|
||||
.map(|name| ArtistRef {
|
||||
id: -1,
|
||||
name: name.clone(),
|
||||
})
|
||||
.collect(),
|
||||
featured_artists: Vec::new(),
|
||||
release_id: -1,
|
||||
release_title: format!("federation · {}", fed.owner_short()),
|
||||
release_year: fed.year,
|
||||
file_path: path.to_string_lossy().into_owned(),
|
||||
cover_path: None,
|
||||
audio_format: path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_string()),
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: file_size,
|
||||
play_count: 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
//! Importing audio files into the library: directory scanning, tag reading
|
||||
//! (via lofty) and cover extraction. Importing the same file again updates
|
||||
//! its metadata instead of duplicating it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use lofty::file::{AudioFile as _, TaggedFileExt as _};
|
||||
use lofty::picture::MimeType;
|
||||
use lofty::tag::{Accessor as _, ItemKey};
|
||||
use rusqlite::{OptionalExtension as _, params};
|
||||
|
||||
use super::{Library, find_or_create_artist};
|
||||
|
||||
/// Extensions the playback engine can decode (rodio/symphonia feature set).
|
||||
const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"];
|
||||
|
||||
/// Everything known about one audio file, ready to be written to the DB.
|
||||
#[derive(Debug)]
|
||||
pub struct TrackImport {
|
||||
pub file_path: String,
|
||||
pub title: String,
|
||||
pub artists: Vec<String>,
|
||||
pub featured_artists: Vec<String>,
|
||||
pub album_artists: Vec<String>,
|
||||
pub release_title: String,
|
||||
pub year: Option<i32>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
pub duration_seconds: f64,
|
||||
pub audio_format: Option<String>,
|
||||
pub audio_bitrate: Option<i32>,
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
pub file_size_bytes: Option<i64>,
|
||||
/// Embedded cover art (bytes, file extension), if any.
|
||||
pub cover: Option<(Vec<u8>, &'static str)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ImportOutcome {
|
||||
pub added: usize,
|
||||
pub updated: usize,
|
||||
pub failed: Vec<(PathBuf, String)>,
|
||||
}
|
||||
|
||||
impl ImportOutcome {
|
||||
pub fn summary(&self) -> String {
|
||||
let mut message = format!("imported {} track(s)", self.added);
|
||||
if self.updated > 0 {
|
||||
message.push_str(&format!(", updated {}", self.updated));
|
||||
}
|
||||
if !self.failed.is_empty() {
|
||||
message.push_str(&format!(", {} failed", self.failed.len()));
|
||||
}
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a file or a directory (recursively). `progress(done, total, name)`
|
||||
/// is called after every file.
|
||||
pub fn import_path(
|
||||
library: &Library,
|
||||
path: &Path,
|
||||
mut progress: impl FnMut(usize, usize, &str),
|
||||
) -> Result<ImportOutcome> {
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.with_context(|| format!("{} does not exist", path.display()))?;
|
||||
let mut files = Vec::new();
|
||||
collect_audio_files(&path, &mut files);
|
||||
anyhow::ensure!(
|
||||
!files.is_empty(),
|
||||
"no audio files found at {} (supported: {})",
|
||||
path.display(),
|
||||
AUDIO_EXTENSIONS.join(", ")
|
||||
);
|
||||
files.sort();
|
||||
|
||||
let total = files.len();
|
||||
let mut outcome = ImportOutcome::default();
|
||||
for (index, file) in files.iter().enumerate() {
|
||||
match read_file(file).and_then(|import| upsert_track(library, &import)) {
|
||||
Ok((_, created)) => {
|
||||
if created {
|
||||
outcome.added += 1;
|
||||
} else {
|
||||
outcome.updated += 1;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(file = %file.display(), %err, "import failed");
|
||||
outcome.failed.push((file.clone(), format!("{err:#}")));
|
||||
}
|
||||
}
|
||||
let name = file
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
progress(index + 1, total, &name);
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn collect_audio_files(path: &Path, files: &mut Vec<PathBuf>) {
|
||||
if path.is_dir() {
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
collect_audio_files(&entry.path(), files);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase());
|
||||
if extension.is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str())) {
|
||||
files.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// Read tags and audio properties from one file.
|
||||
pub fn read_file(path: &Path) -> Result<TrackImport> {
|
||||
let tagged = lofty::read_from_path(path).context("cannot read tags")?;
|
||||
let properties = tagged.properties();
|
||||
let tag = tagged.primary_tag().or_else(|| tagged.first_tag());
|
||||
|
||||
let fallback_title = path
|
||||
.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let (mut title, artist_raw, album, year, track_number, disc_number, album_artist_raw, cover) =
|
||||
match tag {
|
||||
Some(tag) => (
|
||||
tag.title()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback_title),
|
||||
tag.artist().map(|value| value.into_owned()),
|
||||
tag.album()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
tag.year().and_then(|value| i32::try_from(value).ok()),
|
||||
tag.track().and_then(|value| i32::try_from(value).ok()),
|
||||
tag.disk().and_then(|value| i32::try_from(value).ok()),
|
||||
tag.get_string(&ItemKey::AlbumArtist)
|
||||
.map(|value| value.to_string()),
|
||||
tag.pictures().first().map(|picture| {
|
||||
let extension = match picture.mime_type() {
|
||||
Some(MimeType::Png) => "png",
|
||||
Some(MimeType::Gif) => "gif",
|
||||
Some(MimeType::Bmp) => "bmp",
|
||||
_ => "jpg",
|
||||
};
|
||||
(picture.data().to_vec(), extension)
|
||||
}),
|
||||
),
|
||||
None => (fallback_title, None, None, None, None, None, None, None),
|
||||
};
|
||||
|
||||
let (mut artists, mut featured) = split_artist_tag(artist_raw.as_deref().unwrap_or(""));
|
||||
// "Song (feat. X)" in the title moves X into the featured list.
|
||||
if let Some((clean_title, feat)) = extract_title_feat(&title) {
|
||||
title = clean_title;
|
||||
for name in feat {
|
||||
if !featured.iter().any(|f| f.eq_ignore_ascii_case(&name)) {
|
||||
featured.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if artists.is_empty() {
|
||||
artists.push("Unknown Artist".to_string());
|
||||
}
|
||||
let album_artists = match album_artist_raw.as_deref().map(split_artist_tag) {
|
||||
Some((main, _)) if !main.is_empty() => main,
|
||||
_ => artists.clone(),
|
||||
};
|
||||
|
||||
let metadata = std::fs::metadata(path).ok();
|
||||
Ok(TrackImport {
|
||||
file_path: path.to_string_lossy().into_owned(),
|
||||
title,
|
||||
artists,
|
||||
featured_artists: featured,
|
||||
album_artists,
|
||||
release_title: album.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
year,
|
||||
track_number,
|
||||
disc_number,
|
||||
duration_seconds: properties.duration().as_secs_f64(),
|
||||
audio_format: path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase()),
|
||||
audio_bitrate: properties
|
||||
.audio_bitrate()
|
||||
.and_then(|value| i32::try_from(value).ok()),
|
||||
audio_sample_rate: properties
|
||||
.sample_rate()
|
||||
.and_then(|value| i32::try_from(value).ok()),
|
||||
audio_bit_depth: properties.bit_depth().map(i32::from),
|
||||
file_size_bytes: metadata.map(|meta| meta.len() as i64),
|
||||
cover,
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert or update one track (matching by file path). Returns the track id
|
||||
/// and whether a new row was created.
|
||||
pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, bool)> {
|
||||
let mut conn = library.lock();
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
// Release, keyed by (title, first album artist).
|
||||
let album_artist_id = find_or_create_artist(
|
||||
&tx,
|
||||
import
|
||||
.album_artists
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or("Unknown Artist"),
|
||||
)?;
|
||||
let release_id: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT r.id FROM releases r
|
||||
JOIN release_artists ra ON ra.release_id = r.id
|
||||
WHERE r.title = ?1 COLLATE NOCASE AND ra.artist_id = ?2",
|
||||
params![import.release_title, album_artist_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let release_id = match release_id {
|
||||
Some(id) => {
|
||||
// Fill in the year if this file is the first one to know it.
|
||||
if import.year.is_some() {
|
||||
tx.execute(
|
||||
"UPDATE releases SET year = COALESCE(year, ?2) WHERE id = ?1",
|
||||
params![id, import.year],
|
||||
)?;
|
||||
}
|
||||
id
|
||||
}
|
||||
None => {
|
||||
tx.execute(
|
||||
"INSERT INTO releases (title, release_type, year) VALUES (?1, 'album', ?2)",
|
||||
params![import.release_title, import.year],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
for (position, name) in import.album_artists.iter().enumerate() {
|
||||
let artist_id = find_or_create_artist(&tx, name)?;
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO release_artists (release_id, artist_id, position)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![id, artist_id, position as i64],
|
||||
)?;
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
let existing: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT id FROM tracks WHERE file_path = ?1",
|
||||
[&import.file_path],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let (track_id, created) = match existing {
|
||||
Some(id) => {
|
||||
tx.execute(
|
||||
"UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4,
|
||||
duration_seconds = ?5, release_id = ?6, audio_format = ?7,
|
||||
audio_bitrate = ?8, audio_sample_rate = ?9, audio_bit_depth = ?10,
|
||||
file_size_bytes = ?11
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
id,
|
||||
import.title,
|
||||
import.track_number,
|
||||
import.disc_number,
|
||||
import.duration_seconds,
|
||||
release_id,
|
||||
import.audio_format,
|
||||
import.audio_bitrate,
|
||||
import.audio_sample_rate,
|
||||
import.audio_bit_depth,
|
||||
import.file_size_bytes,
|
||||
],
|
||||
)?;
|
||||
tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?;
|
||||
(id, false)
|
||||
}
|
||||
None => {
|
||||
tx.execute(
|
||||
"INSERT INTO tracks (title, track_number, disc_number, duration_seconds,
|
||||
release_id, file_path, audio_format, audio_bitrate, audio_sample_rate,
|
||||
audio_bit_depth, file_size_bytes)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
import.title,
|
||||
import.track_number,
|
||||
import.disc_number,
|
||||
import.duration_seconds,
|
||||
release_id,
|
||||
import.file_path,
|
||||
import.audio_format,
|
||||
import.audio_bitrate,
|
||||
import.audio_sample_rate,
|
||||
import.audio_bit_depth,
|
||||
import.file_size_bytes,
|
||||
],
|
||||
)?;
|
||||
(tx.last_insert_rowid(), true)
|
||||
}
|
||||
};
|
||||
for (position, name) in import.artists.iter().enumerate() {
|
||||
let artist_id = find_or_create_artist(&tx, name)?;
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position)
|
||||
VALUES (?1, ?2, 'main', ?3)",
|
||||
params![track_id, artist_id, position as i64],
|
||||
)?;
|
||||
}
|
||||
for (position, name) in import.featured_artists.iter().enumerate() {
|
||||
let artist_id = find_or_create_artist(&tx, name)?;
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position)
|
||||
VALUES (?1, ?2, 'featured', ?3)",
|
||||
params![track_id, artist_id, position as i64],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Cover: a release keeps the first cover found — an image file next to
|
||||
// the audio, or the embedded picture saved into the covers directory.
|
||||
let has_cover: bool = tx
|
||||
.query_row(
|
||||
"SELECT cover_path IS NOT NULL FROM releases WHERE id = ?1",
|
||||
[release_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if !has_cover
|
||||
&& let Some(cover_path) = resolve_cover(library, release_id, import)
|
||||
{
|
||||
tx.execute(
|
||||
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
|
||||
params![release_id, cover_path],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok((track_id, created))
|
||||
}
|
||||
|
||||
/// Find a cover image for the release: a cover/folder/front image in the
|
||||
/// audio file's directory, or the embedded picture written to disk.
|
||||
fn resolve_cover(library: &Library, release_id: i64, import: &TrackImport) -> Option<String> {
|
||||
let directory = Path::new(&import.file_path).parent()?;
|
||||
if let Ok(entries) = std::fs::read_dir(directory) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(|stem| stem.to_ascii_lowercase());
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase());
|
||||
let is_image = matches!(
|
||||
extension.as_deref(),
|
||||
Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif")
|
||||
);
|
||||
if is_image
|
||||
&& matches!(stem.as_deref(), Some("cover" | "folder" | "front" | "album"))
|
||||
{
|
||||
return Some(path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let (data, extension) = import.cover.as_ref()?;
|
||||
let covers_dir = library.covers_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(covers_dir) {
|
||||
tracing::warn!(%err, "cannot create covers directory");
|
||||
return None;
|
||||
}
|
||||
let path = covers_dir.join(format!("release_{release_id}.{extension}"));
|
||||
match std::fs::write(&path, data) {
|
||||
Ok(()) => Some(path.to_string_lossy().into_owned()),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, path = %path.display(), "cannot save embedded cover");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split an artist tag into (main artists, featured artists).
|
||||
/// Separators: ";" and "/" between main artists; "feat."/"ft."/"featuring"
|
||||
/// starts the featured list.
|
||||
pub fn split_artist_tag(raw: &str) -> (Vec<String>, Vec<String>) {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
let (main_part, feat_part) = match find_feat_marker(raw) {
|
||||
Some((at, marker_len)) => {
|
||||
let main = raw[..at].trim_end_matches(['(', '[', ' ', ',', '-']);
|
||||
let feat = raw[at + marker_len..].trim_end_matches([')', ']']);
|
||||
(main, feat)
|
||||
}
|
||||
None => (raw, ""),
|
||||
};
|
||||
(split_names(main_part), split_names(feat_part))
|
||||
}
|
||||
|
||||
/// The earliest "feat."/"ft."/"featuring" marker that stands as its own
|
||||
/// word — preceded by a separator and followed by a space — so artist names
|
||||
/// like "Daft Punk" are not split on the "ft" inside them.
|
||||
fn find_feat_marker(raw: &str) -> Option<(usize, usize)> {
|
||||
let lowered = raw.to_lowercase();
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for marker in ["featuring", "feat.", "feat", "ft.", "ft"] {
|
||||
for (at, _) in lowered.match_indices(marker) {
|
||||
let before_ok = raw[..at]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|c| matches!(c, ' ' | '(' | '[' | ',' | '-'));
|
||||
let after_ok = raw[at + marker.len()..].starts_with(' ');
|
||||
if before_ok && after_ok && best.is_none_or(|(current, _)| at < current) {
|
||||
best = Some((at, marker.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn split_names(raw: &str) -> Vec<String> {
|
||||
raw.split([';', '/'])
|
||||
.flat_map(|part| part.split(" & "))
|
||||
.map(|name| name.trim().trim_matches(',').trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract "(feat. X)" / "[ft. Y]" from a track title.
|
||||
fn extract_title_feat(title: &str) -> Option<(String, Vec<String>)> {
|
||||
let lowered = title.to_lowercase();
|
||||
for marker in ["(feat.", "(feat ", "(ft.", "[feat.", "[ft."] {
|
||||
if let Some(start) = lowered.find(marker) {
|
||||
let closer = if marker.starts_with('(') { ')' } else { ']' };
|
||||
let rest = &title[start + marker.len()..];
|
||||
let end = rest.find(closer)?;
|
||||
let names = split_names(&rest[..end]);
|
||||
if names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut clean = title[..start].trim_end().to_string();
|
||||
clean.push_str(rest[end + 1..].trim_end());
|
||||
return Some((clean.trim().to_string(), names));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal valid WAV file: 0.1s of silence at 8kHz mono 16-bit.
|
||||
fn write_test_wav(path: &Path) {
|
||||
let samples: u32 = 800;
|
||||
let data_len = samples * 2;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"RIFF");
|
||||
bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
bytes.extend_from_slice(b"WAVEfmt ");
|
||||
bytes.extend_from_slice(&16u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes()); // mono
|
||||
bytes.extend_from_slice(&8000u32.to_le_bytes()); // sample rate
|
||||
bytes.extend_from_slice(&16000u32.to_le_bytes()); // byte rate
|
||||
bytes.extend_from_slice(&2u16.to_le_bytes()); // block align
|
||||
bytes.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
|
||||
bytes.extend_from_slice(b"data");
|
||||
bytes.extend_from_slice(&data_len.to_le_bytes());
|
||||
bytes.resize(bytes.len() + data_len as usize, 0);
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_a_real_audio_file_end_to_end() {
|
||||
let dir = std::env::temp_dir().join(format!("furumi-import-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let wav = dir.join("My Song.wav");
|
||||
write_test_wav(&wav);
|
||||
|
||||
let db = dir.join("library.db");
|
||||
let library = Library::open(&db).unwrap();
|
||||
let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap();
|
||||
assert_eq!(outcome.added, 1);
|
||||
assert!(outcome.failed.is_empty());
|
||||
|
||||
// Untagged files fall back to the file name and placeholder names.
|
||||
let results = library.search("My Song", 10).unwrap();
|
||||
assert_eq!(results.tracks.len(), 1);
|
||||
let track = &results.tracks[0];
|
||||
assert_eq!(track.title, "My Song");
|
||||
assert_eq!(track.artists[0].name, "Unknown Artist");
|
||||
assert_eq!(track.release_title, "Unknown Album");
|
||||
assert!(track.duration_seconds > 0.05);
|
||||
assert_eq!(track.audio_sample_rate, Some(8000));
|
||||
assert!(std::fs::File::open(&track.file_path).is_ok());
|
||||
|
||||
// Re-importing the same directory only updates.
|
||||
let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap();
|
||||
assert_eq!((outcome.added, outcome.updated), (0, 1));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_plain_artist() {
|
||||
let (main, feat) = split_artist_tag("Daft Punk");
|
||||
assert_eq!(main, vec!["Daft Punk"]);
|
||||
assert!(feat.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_multiple_and_featured() {
|
||||
let (main, feat) = split_artist_tag("A; B feat. C & D");
|
||||
assert_eq!(main, vec!["A", "B"]);
|
||||
assert_eq!(feat, vec!["C", "D"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_commas_inside_names() {
|
||||
let (main, _) = split_artist_tag("Tyler, The Creator");
|
||||
assert_eq!(main, vec!["Tyler, The Creator"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_feat_from_title() {
|
||||
let (title, names) = extract_title_feat("Song (feat. X & Y)").unwrap();
|
||||
assert_eq!(title, "Song");
|
||||
assert_eq!(names, vec!["X", "Y"]);
|
||||
assert!(extract_title_feat("Plain Song").is_none());
|
||||
}
|
||||
}
|
||||
+1018
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
//! Data shapes the views render. They mirror what the furumusic API used to
|
||||
//! return, but every field is now filled from the local SQLite library.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArtistCard {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Path to a local image file, if one is set for the artist.
|
||||
pub image_path: Option<String>,
|
||||
pub release_count: i64,
|
||||
pub track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArtistRef {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackItem {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
pub duration_seconds: f64,
|
||||
pub artists: Vec<ArtistRef>,
|
||||
pub featured_artists: Vec<ArtistRef>,
|
||||
pub release_id: i64,
|
||||
pub release_title: String,
|
||||
pub release_year: Option<i32>,
|
||||
/// Absolute path to the local audio file.
|
||||
pub file_path: String,
|
||||
/// Path to a local cover image (the release cover).
|
||||
pub cover_path: Option<String>,
|
||||
pub audio_format: Option<String>,
|
||||
pub audio_bitrate: Option<i32>,
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
pub file_size_bytes: Option<i64>,
|
||||
/// Completed local plays, from the history table.
|
||||
pub play_count: 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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct ReleaseCard {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub cover_path: Option<String>,
|
||||
pub track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ArtistDetail {
|
||||
#[allow(dead_code, reason = "cache key is held by the caller")]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub image_path: Option<String>,
|
||||
pub total_track_count: i64,
|
||||
pub total_play_count: i64,
|
||||
pub top_tracks: Vec<TrackItem>,
|
||||
pub releases: Vec<ReleaseCard>,
|
||||
/// Tracks where this artist is featured (the only content for artists
|
||||
/// without own releases).
|
||||
pub featured_tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
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_path: Option<String>,
|
||||
pub artists: Vec<ArtistRef>,
|
||||
pub tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlaylistCard {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub track_count: i64,
|
||||
/// "normal" for user playlists, "likes" for the virtual Likes playlist.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
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, Default)]
|
||||
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)]
|
||||
pub struct ArtistsPage {
|
||||
pub items: Vec<ArtistCard>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Edited values submitted from the track edit form. `None` numbers clear
|
||||
/// the column.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackEdit {
|
||||
pub title: String,
|
||||
pub artists: Vec<String>,
|
||||
pub featured_artists: Vec<String>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReleaseEdit {
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub artists: Vec<String>,
|
||||
}
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
mod api;
|
||||
mod app;
|
||||
mod art;
|
||||
mod config;
|
||||
mod federation;
|
||||
mod library;
|
||||
mod media;
|
||||
mod player;
|
||||
mod ui;
|
||||
|
||||
+4
-6
@@ -10,10 +10,9 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
|
||||
use stream_download::StreamDownload;
|
||||
use stream_download::storage::temp::TempStorageProvider;
|
||||
|
||||
pub type TrackReader = StreamDownload<TempStorageProvider>;
|
||||
/// Local audio files are read straight from disk.
|
||||
pub type TrackReader = std::io::BufReader<std::fs::File>;
|
||||
|
||||
/// Perceptual volume: cubic mapping from percent to linear amplitude, so
|
||||
/// equal percent steps sound like equal loudness steps and low percentages
|
||||
@@ -258,11 +257,10 @@ fn handle(
|
||||
*track_loaded = false;
|
||||
}
|
||||
Command::Seek(position) => {
|
||||
if let Some(out) = output {
|
||||
if let Err(err) = out.player.try_seek(position) {
|
||||
if let Some(out) = output
|
||||
&& let Err(err) = out.player.try_seek(position) {
|
||||
tracing::warn!(%err, "seek failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::SetVolume(volume) => {
|
||||
if let Some(out) = output {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! The Federation tab: settings rows on top, a live status block below.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
|
||||
use super::theme;
|
||||
use crate::app::state::{AppState, FedRow};
|
||||
|
||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let block = Block::bordered()
|
||||
.title(" Federation ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::dim());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let rows_height = FedRow::ALL.len() as u16;
|
||||
let [rows_area, _, status_area] = Layout::vertical([
|
||||
Constraint::Length(rows_height),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
let settings = &state.federation.settings;
|
||||
let on_off = |on: bool| if on { "on" } else { "off" };
|
||||
for (index, row) in FedRow::ALL.iter().enumerate() {
|
||||
let (label, value) = match row {
|
||||
FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()),
|
||||
FedRow::NetworkId => (
|
||||
"Network ID (shared secret)",
|
||||
if settings.network_id.is_empty() {
|
||||
"(not set — press enter)".to_string()
|
||||
} else {
|
||||
settings.network_id.clone()
|
||||
},
|
||||
),
|
||||
FedRow::SaveOnListen => (
|
||||
"Save federated tracks to the library on listen",
|
||||
on_off(settings.save_on_listen).to_string(),
|
||||
),
|
||||
FedRow::SyncNow => ("Publish the library now", "↵".to_string()),
|
||||
FedRow::ShowTicket => ("Show my connection ticket", "↵".to_string()),
|
||||
FedRow::Connect => ("Connect to a peer by ticket…", "↵".to_string()),
|
||||
};
|
||||
let selected = index == state.federation.cursor;
|
||||
let rect = Rect {
|
||||
x: rows_area.x,
|
||||
y: rows_area.y + index as u16,
|
||||
width: rows_area.width,
|
||||
height: 1,
|
||||
};
|
||||
if rect.y >= rows_area.y + rows_area.height {
|
||||
break;
|
||||
}
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
let label_width = 48usize;
|
||||
let line = Line::from(vec![
|
||||
Span::styled(marker, theme::accent()),
|
||||
Span::styled(format!("{label:<label_width$}"), if selected {
|
||||
theme::accent()
|
||||
} else {
|
||||
ratatui::style::Style::default()
|
||||
}),
|
||||
Span::styled(value, theme::dim()),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), rect);
|
||||
}
|
||||
|
||||
draw_status(frame, status_area, state);
|
||||
}
|
||||
|
||||
fn status_line(label: &str, value: String) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{label:<22}"), theme::dim()),
|
||||
Span::raw(value),
|
||||
])
|
||||
}
|
||||
|
||||
fn short_id(id: &str) -> String {
|
||||
id.chars().take(12).collect::<String>() + "…"
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let mut lines: Vec<Line> = vec![Line::styled("Status", theme::header())];
|
||||
match &state.federation.status {
|
||||
None => lines.push(Line::styled("loading…", theme::dim())),
|
||||
Some(status) if !status.running => {
|
||||
lines.push(status_line("Node", "stopped".to_string()));
|
||||
if let Some(error) = &status.last_error {
|
||||
lines.push(status_line("Error", error.clone()));
|
||||
}
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::styled(
|
||||
"Enable federation and set a network id — every instance using the",
|
||||
theme::dim(),
|
||||
));
|
||||
lines.push(Line::styled(
|
||||
"same id (furumi TUI or furumi-fd) finds the others automatically.",
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
Some(status) => {
|
||||
lines.push(status_line("Node", "running".to_string()));
|
||||
lines.push(status_line("Network", status.network.clone()));
|
||||
lines.push(status_line("Endpoint ID", status.endpoint_id.clone()));
|
||||
let peers = if status.connected_peers.is_empty() {
|
||||
"none yet".to_string()
|
||||
} else {
|
||||
let names: Vec<String> =
|
||||
status.connected_peers.iter().map(|p| short_id(p)).collect();
|
||||
format!("{} — {}", status.connected_peers.len(), names.join(", "))
|
||||
};
|
||||
lines.push(status_line("Connected peers", peers));
|
||||
lines.push(status_line(
|
||||
"Known contacts",
|
||||
status.known_contacts.to_string(),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Published items",
|
||||
status.published_items.to_string(),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Last sync",
|
||||
status
|
||||
.last_sync
|
||||
.clone()
|
||||
.unwrap_or_else(|| "not yet".to_string()),
|
||||
));
|
||||
if let Some(error) = &status.last_error {
|
||||
lines.push(status_line("Error", error.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
frame.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
+58
-21
@@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Row, Table};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use super::{art, theme};
|
||||
use crate::api::models::{ArtistCard, ReleaseCard};
|
||||
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
|
||||
use crate::app::state::{
|
||||
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
|
||||
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups,
|
||||
@@ -315,7 +315,7 @@ fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) {
|
||||
draw_tile(
|
||||
frame,
|
||||
tile,
|
||||
tile_art(state, artist.image_url.as_ref()),
|
||||
tile_art(state, artist.image_path.as_ref()),
|
||||
&artist.name,
|
||||
&artist_tile_meta(artist),
|
||||
index == global.selected,
|
||||
@@ -395,7 +395,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
height: ART_HEADER_HEIGHT.min(art_area.height),
|
||||
..art_area
|
||||
},
|
||||
header_art(state, detail.image_url.as_ref()),
|
||||
header_art(state, detail.image_path.as_ref()),
|
||||
);
|
||||
let mut about = format!("{} releases", detail.releases.len());
|
||||
if !detail.featured_tracks.is_empty() {
|
||||
@@ -536,7 +536,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
draw_tile(
|
||||
frame,
|
||||
tile,
|
||||
tile_art(state, release.cover_url.as_ref()),
|
||||
tile_art(state, release.cover_path.as_ref()),
|
||||
&release.title,
|
||||
&release_tile_meta(release),
|
||||
cursor == tracks + position,
|
||||
@@ -648,13 +648,12 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
||||
height: ART_HEADER_HEIGHT.min(art_area.height),
|
||||
..art_area
|
||||
},
|
||||
header_art(state, detail.cover_url.as_ref()),
|
||||
header_art(state, detail.cover_path.as_ref()),
|
||||
);
|
||||
|
||||
let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect();
|
||||
let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default();
|
||||
let uploaders: Vec<&str> = detail.uploaders.iter().map(|u| u.name.as_str()).collect();
|
||||
let mut info = vec![
|
||||
let info = vec![
|
||||
Line::default(),
|
||||
Line::styled(detail.title.clone(), theme::header()),
|
||||
Line::raw(artists.join(", ")),
|
||||
@@ -668,12 +667,6 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
||||
theme::dim(),
|
||||
),
|
||||
];
|
||||
if !uploaders.is_empty() {
|
||||
info.push(Line::styled(
|
||||
format!("uploaded by {}", uploaders.join(", ")),
|
||||
theme::dim(),
|
||||
));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(info), info_area);
|
||||
|
||||
// Track list with centered scrolling.
|
||||
@@ -719,15 +712,20 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
}
|
||||
let inner = bordered(frame, area, title);
|
||||
|
||||
let Some(results) = &search.results else {
|
||||
let hint = if search.query.is_empty() {
|
||||
"type to search artists, releases and tracks"
|
||||
} else {
|
||||
"searching…"
|
||||
};
|
||||
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
|
||||
let empty_results = SearchResults::default();
|
||||
let results = match &search.results {
|
||||
Some(results) => results,
|
||||
None if !state.search.fed_tracks.is_empty() || state.search.fed_loading => &empty_results,
|
||||
None => {
|
||||
let hint = if search.query.is_empty() {
|
||||
"type to search artists, releases and tracks"
|
||||
} else {
|
||||
"searching…"
|
||||
};
|
||||
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
|
||||
}
|
||||
};
|
||||
if results.len() == 0 {
|
||||
if results.len() == 0 && state.search.fed_tracks.is_empty() && !state.search.fed_loading {
|
||||
return centered_line(frame, inner, Line::styled("nothing found", theme::dim()));
|
||||
}
|
||||
|
||||
@@ -784,6 +782,45 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
// Tracks found on the federated network, marked with the owning peer.
|
||||
if !state.search.fed_tracks.is_empty() || state.search.fed_loading {
|
||||
if !rows.is_empty() {
|
||||
rows.push((Line::default(), None, None));
|
||||
}
|
||||
let header = if state.search.fed_loading {
|
||||
"Federation · searching…"
|
||||
} else {
|
||||
"Federation"
|
||||
};
|
||||
rows.push((Line::styled(header, theme::header()), None, None));
|
||||
for fed in &state.search.fed_tracks {
|
||||
let origin = if fed.own {
|
||||
"your library".to_string()
|
||||
} else {
|
||||
format!("peer {}…", fed.owner_short())
|
||||
};
|
||||
let mut meta = fed.duration_label();
|
||||
if let Some(year) = fed.year {
|
||||
if !meta.is_empty() {
|
||||
meta.push_str(" · ");
|
||||
}
|
||||
meta.push_str(&year.to_string());
|
||||
}
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(fed.title.clone()),
|
||||
Span::styled(
|
||||
format!(" {} · {}", fed.artist_line(), origin),
|
||||
theme::dim(),
|
||||
),
|
||||
]),
|
||||
Some(meta),
|
||||
Some(index),
|
||||
));
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let cursor_row = rows
|
||||
.iter()
|
||||
|
||||
-230
@@ -1,230 +0,0 @@
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||
|
||||
use super::theme;
|
||||
use crate::app::state::{LoginField, LoginForm, LoginMode};
|
||||
|
||||
pub fn draw(frame: &mut Frame, form: &LoginForm) {
|
||||
match form.mode {
|
||||
LoginMode::Form => draw_form(frame, form),
|
||||
LoginMode::SsoPending => draw_sso_pending(frame, form),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_form(frame: &mut Frame, form: &LoginForm) {
|
||||
let area = centered(frame.area(), 52, 19);
|
||||
let block = Block::bordered()
|
||||
.title(" Sign in to furumi ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// SSO is the primary path: server URL + SSO button up top, the rarely
|
||||
// used password fallback below a separator.
|
||||
let [
|
||||
server,
|
||||
sso_button,
|
||||
separator,
|
||||
username,
|
||||
password,
|
||||
signin_button,
|
||||
message,
|
||||
hint,
|
||||
] = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
draw_field(
|
||||
frame,
|
||||
server,
|
||||
"Server URL",
|
||||
&form.server_url,
|
||||
false,
|
||||
form.focus == LoginField::ServerUrl,
|
||||
);
|
||||
draw_button(
|
||||
frame,
|
||||
sso_button,
|
||||
"[ Continue with SSO ]",
|
||||
form.focus == LoginField::SsoButton,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("── or sign in with password ──", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
separator,
|
||||
);
|
||||
draw_field(
|
||||
frame,
|
||||
username,
|
||||
"Username",
|
||||
&form.username,
|
||||
false,
|
||||
form.focus == LoginField::Username,
|
||||
);
|
||||
draw_field(
|
||||
frame,
|
||||
password,
|
||||
"Password",
|
||||
&form.password,
|
||||
true,
|
||||
form.focus == LoginField::Password,
|
||||
);
|
||||
draw_button(
|
||||
frame,
|
||||
signin_button,
|
||||
"[ Sign in ]",
|
||||
form.focus == LoginField::SignInButton,
|
||||
);
|
||||
|
||||
draw_message(frame, message, form);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
"tab/↑↓ move · enter submit · ctrl-c quit",
|
||||
theme::dim(),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
hint,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) {
|
||||
// The URL stays on ONE line (wrapping breaks copy-paste); the dialog is
|
||||
// as wide as the terminal allows and ctrl-l copies the full link.
|
||||
let width =
|
||||
(form.sso_url.len() as u16 + 4).clamp(48, frame.area().width.saturating_sub(2).max(40));
|
||||
let area = centered(frame.area(), width, 14.min(frame.area().height));
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Continue with SSO ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [steps, url_label, url, paste, message, hint] = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
let lines = if let Some(port) = form.sso_port {
|
||||
vec![
|
||||
Line::raw("1. Finish signing in, in the browser window."),
|
||||
Line::from(vec![
|
||||
Span::raw("2. Sign-in completes here automatically "),
|
||||
Span::styled(format!("(waiting on 127.0.0.1:{port})"), theme::dim()),
|
||||
]),
|
||||
Line::raw("3. If it doesn't, paste the code from the page below."),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Line::raw("1. Finish signing in, in the browser window."),
|
||||
Line::raw("2. Copy the code shown on the final page."),
|
||||
Line::raw("3. Paste it below and press Enter."),
|
||||
]
|
||||
};
|
||||
frame.render_widget(Paragraph::new(lines), steps);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
"If the browser didn't open — ctrl-l copies this link, ctrl-o retries:",
|
||||
theme::dim(),
|
||||
)),
|
||||
url_label,
|
||||
);
|
||||
// One line, never wrapped: a wrapped URL copies with a line break and
|
||||
// stops working. If it doesn't fit, ctrl-l still copies it whole.
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(form.sso_url.clone(), theme::accent())),
|
||||
url,
|
||||
);
|
||||
|
||||
draw_field(frame, paste, "Link or code", &form.sso_paste, false, true);
|
||||
draw_message(frame, message, form);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
"enter submit · ctrl-l copy link · esc back · ctrl-c quit",
|
||||
theme::dim(),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
hint,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_field(frame: &mut Frame, area: Rect, label: &str, value: &str, mask: bool, focused: bool) {
|
||||
let border = if focused {
|
||||
theme::accent()
|
||||
} else {
|
||||
theme::dim()
|
||||
};
|
||||
let block = Block::bordered().title(label).border_style(border);
|
||||
let shown = if mask {
|
||||
"•".repeat(value.chars().count())
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
// Keep the tail visible when the value overflows the field.
|
||||
let width = block.inner(area).width.saturating_sub(1) as usize;
|
||||
let mut text: String = shown
|
||||
.chars()
|
||||
.skip(shown.chars().count().saturating_sub(width))
|
||||
.collect();
|
||||
if focused {
|
||||
text.push('█');
|
||||
}
|
||||
frame.render_widget(Paragraph::new(text).block(block), area);
|
||||
}
|
||||
|
||||
fn draw_button(frame: &mut Frame, area: Rect, label: &str, focused: bool) {
|
||||
let style = if focused {
|
||||
theme::tab_active()
|
||||
} else {
|
||||
theme::dim()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(label, style)).alignment(Alignment::Center),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_message(frame: &mut Frame, area: Rect, form: &LoginForm) {
|
||||
let line = if form.busy {
|
||||
Line::styled("signing in…", theme::accent())
|
||||
} else if let Some(error) = &form.error {
|
||||
Line::styled(error.clone(), Style::new().fg(Color::Red))
|
||||
} else {
|
||||
Line::default()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(line)
|
||||
.wrap(Wrap { trim: true })
|
||||
.alignment(Alignment::Center),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn centered(area: Rect, width: u16, height: u16) -> Rect {
|
||||
let [rect] = Layout::horizontal([Constraint::Length(width.min(area.width))])
|
||||
.flex(Flex::Center)
|
||||
.areas(area);
|
||||
let [rect] = Layout::vertical([Constraint::Length(height.min(area.height))])
|
||||
.flex(Flex::Center)
|
||||
.areas(rect);
|
||||
rect
|
||||
}
|
||||
+10
-58
@@ -1,6 +1,6 @@
|
||||
pub mod art;
|
||||
mod federation;
|
||||
mod global;
|
||||
mod login;
|
||||
mod logs;
|
||||
mod playlists;
|
||||
mod popup;
|
||||
@@ -12,14 +12,10 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
||||
|
||||
use crate::app::state::{AppState, Screen, Tab, TrackSelectionScope};
|
||||
use crate::app::state::{AppState, Tab, TrackSelectionScope};
|
||||
use crate::config::keymap::Keymap;
|
||||
|
||||
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
||||
if state.screen == Screen::Login {
|
||||
login::draw(frame, &state.login);
|
||||
return;
|
||||
}
|
||||
let [tabs_area, main_area, status_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
@@ -32,6 +28,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
||||
Tab::Global => global::draw(frame, main_area, state),
|
||||
Tab::Playlists => playlists::draw(frame, main_area, state),
|
||||
Tab::Queue => draw_queue(frame, main_area, state),
|
||||
Tab::Federation => federation::draw(frame, main_area, state),
|
||||
Tab::Logs => logs::draw(frame, main_area, state),
|
||||
}
|
||||
draw_status(frame, status_area, state);
|
||||
@@ -60,7 +57,7 @@ pub(crate) fn track_row(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
track: &crate::api::models::TrackItem,
|
||||
track: &crate::library::models::TrackItem,
|
||||
index_label: String,
|
||||
selected: bool,
|
||||
visual_selected: bool,
|
||||
@@ -92,7 +89,7 @@ pub(crate) fn track_row(
|
||||
}
|
||||
|
||||
pub(crate) fn track_meta_suffix(
|
||||
track: &crate::api::models::TrackItem,
|
||||
track: &crate::library::models::TrackItem,
|
||||
include_tech: bool,
|
||||
) -> String {
|
||||
let has_tech = track.audio_format.is_some()
|
||||
@@ -201,8 +198,8 @@ fn format_secs(secs: f64) -> String {
|
||||
/// Wider consoles get a longer bar and full flags; narrow ones drop pieces.
|
||||
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(track) = &player.current {
|
||||
if player.playing {
|
||||
if let Some(track) = &player.current
|
||||
&& player.playing {
|
||||
let bar_width: usize = match width {
|
||||
0..=59 => 0,
|
||||
60..=79 => 8,
|
||||
@@ -227,7 +224,6 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if width >= 80 {
|
||||
let volume_cells = usize::from(player.volume / 10);
|
||||
spans.extend([
|
||||
@@ -260,65 +256,21 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max: usize) -> String {
|
||||
let mut out: String = value.chars().take(max).collect();
|
||||
if value.chars().count() > max {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn device_status_line(state: &AppState) -> Line<'static> {
|
||||
if state.devices.is_playback_device() {
|
||||
return Line::from(vec![Span::styled("playing here", theme::accent())]);
|
||||
}
|
||||
let name = state
|
||||
.devices
|
||||
.active_device_name()
|
||||
.map(|name| truncate_chars(name, 26))
|
||||
.unwrap_or_else(|| "remote device".to_string());
|
||||
Line::from(vec![
|
||||
Span::styled("controlling ", theme::dim()),
|
||||
Span::styled(name, theme::accent()),
|
||||
])
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let [player_row, message_row] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
||||
|
||||
let player = &state.player;
|
||||
// Layout: track title left, time/progress/flags centered, user right.
|
||||
// The center block is built first and gets a fixed width; the title
|
||||
// Layout: track title left, time/progress/flags on the right. The
|
||||
// right block is built first and gets a fixed width; the title
|
||||
// truncates into whatever is left.
|
||||
let center = player_right_line(player, area.width);
|
||||
let center_width = (center.width() as u16).min(area.width);
|
||||
let device_line = device_status_line(state);
|
||||
let device_width = (device_line.width() as u16).min(32);
|
||||
let user_line = state.user.as_ref().map(|user| {
|
||||
Line::from(vec![
|
||||
Span::styled("◉ ", theme::accent()),
|
||||
Span::raw(user.name.clone()),
|
||||
])
|
||||
});
|
||||
let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16);
|
||||
let [title_area, right_area, device_area, user_area] = Layout::horizontal([
|
||||
let [title_area, right_area] = Layout::horizontal([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(center_width),
|
||||
Constraint::Length(device_width.saturating_add(2)),
|
||||
Constraint::Length(user_width),
|
||||
])
|
||||
.areas(player_row);
|
||||
frame.render_widget(
|
||||
Paragraph::new(device_line).alignment(Alignment::Right),
|
||||
device_area,
|
||||
);
|
||||
if let Some(user_line) = user_line {
|
||||
frame.render_widget(
|
||||
Paragraph::new(user_line).alignment(Alignment::Right),
|
||||
user_area,
|
||||
);
|
||||
}
|
||||
|
||||
let mut spans = Vec::new();
|
||||
match &player.current {
|
||||
|
||||
+1
-19
@@ -78,26 +78,8 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let mut flags = Vec::new();
|
||||
if !playlist.is_own {
|
||||
if let Some(owner) = &playlist.owner_name {
|
||||
flags.push(format!("by {owner}"));
|
||||
}
|
||||
}
|
||||
if playlist.is_public {
|
||||
flags.push("public".to_string());
|
||||
}
|
||||
let suffix = if flags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", flags.join(" · "))
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
marker,
|
||||
Span::raw(playlist.title.clone()),
|
||||
Span::styled(suffix, theme::dim()),
|
||||
])),
|
||||
Paragraph::new(Line::from(vec![marker, Span::raw(playlist.title.clone())])),
|
||||
row,
|
||||
);
|
||||
frame.render_widget(
|
||||
|
||||
+130
-86
@@ -4,8 +4,8 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
||||
|
||||
use super::theme;
|
||||
use crate::api::models::{ArtistRef, TrackItem};
|
||||
use crate::app::state::{AppState, Loadable, Popup, addable_playlists};
|
||||
use crate::app::state::{AppState, EditField, Loadable, Popup, addable_playlists};
|
||||
use crate::library::models::{ArtistRef, TrackItem};
|
||||
|
||||
pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
match state.popup.as_ref() {
|
||||
@@ -13,102 +13,156 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
draw_picker(frame, state, &track.title, *cursor)
|
||||
}
|
||||
Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy),
|
||||
Some(Popup::Devices { cursor }) => draw_devices(frame, state, *cursor),
|
||||
Some(Popup::Edit {
|
||||
title,
|
||||
fields,
|
||||
focus,
|
||||
error,
|
||||
..
|
||||
}) => draw_edit(frame, title, fields, *focus, error.as_deref()),
|
||||
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label),
|
||||
Some(Popup::TrackInfo {
|
||||
tracks,
|
||||
cursor,
|
||||
scroll,
|
||||
}) => draw_track_info(frame, tracks, *cursor, *scroll),
|
||||
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
|
||||
Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input),
|
||||
Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||
let rows = state.devices.devices.len().max(1);
|
||||
let height = (rows as u16 + 4)
|
||||
.min(frame.area().height.saturating_sub(2))
|
||||
.max(7);
|
||||
let area = centered(frame.area(), 64, height);
|
||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||
fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) {
|
||||
let area = centered(frame.area(), 64, 5);
|
||||
let block = Block::bordered()
|
||||
.title(" Connected devices ")
|
||||
.title(format!(" {title} "))
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
let [entry_area, hint_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
|
||||
// Keep the tail visible when the value (a ticket) exceeds the width.
|
||||
let visible: String = {
|
||||
let width = usize::from(entry_area.width.saturating_sub(2));
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
let skip = chars.len().saturating_sub(width);
|
||||
chars[skip..].iter().collect()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(visible),
|
||||
Span::styled("█", theme::accent()),
|
||||
])),
|
||||
entry_area,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("enter: apply · esc: cancel", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
hint_area,
|
||||
);
|
||||
}
|
||||
|
||||
/// Read-only wrapped text (this peer's federation ticket).
|
||||
fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) {
|
||||
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
|
||||
let text_width = usize::from(width.saturating_sub(2));
|
||||
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
|
||||
let area = centered(frame.area(), width, lines_needed.clamp(5, frame.area().height));
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {title} "))
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(text.to_string()).wrap(Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
/// Metadata edit form: one bordered input per field, the focused field gets
|
||||
/// the accent border and a cursor block.
|
||||
fn draw_edit(
|
||||
frame: &mut Frame,
|
||||
title: &str,
|
||||
fields: &[EditField],
|
||||
focus: usize,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
let height = (fields.len() as u16 * 3 + 4).min(frame.area().height.saturating_sub(2));
|
||||
let area = centered(frame.area(), 60, height);
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {title} "))
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [list_area, _, footer] = Layout::vertical([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(inner);
|
||||
let mut constraints: Vec<Constraint> = fields.iter().map(|_| Constraint::Length(3)).collect();
|
||||
constraints.push(Constraint::Min(0));
|
||||
constraints.push(Constraint::Length(1));
|
||||
let areas = Layout::vertical(constraints).split(inner);
|
||||
|
||||
if state.devices.devices.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("waiting for device poll…", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
list_area,
|
||||
);
|
||||
} else {
|
||||
let visible = usize::from(list_area.height.max(1));
|
||||
let cursor = cursor.min(state.devices.devices.len() - 1);
|
||||
let first = cursor
|
||||
.saturating_sub(visible / 2)
|
||||
.min(state.devices.devices.len().saturating_sub(visible));
|
||||
for (index, device) in state
|
||||
.devices
|
||||
.devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(first)
|
||||
.take(visible)
|
||||
{
|
||||
let row = Rect {
|
||||
x: list_area.x,
|
||||
y: list_area.y + (index - first) as u16,
|
||||
width: list_area.width,
|
||||
height: 1,
|
||||
};
|
||||
let marker = if device.is_active {
|
||||
Span::styled("▶ ", theme::accent())
|
||||
} else {
|
||||
Span::styled(" ", theme::dim())
|
||||
};
|
||||
let current = if device.is_current {
|
||||
" · this TUI"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let switching = if state.devices.switching_to.as_deref() == Some(device.id.as_str()) {
|
||||
" · switching"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let line = Line::from(vec![
|
||||
marker,
|
||||
Span::raw(device.name.clone()),
|
||||
Span::styled(
|
||||
format!(" · {}{current}{switching}", device.kind),
|
||||
theme::dim(),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), row);
|
||||
if index == cursor {
|
||||
frame.buffer_mut().set_style(row, theme::tab_active());
|
||||
}
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
let focused = index == focus;
|
||||
let field_block = Block::bordered().title(field.label).border_style(if focused {
|
||||
theme::accent()
|
||||
} else {
|
||||
theme::dim()
|
||||
});
|
||||
let field_inner = field_block.inner(areas[index]);
|
||||
frame.render_widget(field_block, areas[index]);
|
||||
let width = usize::from(field_inner.width.saturating_sub(1));
|
||||
let mut shown: String = field
|
||||
.value
|
||||
.chars()
|
||||
.skip(field.value.chars().count().saturating_sub(width))
|
||||
.collect();
|
||||
if focused {
|
||||
shown.push('█');
|
||||
}
|
||||
frame.render_widget(Paragraph::new(shown), field_inner);
|
||||
}
|
||||
|
||||
let hint = if let Some(error) = &state.devices.poll_error {
|
||||
Line::styled(format!("sync error: {error}"), theme::dim())
|
||||
} else {
|
||||
Line::styled("enter make active · esc close", theme::dim())
|
||||
let footer = areas[areas.len() - 1];
|
||||
let hint = match error {
|
||||
Some(error) => Line::styled(error.to_string(), theme::accent()),
|
||||
None => Line::styled("tab/↑↓ field · enter save · esc cancel", theme::dim()),
|
||||
};
|
||||
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer);
|
||||
}
|
||||
|
||||
fn draw_confirm_delete(frame: &mut Frame, label: &str) {
|
||||
let width = 64.min(frame.area().width.saturating_sub(4)).max(30);
|
||||
let area = centered(frame.area(), width, 7);
|
||||
let block = Block::bordered()
|
||||
.title(" Delete? ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("Delete {label}?"))
|
||||
.wrap(Wrap { trim: false })
|
||||
.alignment(Alignment::Center),
|
||||
body,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("enter/y delete · esc/n cancel", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
footer,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_log_detail(frame: &mut Frame, entry: &crate::config::logging::LogEntry) {
|
||||
let width = 90.min(frame.area().width.saturating_sub(4)).max(40);
|
||||
let height = 18.min(frame.area().height.saturating_sub(2)).max(7);
|
||||
@@ -203,7 +257,6 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
||||
track.duration_seconds
|
||||
),
|
||||
),
|
||||
field("Uploader", empty_dash(&track.uploader_name)),
|
||||
field("Audio format", opt_string(track.audio_format.clone())),
|
||||
field(
|
||||
"Bitrate",
|
||||
@@ -220,18 +273,9 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
||||
opt_map(track.audio_bit_depth, |v| format!("{v} bit")),
|
||||
),
|
||||
field("File size", file_size(track.file_size_bytes)),
|
||||
field("Last.fm listeners", opt_display(track.lastfm_listeners)),
|
||||
field("Last.fm plays", opt_display(track.lastfm_playcount)),
|
||||
field(
|
||||
"Last.fm rating",
|
||||
opt_map(track.lastfm_rating, |v| format!("{v:.3}")),
|
||||
),
|
||||
field(
|
||||
"Last.fm updated",
|
||||
opt_string(track.lastfm_updated_at.clone()),
|
||||
),
|
||||
field("Stream URL", empty_dash(&track.stream_url)),
|
||||
field("Cover URL", opt_string(track.cover_url.clone())),
|
||||
field("Plays", track.play_count.to_string()),
|
||||
field("File path", empty_dash(&track.file_path)),
|
||||
field("Cover path", opt_string(track.cover_path.clone())),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user