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]
|
[dependencies]
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
|
||||||
crokey = "1.4.0"
|
crokey = "1.4.0"
|
||||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
futures-util = "0.3.32"
|
futures-util = "0.3.32"
|
||||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
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"
|
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"] }
|
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 = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
serde_json = "1.0.150"
|
||||||
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
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"
|
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"
|
toml = "1.1.2"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
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,
|
NewPlaylist,
|
||||||
ToggleHelp,
|
ToggleHelp,
|
||||||
ToggleViewMode,
|
ToggleViewMode,
|
||||||
OpenDevices,
|
|
||||||
OpenCommandLine,
|
OpenCommandLine,
|
||||||
OpenSearch,
|
OpenSearch,
|
||||||
Logout,
|
EditSelected,
|
||||||
|
DeleteSelected,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Help-window sections, in display order.
|
/// Help-window sections, in display order.
|
||||||
@@ -50,15 +50,17 @@ pub enum Action {
|
|||||||
pub enum Category {
|
pub enum Category {
|
||||||
Playback,
|
Playback,
|
||||||
Queue,
|
Queue,
|
||||||
|
Library,
|
||||||
Navigation,
|
Navigation,
|
||||||
Search,
|
Search,
|
||||||
System,
|
System,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Category {
|
impl Category {
|
||||||
pub const ALL: [Category; 5] = [
|
pub const ALL: [Category; 6] = [
|
||||||
Category::Playback,
|
Category::Playback,
|
||||||
Category::Queue,
|
Category::Queue,
|
||||||
|
Category::Library,
|
||||||
Category::Navigation,
|
Category::Navigation,
|
||||||
Category::Search,
|
Category::Search,
|
||||||
Category::System,
|
Category::System,
|
||||||
@@ -68,6 +70,7 @@ impl Category {
|
|||||||
match self {
|
match self {
|
||||||
Category::Playback => "Playback",
|
Category::Playback => "Playback",
|
||||||
Category::Queue => "Queue & playlists",
|
Category::Queue => "Queue & playlists",
|
||||||
|
Category::Library => "Library",
|
||||||
Category::Navigation => "Navigation",
|
Category::Navigation => "Navigation",
|
||||||
Category::Search => "Search & commands",
|
Category::Search => "Search & commands",
|
||||||
Category::System => "System",
|
Category::System => "System",
|
||||||
@@ -111,9 +114,9 @@ impl Action {
|
|||||||
| Action::GoToTab(_)
|
| Action::GoToTab(_)
|
||||||
| Action::GoToRelease
|
| Action::GoToRelease
|
||||||
| Action::ToggleViewMode => Category::Navigation,
|
| Action::ToggleViewMode => Category::Navigation,
|
||||||
|
Action::EditSelected | Action::DeleteSelected => Category::Library,
|
||||||
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
|
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
|
||||||
Action::OpenDevices => Category::System,
|
Action::ToggleHelp | Action::Quit => Category::System,
|
||||||
Action::ToggleHelp | Action::Logout | Action::Quit => Category::System,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +124,7 @@ impl Action {
|
|||||||
pub fn command_hint(&self) -> Option<&'static str> {
|
pub fn command_hint(&self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Action::Quit => Some(":q"),
|
Action::Quit => Some(":q"),
|
||||||
Action::Logout => Some(":logout"),
|
Action::EditSelected => Some(":import <path> adds files"),
|
||||||
Action::PlayPause => Some(":play"),
|
Action::PlayPause => Some(":play"),
|
||||||
Action::NextTrack => Some(":next"),
|
Action::NextTrack => Some(":next"),
|
||||||
Action::PrevTrack => Some(":prev"),
|
Action::PrevTrack => Some(":prev"),
|
||||||
@@ -130,7 +133,6 @@ impl Action {
|
|||||||
Action::ToggleShuffle => Some(":shuffle"),
|
Action::ToggleShuffle => Some(":shuffle"),
|
||||||
Action::CycleRepeat => Some(":repeat [off|one|all]"),
|
Action::CycleRepeat => Some(":repeat [off|one|all]"),
|
||||||
Action::ClearQueue => Some(":clear"),
|
Action::ClearQueue => Some(":clear"),
|
||||||
Action::OpenDevices => Some(":devices"),
|
|
||||||
Action::ToggleHelp => Some(":help"),
|
Action::ToggleHelp => Some(":help"),
|
||||||
Action::OpenSearch => Some("/text"),
|
Action::OpenSearch => Some("/text"),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -174,10 +176,10 @@ impl Action {
|
|||||||
Action::NewPlaylist => "Create a playlist".into(),
|
Action::NewPlaylist => "Create a playlist".into(),
|
||||||
Action::ToggleHelp => "Show / hide keybindings".into(),
|
Action::ToggleHelp => "Show / hide keybindings".into(),
|
||||||
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
||||||
Action::OpenDevices => "Connected devices".into(),
|
|
||||||
Action::OpenCommandLine => "Command line (:help for commands)".into(),
|
Action::OpenCommandLine => "Command line (:help for commands)".into(),
|
||||||
Action::OpenSearch => "Search artists, releases, tracks".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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use crate::api::client::ApiError;
|
|
||||||
use crate::app::Runtime;
|
use crate::app::Runtime;
|
||||||
use crate::app::command::{self, Command, Parsed};
|
use crate::app::command::{self, Command, Parsed};
|
||||||
use crate::app::event::AppEvent;
|
use crate::app::event::AppEvent;
|
||||||
@@ -59,7 +58,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
|||||||
match command {
|
match command {
|
||||||
// One-shot commands have no live effect.
|
// One-shot commands have no live effect.
|
||||||
Command::Quit
|
Command::Quit
|
||||||
| Command::Logout
|
| Command::Import(_)
|
||||||
| Command::Volume(_)
|
| Command::Volume(_)
|
||||||
| Command::Seek(_)
|
| Command::Seek(_)
|
||||||
| Command::SeekTo(_)
|
| Command::SeekTo(_)
|
||||||
@@ -70,7 +69,6 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
|||||||
| Command::Prev
|
| Command::Prev
|
||||||
| Command::PlayPause
|
| Command::PlayPause
|
||||||
| Command::Help
|
| Command::Help
|
||||||
| Command::Devices
|
|
||||||
| Command::Logs(_) => {}
|
| Command::Logs(_) => {}
|
||||||
Command::Search(query) => {
|
Command::Search(query) => {
|
||||||
state.active_tab = Tab::Global;
|
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
|
/// Debounced, race-free search: every edit bumps the global sequence; the
|
||||||
/// spawned task only queries if it is still the latest after the debounce,
|
/// spawned task only queries if it is still the latest after the debounce,
|
||||||
/// and the receiver drops responses that arrive out of date.
|
/// 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 seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
let query = state.search.query.clone();
|
let query = state.search.query.clone();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
state.search.loading = false;
|
state.search.loading = false;
|
||||||
state.search.results = None;
|
state.search.results = None;
|
||||||
|
state.search.fed_tracks.clear();
|
||||||
|
state.search.fed_loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(api) = runtime.api.clone() else {
|
let library = Arc::clone(&runtime.library);
|
||||||
return;
|
|
||||||
};
|
|
||||||
state.search.loading = true;
|
state.search.loading = true;
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
let latest = Arc::clone(&runtime.search_seq);
|
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 {
|
if latest.load(Ordering::SeqCst) != seq {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let event = match api.search(&query, SEARCH_LIMIT).await {
|
let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT))
|
||||||
Ok(results) => AppEvent::SearchLoaded {
|
.await
|
||||||
seq,
|
.map_err(|err| err.to_string())
|
||||||
result: Ok(results),
|
.and_then(|result| result.map_err(|err| format!("{err:#}")));
|
||||||
},
|
let _ = tx.send(AppEvent::SearchLoaded { seq, result });
|
||||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
|
||||||
Err(err) => AppEvent::SearchLoaded {
|
|
||||||
seq,
|
|
||||||
result: Err(err.to_string()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let _ = tx.send(event);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
/// 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 {
|
match command {
|
||||||
Command::Search(_) => {}
|
Command::Search(_) => {}
|
||||||
Command::Quit => state.should_quit = true,
|
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) => {
|
Command::Volume(value) => {
|
||||||
state.player.volume = value;
|
state.player.volume = value;
|
||||||
super::perform_effect(state, runtime, Effect::SetVolume(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::Prev => run_action(state, runtime, Action::PrevTrack),
|
||||||
Command::PlayPause => run_action(state, runtime, Action::PlayPause),
|
Command::PlayPause => run_action(state, runtime, Action::PlayPause),
|
||||||
Command::Help => state.help_visible = true,
|
Command::Help => state.help_visible = true,
|
||||||
Command::Devices => run_action(state, runtime, Action::OpenDevices),
|
|
||||||
Command::Logs(level) => {
|
Command::Logs(level) => {
|
||||||
if let Some(index) = level {
|
if let Some(index) = level {
|
||||||
state.logs.level_index = index.min(LOG_LEVELS.len() - 1);
|
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
|
/// `:q` / `:quit` — exit immediately (explicit enough to skip the
|
||||||
/// double-press confirmation).
|
/// double-press confirmation).
|
||||||
Quit,
|
Quit,
|
||||||
/// `:logout` — sign out and return to the login screen.
|
/// `:import <path>` — import an audio file or a directory into the
|
||||||
Logout,
|
/// library.
|
||||||
|
Import(String),
|
||||||
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
||||||
Volume(u8),
|
Volume(u8),
|
||||||
/// `:seek +30` / `:seek -10` — relative seek in seconds.
|
/// `:seek +30` / `:seek -10` — relative seek in seconds.
|
||||||
@@ -43,8 +44,6 @@ pub enum Command {
|
|||||||
PlayPause,
|
PlayPause,
|
||||||
/// `:help` — open the keybinding help.
|
/// `:help` — open the keybinding help.
|
||||||
Help,
|
Help,
|
||||||
/// `:devices` — open the connected-devices picker.
|
|
||||||
Devices,
|
|
||||||
/// `:logs [error|warn|info|debug|trace]` — jump to the Logs tab,
|
/// `:logs [error|warn|info|debug|trace]` — jump to the Logs tab,
|
||||||
/// optionally setting the severity filter.
|
/// optionally setting the severity filter.
|
||||||
Logs(Option<usize>),
|
Logs(Option<usize>),
|
||||||
@@ -74,7 +73,15 @@ pub fn parse(input: &str) -> Parsed {
|
|||||||
let arg = parts.next();
|
let arg = parts.next();
|
||||||
match name {
|
match name {
|
||||||
"q" | "quit" => Parsed::Command(Command::Quit),
|
"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()) {
|
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
|
||||||
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
||||||
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
||||||
@@ -96,7 +103,6 @@ pub fn parse(input: &str) -> Parsed {
|
|||||||
"prev" => Parsed::Command(Command::Prev),
|
"prev" => Parsed::Command(Command::Prev),
|
||||||
"pause" | "play" => Parsed::Command(Command::PlayPause),
|
"pause" | "play" => Parsed::Command(Command::PlayPause),
|
||||||
"help" => Parsed::Command(Command::Help),
|
"help" => Parsed::Command(Command::Help),
|
||||||
"devices" | "device" => Parsed::Command(Command::Devices),
|
|
||||||
"logs" => match arg {
|
"logs" => match arg {
|
||||||
None => Parsed::Command(Command::Logs(None)),
|
None => Parsed::Command(Command::Logs(None)),
|
||||||
Some(level) => match ["error", "warn", "info", "debug", "trace"]
|
Some(level) => match ["error", "warn", "info", "debug", "trace"]
|
||||||
@@ -152,7 +158,11 @@ mod tests {
|
|||||||
fn parses_word_commands() {
|
fn parses_word_commands() {
|
||||||
assert_eq!(parse("q"), Parsed::Command(Command::Quit));
|
assert_eq!(parse("q"), Parsed::Command(Command::Quit));
|
||||||
assert_eq!(parse("quit"), 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("volume 40"), Parsed::Command(Command::Volume(40)));
|
||||||
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
||||||
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
|
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
|
||||||
@@ -162,7 +172,6 @@ mod tests {
|
|||||||
Parsed::Command(Command::Repeat(Some(RepeatArg::All)))
|
Parsed::Command(Command::Repeat(Some(RepeatArg::All)))
|
||||||
);
|
);
|
||||||
assert_eq!(parse("clear"), Parsed::Command(Command::ClearQueue));
|
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))));
|
assert_eq!(parse("logs debug"), Parsed::Command(Command::Logs(Some(3))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-21
@@ -1,25 +1,24 @@
|
|||||||
use std::sync::Arc;
|
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::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
|
/// Events delivered to the main loop by background tasks (library queries,
|
||||||
/// playback engine, device sync). Tasks never touch AppState directly.
|
/// the playback engine, imports). Tasks never touch AppState directly.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
StatusMessage(String),
|
StatusMessage(String),
|
||||||
LoginSucceeded(Box<AuthSession>),
|
/// A page of the artists list arrived (or failed).
|
||||||
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).
|
|
||||||
ArtistsLoaded(Result<ArtistsPage, String>),
|
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 {
|
ArtistViewLoaded {
|
||||||
id: i64,
|
id: i64,
|
||||||
result: Result<ArtistDetail, String>,
|
result: Result<ArtistDetail, String>,
|
||||||
@@ -33,7 +32,7 @@ pub enum AppEvent {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
result: Result<SearchResults, String>,
|
result: Result<SearchResults, String>,
|
||||||
},
|
},
|
||||||
/// Artwork fetched and decoded for the shared art cache.
|
/// Artwork loaded and decoded for the shared art cache.
|
||||||
ArtLoaded {
|
ArtLoaded {
|
||||||
key: String,
|
key: String,
|
||||||
art: Option<Arc<ArtImage>>,
|
art: Option<Arc<ArtImage>>,
|
||||||
@@ -41,7 +40,7 @@ pub enum AppEvent {
|
|||||||
Player(crate::player::PlayerEvent),
|
Player(crate::player::PlayerEvent),
|
||||||
/// A command from the OS media keys.
|
/// A command from the OS media keys.
|
||||||
Media(crate::media::MediaCommand),
|
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.
|
/// path takes over when the current track ends.
|
||||||
PrefetchFailed {
|
PrefetchFailed {
|
||||||
pos: usize,
|
pos: usize,
|
||||||
@@ -57,11 +56,6 @@ pub enum AppEvent {
|
|||||||
track_id: i64,
|
track_id: i64,
|
||||||
liked: bool,
|
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).
|
/// A release fetched for queueing (a / shift-a on a release).
|
||||||
EnqueueTracks {
|
EnqueueTracks {
|
||||||
tracks: Vec<TrackItem>,
|
tracks: Vec<TrackItem>,
|
||||||
@@ -77,4 +71,33 @@ pub enum AppEvent {
|
|||||||
playlist_title: String,
|
playlist_title: String,
|
||||||
result: Result<(), 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
|
//! Modal dialog input: the add-to-playlist picker, new-playlist name entry,
|
||||||
//! entry. The popup is taken out of the state, handled as an owned value
|
//! metadata edit forms and delete confirmations. The popup is taken out of
|
||||||
//! and put back unless the action closed it.
|
//! 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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use crate::api::models::TrackItem;
|
|
||||||
use crate::app::Runtime;
|
use crate::app::Runtime;
|
||||||
use crate::app::event::AppEvent;
|
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) {
|
pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
|
||||||
let Some(popup) = state.popup.take() else {
|
let Some(popup) = state.popup.take() else {
|
||||||
@@ -22,7 +27,16 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
|
|||||||
input,
|
input,
|
||||||
busy,
|
busy,
|
||||||
} => handle_name_entry(state, runtime, for_track, input, busy, key),
|
} => 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 {
|
Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -32,54 +46,269 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
|
|||||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
||||||
_ => state.popup = Some(Popup::LogDetail(entry)),
|
_ => 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.
|
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||||
pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
fn handle_fed_input(
|
||||||
if let Some(Popup::NewPlaylist { input, busy, .. }) = &mut state.popup {
|
state: &mut AppState,
|
||||||
if !*busy {
|
runtime: &Runtime,
|
||||||
input.extend(pasted.chars().filter(|c| !c.is_control()));
|
field: FedInputField,
|
||||||
}
|
mut input: String,
|
||||||
}
|
key: KeyEvent,
|
||||||
}
|
) {
|
||||||
|
|
||||||
fn handle_devices(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) {
|
|
||||||
let len = state.devices.devices.len();
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {}
|
KeyCode::Esc => {}
|
||||||
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::Enter => {
|
KeyCode::Enter => {
|
||||||
if let Some(device) = state.devices.devices.get(cursor.min(len.saturating_sub(1))) {
|
let value = input.trim().to_string();
|
||||||
let target = device.id.clone();
|
match field {
|
||||||
state.devices.switching_to = Some(target.clone());
|
FedInputField::NetworkId => {
|
||||||
state.popup = Some(Popup::Devices { cursor });
|
state.federation.settings.network_id = value;
|
||||||
spawn_select_device(runtime, target);
|
// An empty id turns federation off rather than leaving a
|
||||||
} else {
|
// node bound to an unnamed network; a freshly set id
|
||||||
state.popup = Some(Popup::Devices { cursor: 0 });
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
KeyCode::Backspace => {
|
||||||
state.popup = Some(Popup::Devices {
|
input.pop();
|
||||||
cursor: cursor.min(len.saturating_sub(1)),
|
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(
|
fn handle_track_info(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
tracks: Vec<TrackItem>,
|
tracks: Vec<TrackItem>,
|
||||||
@@ -132,6 +361,10 @@ fn handle_track_info(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add-to-playlist picker & new playlist
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn handle_picker(
|
fn handle_picker(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
runtime: &Runtime,
|
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) {
|
fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, track: TrackItem) {
|
||||||
let Some(api) = runtime.api.clone() else {
|
let library = Arc::clone(&runtime.library);
|
||||||
return;
|
|
||||||
};
|
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = api
|
let result = library
|
||||||
.add_tracks_to_playlist(playlist_id, &[track.id])
|
.add_tracks_to_playlist(playlist_id, &[track.id])
|
||||||
.await
|
.map_err(|err| format!("{err:#}"));
|
||||||
.map_err(|e| e.to_string());
|
|
||||||
let _ = tx.send(AppEvent::PlaylistTracksAdded {
|
let _ = tx.send(AppEvent::PlaylistTracksAdded {
|
||||||
playlist_id,
|
playlist_id,
|
||||||
playlist_title,
|
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>) {
|
fn spawn_create_playlist(runtime: &Runtime, title: String, add_track: Option<TrackItem>) {
|
||||||
let Some(api) = runtime.api.clone() else {
|
let library = Arc::clone(&runtime.library);
|
||||||
return;
|
|
||||||
};
|
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = api.create_playlist(&title).await.map_err(|e| e.to_string());
|
let result = library
|
||||||
|
.create_playlist(&title)
|
||||||
|
.map_err(|err| format!("{err:#}"));
|
||||||
let _ = tx.send(AppEvent::PlaylistCreated { result, add_track });
|
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::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::api::models::{
|
|
||||||
ArtistCard, ArtistDetail, DeviceDto, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
|
|
||||||
SearchResults, TrackItem, User,
|
|
||||||
};
|
|
||||||
use crate::art::ArtImage;
|
use crate::art::ArtImage;
|
||||||
use crate::config::keymap::KeyContext;
|
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.
|
/// Remote data that a view renders: spinner, content, or error.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -83,9 +83,12 @@ pub struct GlobalTab {
|
|||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
pub view: ViewMode,
|
pub view: ViewMode,
|
||||||
pub stack: Vec<GlobalView>,
|
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.
|
/// `(page-1) * limit`, so it must not change between pages.
|
||||||
pub page_limit: Option<i64>,
|
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 {
|
impl Default for GlobalTab {
|
||||||
@@ -101,6 +104,7 @@ impl Default for GlobalTab {
|
|||||||
view: ViewMode::default(),
|
view: ViewMode::default(),
|
||||||
stack: Vec::new(),
|
stack: Vec::new(),
|
||||||
page_limit: None,
|
page_limit: None,
|
||||||
|
reloading: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,8 +165,8 @@ pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>>
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The virtual server-side Likes playlist id (`kind == "likes"`).
|
/// The virtual Likes playlist id (`kind == "likes"`).
|
||||||
pub const LIKES_PLAYLIST_ID: i64 = -1;
|
pub use crate::library::LIKES_PLAYLIST_ID;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct OpenedPlaylist {
|
pub struct OpenedPlaylist {
|
||||||
@@ -282,42 +286,45 @@ impl Default for LogsTab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
/// What an open edit form writes to when saved.
|
||||||
pub struct DevicesState {
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub device_id: String,
|
pub enum EditTarget {
|
||||||
pub active_device_id: Option<String>,
|
Track(i64),
|
||||||
pub devices: Vec<DeviceDto>,
|
Release(i64),
|
||||||
pub poll_error: Option<String>,
|
Artist(i64),
|
||||||
pub switching_to: Option<String>,
|
Playlist(i64),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DevicesState {
|
/// What a confirmed delete removes.
|
||||||
pub fn is_playback_device(&self) -> bool {
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
self.active_device_id
|
pub enum DeleteTarget {
|
||||||
.as_deref()
|
Track(i64),
|
||||||
.is_none_or(|active| active == self.device_id)
|
Release(i64),
|
||||||
}
|
Artist(i64),
|
||||||
|
Playlist(i64),
|
||||||
|
}
|
||||||
|
|
||||||
pub fn remote_target_id(&self) -> Option<&str> {
|
/// One text field of an edit form.
|
||||||
self.active_device_id
|
#[derive(Debug, Clone)]
|
||||||
.as_deref()
|
pub struct EditField {
|
||||||
.filter(|active| *active != self.device_id)
|
pub label: &'static str,
|
||||||
}
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn active_device_name(&self) -> Option<&str> {
|
impl EditField {
|
||||||
let active = self.active_device_id.as_deref()?;
|
pub fn new(label: &'static str, value: impl Into<String>) -> Self {
|
||||||
self.devices
|
Self {
|
||||||
.iter()
|
label,
|
||||||
.find(|device| device.id == active)
|
value: value.into(),
|
||||||
.map(|device| device.name.as_str())
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modal dialog over the main screen.
|
/// Modal dialog over the main screen.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Popup {
|
pub enum Popup {
|
||||||
/// Pick one of the user's playlists (row 0 = "create new"); the track
|
/// Pick one of the playlists (row 0 = "create new"); the track is added
|
||||||
/// is added on Enter.
|
/// on Enter.
|
||||||
AddToPlaylist { track: TrackItem, cursor: usize },
|
AddToPlaylist { track: TrackItem, cursor: usize },
|
||||||
/// Name input for a new playlist; when `for_track` is set, the track is
|
/// Name input for a new playlist; when `for_track` is set, the track is
|
||||||
/// added to it right after creation.
|
/// added to it right after creation.
|
||||||
@@ -326,8 +333,16 @@ pub enum Popup {
|
|||||||
input: String,
|
input: String,
|
||||||
busy: bool,
|
busy: bool,
|
||||||
},
|
},
|
||||||
/// Connected devices list; Enter transfers active playback to the row.
|
/// Metadata edit form for a track, release, artist or playlist.
|
||||||
Devices { cursor: usize },
|
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.
|
/// Track metadata viewer; left/right switch between selected tracks.
|
||||||
TrackInfo {
|
TrackInfo {
|
||||||
tracks: Vec<TrackItem>,
|
tracks: Vec<TrackItem>,
|
||||||
@@ -336,15 +351,67 @@ pub enum Popup {
|
|||||||
},
|
},
|
||||||
/// Full, wrapped view of one log entry (Enter on the Logs tab).
|
/// Full, wrapped view of one log entry (Enter on the Logs tab).
|
||||||
LogDetail(crate::config::logging::LogEntry),
|
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
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
/// is managed through likes, not direct adds).
|
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)> {
|
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
|
||||||
match &state.playlists.list {
|
match &state.playlists.list {
|
||||||
Some(Loadable::Ready(list)) => list
|
Some(Loadable::Ready(list)) => list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.is_own && p.kind != "likes")
|
.filter(|p| p.kind != "likes")
|
||||||
.map(|p| (p.id, p.title.clone()))
|
.map(|p| (p.id, p.title.clone()))
|
||||||
.collect(),
|
.collect(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
@@ -367,85 +434,10 @@ pub struct SearchState {
|
|||||||
pub query: String,
|
pub query: String,
|
||||||
pub loading: bool,
|
pub loading: bool,
|
||||||
pub results: Option<SearchResults>,
|
pub results: Option<SearchResults>,
|
||||||
}
|
/// Tracks found on the federated network (empty while federation is
|
||||||
|
/// off); rendered as a separate, marked section.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
pub fed_tracks: Vec<crate::federation::FedTrack>,
|
||||||
pub enum Screen {
|
pub fed_loading: bool,
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -454,17 +446,25 @@ pub enum Tab {
|
|||||||
Global,
|
Global,
|
||||||
Playlists,
|
Playlists,
|
||||||
Queue,
|
Queue,
|
||||||
|
Federation,
|
||||||
Logs,
|
Logs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tab {
|
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 {
|
pub fn title(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Tab::Global => "Global",
|
Tab::Global => "Global",
|
||||||
Tab::Playlists => "Playlists",
|
Tab::Playlists => "Playlists",
|
||||||
Tab::Queue => "Queue",
|
Tab::Queue => "Queue",
|
||||||
|
Tab::Federation => "Federation",
|
||||||
Tab::Logs => "Logs",
|
Tab::Logs => "Logs",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,6 +490,7 @@ impl Tab {
|
|||||||
Tab::Global => KeyContext::Library,
|
Tab::Global => KeyContext::Library,
|
||||||
Tab::Playlists => KeyContext::Playlists,
|
Tab::Playlists => KeyContext::Playlists,
|
||||||
Tab::Queue => KeyContext::Queue,
|
Tab::Queue => KeyContext::Queue,
|
||||||
|
Tab::Federation => KeyContext::Federation,
|
||||||
Tab::Logs => KeyContext::Logs,
|
Tab::Logs => KeyContext::Logs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -567,7 +568,6 @@ impl Default for PlayerBar {
|
|||||||
/// event handlers in the main loop; views render from `&AppState`.
|
/// event handlers in the main loop; views render from `&AppState`.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub screen: Screen,
|
|
||||||
pub active_tab: Tab,
|
pub active_tab: Tab,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
/// Double-press quit confirmation: set by the first Quit press, expires
|
/// Double-press quit confirmation: set by the first Quit press, expires
|
||||||
@@ -577,8 +577,6 @@ pub struct AppState {
|
|||||||
pub pending_keys: Option<String>,
|
pub pending_keys: Option<String>,
|
||||||
pub status_message: Option<String>,
|
pub status_message: Option<String>,
|
||||||
pub player: PlayerBar,
|
pub player: PlayerBar,
|
||||||
pub login: LoginForm,
|
|
||||||
pub user: Option<User>,
|
|
||||||
pub global: GlobalTab,
|
pub global: GlobalTab,
|
||||||
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
|
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
|
||||||
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
||||||
@@ -588,8 +586,8 @@ pub struct AppState {
|
|||||||
pub likes: std::collections::HashSet<i64>,
|
pub likes: std::collections::HashSet<i64>,
|
||||||
pub likes_loaded: bool,
|
pub likes_loaded: bool,
|
||||||
pub logs: LogsTab,
|
pub logs: LogsTab,
|
||||||
pub devices: DevicesState,
|
|
||||||
pub queue_tab: QueueTab,
|
pub queue_tab: QueueTab,
|
||||||
|
pub federation: FederationTab,
|
||||||
pub track_selection: TrackSelection,
|
pub track_selection: TrackSelection,
|
||||||
/// Shift-J jump in flight: focus this (release, track) once the release
|
/// Shift-J jump in flight: focus this (release, track) once the release
|
||||||
/// view finishes loading.
|
/// view finishes loading.
|
||||||
|
|||||||
+370
-87
@@ -1,7 +1,7 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use super::action::Action;
|
use super::action::Action;
|
||||||
use crate::api::models::TrackItem;
|
use crate::library::models::TrackItem;
|
||||||
|
|
||||||
use super::state::{
|
use super::state::{
|
||||||
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
||||||
@@ -31,11 +31,24 @@ pub enum Effect {
|
|||||||
ToggleLikes {
|
ToggleLikes {
|
||||||
track_ids: Vec<i64>,
|
track_ids: Vec<i64>,
|
||||||
},
|
},
|
||||||
|
/// Remove tracks from a stored playlist in the library.
|
||||||
|
RemoveFromPlaylist {
|
||||||
|
playlist_id: i64,
|
||||||
|
track_ids: Vec<i64>,
|
||||||
|
},
|
||||||
RemoveQueueIndices {
|
RemoveQueueIndices {
|
||||||
indices: Vec<usize>,
|
indices: Vec<usize>,
|
||||||
restart_paused: Option<bool>,
|
restart_paused: Option<bool>,
|
||||||
stop: 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> {
|
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
|
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::Select => select_current(state),
|
||||||
Action::Back if state.track_selection.is_active() => {
|
Action::Back if state.track_selection.is_active() => {
|
||||||
state.track_selection.clear();
|
state.track_selection.clear();
|
||||||
@@ -310,9 +311,243 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Needs the Runtime, so it is intercepted in app::handle_main_key
|
Action::EditSelected => {
|
||||||
// before reaching update().
|
open_edit_popup(state);
|
||||||
Action::Logout => None,
|
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) => {
|
TrackSelectionScope::Playlist(id) => {
|
||||||
if let Some(opened) = &mut state.playlists.opened {
|
if let Some(opened) = &mut state.playlists.opened
|
||||||
if opened.id == *id {
|
&& opened.id == *id {
|
||||||
opened.cursor = value;
|
opened.cursor = value;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
TrackSelectionScope::Queue => {
|
TrackSelectionScope::Queue => {
|
||||||
state.queue_tab.cursor = value;
|
state.queue_tab.cursor = value;
|
||||||
@@ -438,7 +672,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
|||||||
state.queue_tab.cursor,
|
state.queue_tab.cursor,
|
||||||
state.player.queue.len(),
|
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.queue_tab.cursor,
|
||||||
&state.player.queue,
|
&state.player.queue,
|
||||||
)),
|
)),
|
||||||
Tab::Logs => None,
|
Tab::Federation | Tab::Logs => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +887,7 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
|||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).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() {
|
for (offset, track) in tracks.into_iter().enumerate() {
|
||||||
player.queue.insert(insert_at + offset, track);
|
player.queue.insert(insert_at + offset, track);
|
||||||
}
|
}
|
||||||
if let Some(prefetched) = &mut player.prefetched_pos {
|
if let Some(prefetched) = &mut player.prefetched_pos
|
||||||
if insert_at <= *prefetched {
|
&& insert_at <= *prefetched {
|
||||||
*prefetched += count;
|
*prefetched += count;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if insert_at <= player.queue_pos && player.current.is_some() {
|
if insert_at <= player.queue_pos && player.current.is_some() {
|
||||||
player.queue_pos += count;
|
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 tail = player.queue.split_off(start);
|
||||||
let mut used = vec![false; order.len()];
|
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()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(position, track)| {
|
.map(|(position, track)| {
|
||||||
@@ -954,6 +1187,14 @@ fn page_step(state: &AppState) -> isize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn move_selection(state: &mut AppState, dx: isize, dy: 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 {
|
if state.active_tab == Tab::Logs {
|
||||||
// The cursor anchors to an entry's seq, so freshly appended log
|
// The cursor anchors to an entry's seq, so freshly appended log
|
||||||
// lines (including ones caused by this very keypress) don't shift
|
// 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 {
|
if state.active_tab == Tab::Queue {
|
||||||
return state.player.queue.len();
|
return state.player.queue.len();
|
||||||
}
|
}
|
||||||
|
if state.active_tab == Tab::Federation {
|
||||||
|
return super::state::FedRow::ALL.len();
|
||||||
|
}
|
||||||
match state.global.stack.last() {
|
match state.global.stack.last() {
|
||||||
None => state.global.artists.len(),
|
None => state.global.artists.len(),
|
||||||
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
|
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(),
|
Some(Loadable::Ready(d)) => d.tracks.len(),
|
||||||
_ => 0,
|
_ => 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;
|
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 state.active_tab == Tab::Logs {
|
||||||
if first {
|
if first {
|
||||||
let level = super::state::LOG_LEVELS[state.logs.level_index];
|
let level = super::state::LOG_LEVELS[state.logs.level_index];
|
||||||
if let Some(buffer) = crate::config::logging::buffer() {
|
if let Some(buffer) = crate::config::logging::buffer()
|
||||||
if let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
|
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
|
||||||
state.logs.selected_seq = Some(seq);
|
state.logs.selected_seq = Some(seq);
|
||||||
state.logs.follow = false;
|
state.logs.follow = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
state.logs.follow = true;
|
state.logs.follow = true;
|
||||||
state.logs.selected_seq = None;
|
state.logs.selected_seq = None;
|
||||||
@@ -1257,6 +1507,9 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if state.active_tab == Tab::Federation {
|
||||||
|
return federation_select(state);
|
||||||
|
}
|
||||||
// Queue: jump playback to the track under the cursor. Earlier tracks
|
// Queue: jump playback to the track under the cursor. Earlier tracks
|
||||||
// stay in the queue as "played"; picking one of them just moves the
|
// stay in the queue as "played"; picking one of them just moves the
|
||||||
// playing position back.
|
// playing position back.
|
||||||
@@ -1274,9 +1527,10 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
enum Outcome {
|
enum Outcome {
|
||||||
Push(GlobalView),
|
Push(GlobalView),
|
||||||
Play {
|
Play {
|
||||||
tracks: Vec<crate::api::models::TrackItem>,
|
tracks: Vec<crate::library::models::TrackItem>,
|
||||||
start: usize,
|
start: usize,
|
||||||
},
|
},
|
||||||
|
PlayFed(crate::federation::FedTrack),
|
||||||
Nothing,
|
Nothing,
|
||||||
}
|
}
|
||||||
let outcome = match state.global.stack.last().copied() {
|
let outcome = match state.global.stack.last().copied() {
|
||||||
@@ -1347,10 +1601,17 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
start: cursor - artists - releases,
|
start: cursor - artists - releases,
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
match outcome {
|
||||||
@@ -1364,13 +1625,57 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
on_new_queue(state);
|
on_new_queue(state);
|
||||||
Some(Effect::PlayCurrent)
|
Some(Effect::PlayCurrent)
|
||||||
}
|
}
|
||||||
|
Outcome::PlayFed(fed) => {
|
||||||
|
state.status_message = Some(format!("federation: fetching \"{}\"…", fed.title));
|
||||||
|
Some(Effect::FedPlay(fed))
|
||||||
|
}
|
||||||
Outcome::Nothing => None,
|
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,
|
/// A freshly created play context: drop the stale pre-shuffle snapshot and,
|
||||||
/// if shuffle is on, shuffle everything after the chosen track right away.
|
/// 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;
|
let player = &mut state.player;
|
||||||
player.original_order = None;
|
player.original_order = None;
|
||||||
if player.shuffle && !player.queue.is_empty() {
|
if player.shuffle && !player.queue.is_empty() {
|
||||||
@@ -1409,19 +1714,17 @@ fn go_back(state: &mut AppState) {
|
|||||||
Tab::Global => {
|
Tab::Global => {
|
||||||
// Esc on a view opened by Shift-J from another tab goes back to
|
// Esc on a view opened by Shift-J from another tab goes back to
|
||||||
// that tab, not down the Global stack.
|
// that tab, not down the Global stack.
|
||||||
if let Some((origin, depth)) = state.jump_origin {
|
if let Some((origin, depth)) = state.jump_origin
|
||||||
if state.global.stack.len() == depth + 1 {
|
&& state.global.stack.len() == depth + 1 {
|
||||||
state.global.stack.pop();
|
state.global.stack.pop();
|
||||||
state.jump_origin = None;
|
state.jump_origin = None;
|
||||||
state.active_tab = origin;
|
state.active_tab = origin;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
if let Some(popped) = state.global.stack.pop()
|
||||||
if let Some(popped) = state.global.stack.pop() {
|
&& matches!(popped, GlobalView::Search { .. }) {
|
||||||
if matches!(popped, GlobalView::Search { .. }) {
|
|
||||||
state.search = SearchState::default();
|
state.search = SearchState::default();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -1450,6 +1753,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
|
|||||||
state.global.stack.clear();
|
state.global.stack.clear();
|
||||||
}
|
}
|
||||||
Tab::Playlists => state.playlists.opened = None,
|
Tab::Playlists => state.playlists.opened = None,
|
||||||
|
Tab::Federation => state.federation.cursor = 0,
|
||||||
Tab::Logs => {
|
Tab::Logs => {
|
||||||
state.logs.follow = true;
|
state.logs.follow = true;
|
||||||
state.logs.selected_seq = None;
|
state.logs.selected_seq = None;
|
||||||
@@ -1465,7 +1769,7 @@ fn not_yet(state: &mut AppState, what: &str) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::models::{ArtistCard, ArtistDetail, TrackItem};
|
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem};
|
||||||
|
|
||||||
fn with_artists(n: usize) -> AppState {
|
fn with_artists(n: usize) -> AppState {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
@@ -1473,7 +1777,7 @@ mod tests {
|
|||||||
.map(|i| ArtistCard {
|
.map(|i| ArtistCard {
|
||||||
id: i as i64,
|
id: i as i64,
|
||||||
name: format!("artist {i}"),
|
name: format!("artist {i}"),
|
||||||
image_url: None,
|
image_path: None,
|
||||||
release_count: 1,
|
release_count: 1,
|
||||||
track_count: 2,
|
track_count: 2,
|
||||||
})
|
})
|
||||||
@@ -1493,18 +1797,14 @@ mod tests {
|
|||||||
release_id: 1,
|
release_id: 1,
|
||||||
release_title: "r".into(),
|
release_title: "r".into(),
|
||||||
release_year: None,
|
release_year: None,
|
||||||
cover_url: None,
|
cover_path: None,
|
||||||
stream_url: format!("/s/{id}"),
|
file_path: format!("/s/{id}"),
|
||||||
uploader_name: String::new(),
|
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
audio_bit_depth: None,
|
audio_bit_depth: None,
|
||||||
file_size_bytes: None,
|
file_size_bytes: None,
|
||||||
lastfm_listeners: None,
|
play_count: 0,
|
||||||
lastfm_playcount: None,
|
|
||||||
lastfm_rating: None,
|
|
||||||
lastfm_updated_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1619,14 +1919,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn artist_tiles_move_by_visual_rows_across_groups() {
|
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 {
|
let release = |id: i64, kind: &str| ReleaseCard {
|
||||||
id,
|
id,
|
||||||
title: format!("r{id}"),
|
title: format!("r{id}"),
|
||||||
release_type: kind.to_string(),
|
release_type: kind.to_string(),
|
||||||
year: None,
|
year: None,
|
||||||
cover_url: None,
|
cover_path: None,
|
||||||
track_count: 1,
|
track_count: 1,
|
||||||
};
|
};
|
||||||
// columns = 3 in tests (no tty → 80 wide): albums rows [0,1,2],[3],
|
// columns = 3 in tests (no tty → 80 wide): albums rows [0,1,2],[3],
|
||||||
@@ -1634,7 +1934,7 @@ mod tests {
|
|||||||
let detail = ArtistDetail {
|
let detail = ArtistDetail {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "a".into(),
|
name: "a".into(),
|
||||||
image_url: None,
|
image_path: None,
|
||||||
total_track_count: 0,
|
total_track_count: 0,
|
||||||
total_play_count: 0,
|
total_play_count: 0,
|
||||||
top_tracks: vec![],
|
top_tracks: vec![],
|
||||||
@@ -1707,7 +2007,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_advances_and_respects_repeat() {
|
fn queue_advances_and_respects_repeat() {
|
||||||
use crate::api::models::TrackItem;
|
use crate::library::models::TrackItem;
|
||||||
use crate::app::state::RepeatMode;
|
use crate::app::state::RepeatMode;
|
||||||
|
|
||||||
let track = |id: i64| TrackItem {
|
let track = |id: i64| TrackItem {
|
||||||
@@ -1721,18 +2021,14 @@ mod tests {
|
|||||||
release_id: 1,
|
release_id: 1,
|
||||||
release_title: "r".into(),
|
release_title: "r".into(),
|
||||||
release_year: None,
|
release_year: None,
|
||||||
cover_url: None,
|
cover_path: None,
|
||||||
stream_url: format!("/api/player/stream/{id}"),
|
file_path: format!("/api/player/stream/{id}"),
|
||||||
uploader_name: String::new(),
|
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
audio_bit_depth: None,
|
audio_bit_depth: None,
|
||||||
file_size_bytes: None,
|
file_size_bytes: None,
|
||||||
lastfm_listeners: None,
|
play_count: 0,
|
||||||
lastfm_playcount: None,
|
|
||||||
lastfm_rating: None,
|
|
||||||
lastfm_updated_at: None,
|
|
||||||
};
|
};
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
state.player.queue = vec![track(1), track(2)];
|
state.player.queue = vec![track(1), track(2)];
|
||||||
@@ -1776,7 +2072,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_tab_select_and_clear() {
|
fn queue_tab_select_and_clear() {
|
||||||
use crate::api::models::TrackItem;
|
use crate::library::models::TrackItem;
|
||||||
let track = |id: i64| TrackItem {
|
let track = |id: i64| TrackItem {
|
||||||
id,
|
id,
|
||||||
title: format!("t{id}"),
|
title: format!("t{id}"),
|
||||||
@@ -1788,18 +2084,14 @@ mod tests {
|
|||||||
release_id: 1,
|
release_id: 1,
|
||||||
release_title: "r".into(),
|
release_title: "r".into(),
|
||||||
release_year: None,
|
release_year: None,
|
||||||
cover_url: None,
|
cover_path: None,
|
||||||
stream_url: format!("/s/{id}"),
|
file_path: format!("/s/{id}"),
|
||||||
uploader_name: String::new(),
|
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
audio_bit_depth: None,
|
audio_bit_depth: None,
|
||||||
file_size_bytes: None,
|
file_size_bytes: None,
|
||||||
lastfm_listeners: None,
|
play_count: 0,
|
||||||
lastfm_playcount: None,
|
|
||||||
lastfm_rating: None,
|
|
||||||
lastfm_updated_at: None,
|
|
||||||
};
|
};
|
||||||
let mut state = AppState {
|
let mut state = AppState {
|
||||||
active_tab: Tab::Queue,
|
active_tab: Tab::Queue,
|
||||||
@@ -1870,7 +2162,7 @@ mod tests {
|
|||||||
Loadable::Ready(ArtistDetail {
|
Loadable::Ready(ArtistDetail {
|
||||||
id: 9,
|
id: 9,
|
||||||
name: "artist".into(),
|
name: "artist".into(),
|
||||||
image_url: None,
|
image_path: None,
|
||||||
total_track_count: 3,
|
total_track_count: 3,
|
||||||
total_play_count: 0,
|
total_play_count: 0,
|
||||||
top_tracks: (1..=3).map(test_track).collect(),
|
top_tracks: (1..=3).map(test_track).collect(),
|
||||||
@@ -1942,7 +2234,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shuffle_reorders_tail_and_restores() {
|
fn shuffle_reorders_tail_and_restores() {
|
||||||
use crate::api::models::TrackItem;
|
use crate::library::models::TrackItem;
|
||||||
let track = |id: i64| TrackItem {
|
let track = |id: i64| TrackItem {
|
||||||
id,
|
id,
|
||||||
title: format!("t{id}"),
|
title: format!("t{id}"),
|
||||||
@@ -1954,18 +2246,14 @@ mod tests {
|
|||||||
release_id: 1,
|
release_id: 1,
|
||||||
release_title: "r".into(),
|
release_title: "r".into(),
|
||||||
release_year: None,
|
release_year: None,
|
||||||
cover_url: None,
|
cover_path: None,
|
||||||
stream_url: format!("/s/{id}"),
|
file_path: format!("/s/{id}"),
|
||||||
uploader_name: String::new(),
|
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
audio_bit_depth: None,
|
audio_bit_depth: None,
|
||||||
file_size_bytes: None,
|
file_size_bytes: None,
|
||||||
lastfm_listeners: None,
|
play_count: 0,
|
||||||
lastfm_playcount: None,
|
|
||||||
lastfm_rating: None,
|
|
||||||
lastfm_updated_at: None,
|
|
||||||
};
|
};
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
state.player.queue = (1..=8).map(track).collect();
|
state.player.queue = (1..=8).map(track).collect();
|
||||||
@@ -1991,7 +2279,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_j_opens_release_from_queue() {
|
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 {
|
let track = |id: i64, release_id: i64| TrackItem {
|
||||||
id,
|
id,
|
||||||
title: format!("t{id}"),
|
title: format!("t{id}"),
|
||||||
@@ -2003,18 +2291,14 @@ mod tests {
|
|||||||
release_id,
|
release_id,
|
||||||
release_title: "r".into(),
|
release_title: "r".into(),
|
||||||
release_year: None,
|
release_year: None,
|
||||||
cover_url: None,
|
cover_path: None,
|
||||||
stream_url: format!("/s/{id}"),
|
file_path: format!("/s/{id}"),
|
||||||
uploader_name: String::new(),
|
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
audio_bit_depth: None,
|
audio_bit_depth: None,
|
||||||
file_size_bytes: None,
|
file_size_bytes: None,
|
||||||
lastfm_listeners: None,
|
play_count: 0,
|
||||||
lastfm_playcount: None,
|
|
||||||
lastfm_rating: None,
|
|
||||||
lastfm_updated_at: None,
|
|
||||||
};
|
};
|
||||||
let mut state = AppState {
|
let mut state = AppState {
|
||||||
active_tab: Tab::Queue,
|
active_tab: Tab::Queue,
|
||||||
@@ -2048,10 +2332,9 @@ mod tests {
|
|||||||
title: "r".into(),
|
title: "r".into(),
|
||||||
release_type: "album".into(),
|
release_type: "album".into(),
|
||||||
year: None,
|
year: None,
|
||||||
cover_url: None,
|
cover_path: None,
|
||||||
artists: vec![],
|
artists: vec![],
|
||||||
tracks: vec![track(1, 7), track(2, 7)],
|
tracks: vec![track(1, 7), track(2, 7)],
|
||||||
uploaders: vec![],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
state.active_tab = Tab::Queue;
|
state.active_tab = Tab::Queue;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
# command: an Action name, optionally with parameters:
|
# command: an Action name, optionally with parameters:
|
||||||
# command = { SeekForward = { seconds = 30 } }
|
# command = { SeekForward = { seconds = 30 } }
|
||||||
# context: optional view filter — global (default), library, search,
|
# context: optional view filter — global (default), library, search,
|
||||||
# playlists, queue, devices.
|
# playlists, queue, logs.
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "q"
|
key_sequence = "q"
|
||||||
@@ -47,6 +47,10 @@ command = { GoToTab = 2 }
|
|||||||
key_sequence = "4"
|
key_sequence = "4"
|
||||||
command = { GoToTab = 3 }
|
command = { GoToTab = 3 }
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "5"
|
||||||
|
command = { GoToTab = 4 }
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "a"
|
key_sequence = "a"
|
||||||
command = "QueueAddNext"
|
command = "QueueAddNext"
|
||||||
@@ -204,12 +208,12 @@ key_sequence = "i"
|
|||||||
command = "OpenTrackInfo"
|
command = "OpenTrackInfo"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "shift-l"
|
key_sequence = "e"
|
||||||
command = "Logout"
|
command = "EditSelected"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "shift-d"
|
key_sequence = "shift-d"
|
||||||
command = "OpenDevices"
|
command = "DeleteSelected"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "v"
|
key_sequence = "v"
|
||||||
|
|||||||
+8
-11
@@ -20,7 +20,7 @@ pub enum KeyContext {
|
|||||||
Search,
|
Search,
|
||||||
Playlists,
|
Playlists,
|
||||||
Queue,
|
Queue,
|
||||||
Devices,
|
Federation,
|
||||||
Logs,
|
Logs,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ impl KeyContext {
|
|||||||
KeyContext::Search => "search",
|
KeyContext::Search => "search",
|
||||||
KeyContext::Playlists => "playlists",
|
KeyContext::Playlists => "playlists",
|
||||||
KeyContext::Queue => "queue",
|
KeyContext::Queue => "queue",
|
||||||
KeyContext::Devices => "devices",
|
KeyContext::Federation => "federation",
|
||||||
KeyContext::Logs => "logs",
|
KeyContext::Logs => "logs",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,8 +81,8 @@ impl Keymap {
|
|||||||
let mut bindings =
|
let mut bindings =
|
||||||
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
|
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
|
||||||
let mut warning = None;
|
let mut warning = None;
|
||||||
if let Some(path) = user_keymap_path() {
|
if let Some(path) = user_keymap_path()
|
||||||
if path.exists() {
|
&& path.exists() {
|
||||||
match fs::read_to_string(&path)
|
match fs::read_to_string(&path)
|
||||||
.map_err(anyhow::Error::from)
|
.map_err(anyhow::Error::from)
|
||||||
.and_then(|text| parse_bindings(&text))
|
.and_then(|text| parse_bindings(&text))
|
||||||
@@ -96,7 +96,6 @@ impl Keymap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let keymap = Self {
|
let keymap = Self {
|
||||||
bindings,
|
bindings,
|
||||||
pending: Vec::new(),
|
pending: Vec::new(),
|
||||||
@@ -219,11 +218,10 @@ enum Lookup {
|
|||||||
/// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works);
|
/// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works);
|
||||||
/// symbols drop it so a "?" binding matches everywhere.
|
/// symbols drop it so a "?" binding matches everywhere.
|
||||||
fn normalize(key: KeyCombination) -> KeyCombination {
|
fn normalize(key: KeyCombination) -> KeyCombination {
|
||||||
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes {
|
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes
|
||||||
if !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
|
&& !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||||
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
|
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
key
|
key
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,8 +301,8 @@ fn parse_chord(chord: &str) -> Result<KeyCombination> {
|
|||||||
// crokey only parses single-byte characters; non-ASCII keys (Cyrillic
|
// crokey only parses single-byte characters; non-ASCII keys (Cyrillic
|
||||||
// bindings) are built directly.
|
// bindings) are built directly.
|
||||||
let mut chars = chord.chars();
|
let mut chars = chord.chars();
|
||||||
if let (Some(c), None) = (chars.next(), chars.next()) {
|
if let (Some(c), None) = (chars.next(), chars.next())
|
||||||
if !c.is_ascii() {
|
&& !c.is_ascii() {
|
||||||
let modifiers = if c.is_uppercase() {
|
let modifiers = if c.is_uppercase() {
|
||||||
KeyModifiers::SHIFT
|
KeyModifiers::SHIFT
|
||||||
} else {
|
} else {
|
||||||
@@ -312,7 +310,6 @@ fn parse_chord(chord: &str) -> Result<KeyCombination> {
|
|||||||
};
|
};
|
||||||
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
|
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
KeyCombination::from_str(chord)
|
KeyCombination::from_str(chord)
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||||
.map(normalize)
|
.map(normalize)
|
||||||
|
|||||||
@@ -2,54 +2,7 @@ pub mod keymap;
|
|||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
pub fn project_dirs() -> Option<ProjectDirs> {
|
pub fn project_dirs() -> Option<ProjectDirs> {
|
||||||
ProjectDirs::from("", "", "furumi")
|
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 app;
|
||||||
mod art;
|
mod art;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod federation;
|
||||||
|
mod library;
|
||||||
mod media;
|
mod media;
|
||||||
mod player;
|
mod player;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|||||||
+4
-6
@@ -10,10 +10,9 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
|
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
|
/// Perceptual volume: cubic mapping from percent to linear amplitude, so
|
||||||
/// equal percent steps sound like equal loudness steps and low percentages
|
/// equal percent steps sound like equal loudness steps and low percentages
|
||||||
@@ -258,11 +257,10 @@ fn handle(
|
|||||||
*track_loaded = false;
|
*track_loaded = false;
|
||||||
}
|
}
|
||||||
Command::Seek(position) => {
|
Command::Seek(position) => {
|
||||||
if let Some(out) = output {
|
if let Some(out) = output
|
||||||
if let Err(err) = out.player.try_seek(position) {
|
&& let Err(err) = out.player.try_seek(position) {
|
||||||
tracing::warn!(%err, "seek failed");
|
tracing::warn!(%err, "seek failed");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Command::SetVolume(volume) => {
|
Command::SetVolume(volume) => {
|
||||||
if let Some(out) = output {
|
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 unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use super::{art, theme};
|
use super::{art, theme};
|
||||||
use crate::api::models::{ArtistCard, ReleaseCard};
|
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
|
||||||
use crate::app::state::{
|
use crate::app::state::{
|
||||||
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
|
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
|
||||||
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups,
|
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(
|
draw_tile(
|
||||||
frame,
|
frame,
|
||||||
tile,
|
tile,
|
||||||
tile_art(state, artist.image_url.as_ref()),
|
tile_art(state, artist.image_path.as_ref()),
|
||||||
&artist.name,
|
&artist.name,
|
||||||
&artist_tile_meta(artist),
|
&artist_tile_meta(artist),
|
||||||
index == global.selected,
|
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),
|
height: ART_HEADER_HEIGHT.min(art_area.height),
|
||||||
..art_area
|
..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());
|
let mut about = format!("{} releases", detail.releases.len());
|
||||||
if !detail.featured_tracks.is_empty() {
|
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(
|
draw_tile(
|
||||||
frame,
|
frame,
|
||||||
tile,
|
tile,
|
||||||
tile_art(state, release.cover_url.as_ref()),
|
tile_art(state, release.cover_path.as_ref()),
|
||||||
&release.title,
|
&release.title,
|
||||||
&release_tile_meta(release),
|
&release_tile_meta(release),
|
||||||
cursor == tracks + position,
|
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),
|
height: ART_HEADER_HEIGHT.min(art_area.height),
|
||||||
..art_area
|
..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 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 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 info = vec![
|
||||||
let mut info = vec![
|
|
||||||
Line::default(),
|
Line::default(),
|
||||||
Line::styled(detail.title.clone(), theme::header()),
|
Line::styled(detail.title.clone(), theme::header()),
|
||||||
Line::raw(artists.join(", ")),
|
Line::raw(artists.join(", ")),
|
||||||
@@ -668,12 +667,6 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
|||||||
theme::dim(),
|
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);
|
frame.render_widget(Paragraph::new(info), info_area);
|
||||||
|
|
||||||
// Track list with centered scrolling.
|
// 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 inner = bordered(frame, area, title);
|
||||||
|
|
||||||
let Some(results) = &search.results else {
|
let empty_results = SearchResults::default();
|
||||||
let hint = if search.query.is_empty() {
|
let results = match &search.results {
|
||||||
"type to search artists, releases and tracks"
|
Some(results) => results,
|
||||||
} else {
|
None if !state.search.fed_tracks.is_empty() || state.search.fed_loading => &empty_results,
|
||||||
"searching…"
|
None => {
|
||||||
};
|
let hint = if search.query.is_empty() {
|
||||||
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
|
"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()));
|
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;
|
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
|
let cursor_row = rows
|
||||||
.iter()
|
.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;
|
pub mod art;
|
||||||
|
mod federation;
|
||||||
mod global;
|
mod global;
|
||||||
mod login;
|
|
||||||
mod logs;
|
mod logs;
|
||||||
mod playlists;
|
mod playlists;
|
||||||
mod popup;
|
mod popup;
|
||||||
@@ -12,14 +12,10 @@ use ratatui::style::{Color, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
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;
|
use crate::config::keymap::Keymap;
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, state: &AppState, 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([
|
let [tabs_area, main_area, status_area] = Layout::vertical([
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Min(0),
|
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::Global => global::draw(frame, main_area, state),
|
||||||
Tab::Playlists => playlists::draw(frame, main_area, state),
|
Tab::Playlists => playlists::draw(frame, main_area, state),
|
||||||
Tab::Queue => draw_queue(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),
|
Tab::Logs => logs::draw(frame, main_area, state),
|
||||||
}
|
}
|
||||||
draw_status(frame, status_area, state);
|
draw_status(frame, status_area, state);
|
||||||
@@ -60,7 +57,7 @@ pub(crate) fn track_row(
|
|||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
track: &crate::api::models::TrackItem,
|
track: &crate::library::models::TrackItem,
|
||||||
index_label: String,
|
index_label: String,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
visual_selected: bool,
|
visual_selected: bool,
|
||||||
@@ -92,7 +89,7 @@ pub(crate) fn track_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn track_meta_suffix(
|
pub(crate) fn track_meta_suffix(
|
||||||
track: &crate::api::models::TrackItem,
|
track: &crate::library::models::TrackItem,
|
||||||
include_tech: bool,
|
include_tech: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
let has_tech = track.audio_format.is_some()
|
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.
|
/// 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> {
|
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
|
||||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||||
if let Some(track) = &player.current {
|
if let Some(track) = &player.current
|
||||||
if player.playing {
|
&& player.playing {
|
||||||
let bar_width: usize = match width {
|
let bar_width: usize = match width {
|
||||||
0..=59 => 0,
|
0..=59 => 0,
|
||||||
60..=79 => 8,
|
60..=79 => 8,
|
||||||
@@ -227,7 +224,6 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if width >= 80 {
|
if width >= 80 {
|
||||||
let volume_cells = usize::from(player.volume / 10);
|
let volume_cells = usize::from(player.volume / 10);
|
||||||
spans.extend([
|
spans.extend([
|
||||||
@@ -260,65 +256,21 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
|
|||||||
Line::from(spans)
|
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) {
|
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let [player_row, message_row] =
|
let [player_row, message_row] =
|
||||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
||||||
|
|
||||||
let player = &state.player;
|
let player = &state.player;
|
||||||
// Layout: track title left, time/progress/flags centered, user right.
|
// Layout: track title left, time/progress/flags on the right. The
|
||||||
// The center block is built first and gets a fixed width; the title
|
// right block is built first and gets a fixed width; the title
|
||||||
// truncates into whatever is left.
|
// truncates into whatever is left.
|
||||||
let center = player_right_line(player, area.width);
|
let center = player_right_line(player, area.width);
|
||||||
let center_width = (center.width() as u16).min(area.width);
|
let center_width = (center.width() as u16).min(area.width);
|
||||||
let device_line = device_status_line(state);
|
let [title_area, right_area] = Layout::horizontal([
|
||||||
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([
|
|
||||||
Constraint::Min(8),
|
Constraint::Min(8),
|
||||||
Constraint::Length(center_width),
|
Constraint::Length(center_width),
|
||||||
Constraint::Length(device_width.saturating_add(2)),
|
|
||||||
Constraint::Length(user_width),
|
|
||||||
])
|
])
|
||||||
.areas(player_row);
|
.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();
|
let mut spans = Vec::new();
|
||||||
match &player.current {
|
match &player.current {
|
||||||
|
|||||||
+1
-19
@@ -78,26 +78,8 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
} else {
|
} else {
|
||||||
Span::raw(" ")
|
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(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(Line::from(vec![marker, Span::raw(playlist.title.clone())])),
|
||||||
marker,
|
|
||||||
Span::raw(playlist.title.clone()),
|
|
||||||
Span::styled(suffix, theme::dim()),
|
|
||||||
])),
|
|
||||||
row,
|
row,
|
||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
|
|||||||
+130
-86
@@ -4,8 +4,8 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
||||||
|
|
||||||
use super::theme;
|
use super::theme;
|
||||||
use crate::api::models::{ArtistRef, TrackItem};
|
use crate::app::state::{AppState, EditField, Loadable, Popup, addable_playlists};
|
||||||
use crate::app::state::{AppState, Loadable, Popup, addable_playlists};
|
use crate::library::models::{ArtistRef, TrackItem};
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, state: &AppState) {
|
pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||||
match state.popup.as_ref() {
|
match state.popup.as_ref() {
|
||||||
@@ -13,102 +13,156 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
draw_picker(frame, state, &track.title, *cursor)
|
draw_picker(frame, state, &track.title, *cursor)
|
||||||
}
|
}
|
||||||
Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy),
|
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 {
|
Some(Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
cursor,
|
cursor,
|
||||||
scroll,
|
scroll,
|
||||||
}) => draw_track_info(frame, tracks, *cursor, *scroll),
|
}) => draw_track_info(frame, tracks, *cursor, *scroll),
|
||||||
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
|
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 => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||||
let rows = state.devices.devices.len().max(1);
|
fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) {
|
||||||
let height = (rows as u16 + 4)
|
let area = centered(frame.area(), 64, 5);
|
||||||
.min(frame.area().height.saturating_sub(2))
|
|
||||||
.max(7);
|
|
||||||
let area = centered(frame.area(), 64, height);
|
|
||||||
let block = Block::bordered()
|
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())
|
.title_style(theme::header())
|
||||||
.border_style(theme::accent());
|
.border_style(theme::accent());
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let [list_area, _, footer] = Layout::vertical([
|
let mut constraints: Vec<Constraint> = fields.iter().map(|_| Constraint::Length(3)).collect();
|
||||||
Constraint::Min(1),
|
constraints.push(Constraint::Min(0));
|
||||||
Constraint::Length(1),
|
constraints.push(Constraint::Length(1));
|
||||||
Constraint::Length(1),
|
let areas = Layout::vertical(constraints).split(inner);
|
||||||
])
|
|
||||||
.areas(inner);
|
|
||||||
|
|
||||||
if state.devices.devices.is_empty() {
|
for (index, field) in fields.iter().enumerate() {
|
||||||
frame.render_widget(
|
let focused = index == focus;
|
||||||
Paragraph::new(Line::styled("waiting for device poll…", theme::dim()))
|
let field_block = Block::bordered().title(field.label).border_style(if focused {
|
||||||
.alignment(Alignment::Center),
|
theme::accent()
|
||||||
list_area,
|
} else {
|
||||||
);
|
theme::dim()
|
||||||
} else {
|
});
|
||||||
let visible = usize::from(list_area.height.max(1));
|
let field_inner = field_block.inner(areas[index]);
|
||||||
let cursor = cursor.min(state.devices.devices.len() - 1);
|
frame.render_widget(field_block, areas[index]);
|
||||||
let first = cursor
|
let width = usize::from(field_inner.width.saturating_sub(1));
|
||||||
.saturating_sub(visible / 2)
|
let mut shown: String = field
|
||||||
.min(state.devices.devices.len().saturating_sub(visible));
|
.value
|
||||||
for (index, device) in state
|
.chars()
|
||||||
.devices
|
.skip(field.value.chars().count().saturating_sub(width))
|
||||||
.devices
|
.collect();
|
||||||
.iter()
|
if focused {
|
||||||
.enumerate()
|
shown.push('█');
|
||||||
.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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
frame.render_widget(Paragraph::new(shown), field_inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hint = if let Some(error) = &state.devices.poll_error {
|
let footer = areas[areas.len() - 1];
|
||||||
Line::styled(format!("sync error: {error}"), theme::dim())
|
let hint = match error {
|
||||||
} else {
|
Some(error) => Line::styled(error.to_string(), theme::accent()),
|
||||||
Line::styled("enter make active · esc close", theme::dim())
|
None => Line::styled("tab/↑↓ field · enter save · esc cancel", theme::dim()),
|
||||||
};
|
};
|
||||||
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer);
|
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) {
|
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 width = 90.min(frame.area().width.saturating_sub(4)).max(40);
|
||||||
let height = 18.min(frame.area().height.saturating_sub(2)).max(7);
|
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
|
track.duration_seconds
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
field("Uploader", empty_dash(&track.uploader_name)),
|
|
||||||
field("Audio format", opt_string(track.audio_format.clone())),
|
field("Audio format", opt_string(track.audio_format.clone())),
|
||||||
field(
|
field(
|
||||||
"Bitrate",
|
"Bitrate",
|
||||||
@@ -220,18 +273,9 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
|||||||
opt_map(track.audio_bit_depth, |v| format!("{v} bit")),
|
opt_map(track.audio_bit_depth, |v| format!("{v} bit")),
|
||||||
),
|
),
|
||||||
field("File size", file_size(track.file_size_bytes)),
|
field("File size", file_size(track.file_size_bytes)),
|
||||||
field("Last.fm listeners", opt_display(track.lastfm_listeners)),
|
field("Plays", track.play_count.to_string()),
|
||||||
field("Last.fm plays", opt_display(track.lastfm_playcount)),
|
field("File path", empty_dash(&track.file_path)),
|
||||||
field(
|
field("Cover path", opt_string(track.cover_path.clone())),
|
||||||
"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())),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user