init federation

This commit is contained in:
Ultradesu
2026-07-16 20:29:51 +03:00
parent 98525718d0
commit b1f8f4cb01
33 changed files with 6417 additions and 4019 deletions
+3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -9,22 +9,23 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.102"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
crokey = "1.4.0"
crossterm = { version = "0.29.0", features = ["event-stream"] }
directories = "6.0.0"
futures-util = "0.3.32"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
open = "5.3.5"
lofty = "0.22"
# P2P federation: library index in a shared DHT + audio streaming between
# peers (same protocol as furumi-fd).
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
ratatui = "0.30.1"
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
rusqlite = { version = "0.39", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
stream-download = { version = "0.24.1", default-features = false, features = ["reqwest-rustls", "temp-storage"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] }
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
toml = "1.1.2"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
-281
View File
@@ -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());
}
}
-778
View File
@@ -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)
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod auth;
pub mod client;
pub mod models;
-314
View File
@@ -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
View File
@@ -39,10 +39,10 @@ pub enum Action {
NewPlaylist,
ToggleHelp,
ToggleViewMode,
OpenDevices,
OpenCommandLine,
OpenSearch,
Logout,
EditSelected,
DeleteSelected,
}
/// Help-window sections, in display order.
@@ -50,15 +50,17 @@ pub enum Action {
pub enum Category {
Playback,
Queue,
Library,
Navigation,
Search,
System,
}
impl Category {
pub const ALL: [Category; 5] = [
pub const ALL: [Category; 6] = [
Category::Playback,
Category::Queue,
Category::Library,
Category::Navigation,
Category::Search,
Category::System,
@@ -68,6 +70,7 @@ impl Category {
match self {
Category::Playback => "Playback",
Category::Queue => "Queue & playlists",
Category::Library => "Library",
Category::Navigation => "Navigation",
Category::Search => "Search & commands",
Category::System => "System",
@@ -111,9 +114,9 @@ impl Action {
| Action::GoToTab(_)
| Action::GoToRelease
| Action::ToggleViewMode => Category::Navigation,
Action::EditSelected | Action::DeleteSelected => Category::Library,
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
Action::OpenDevices => Category::System,
Action::ToggleHelp | Action::Logout | Action::Quit => Category::System,
Action::ToggleHelp | Action::Quit => Category::System,
}
}
@@ -121,7 +124,7 @@ impl Action {
pub fn command_hint(&self) -> Option<&'static str> {
match self {
Action::Quit => Some(":q"),
Action::Logout => Some(":logout"),
Action::EditSelected => Some(":import <path> adds files"),
Action::PlayPause => Some(":play"),
Action::NextTrack => Some(":next"),
Action::PrevTrack => Some(":prev"),
@@ -130,7 +133,6 @@ impl Action {
Action::ToggleShuffle => Some(":shuffle"),
Action::CycleRepeat => Some(":repeat [off|one|all]"),
Action::ClearQueue => Some(":clear"),
Action::OpenDevices => Some(":devices"),
Action::ToggleHelp => Some(":help"),
Action::OpenSearch => Some("/text"),
_ => None,
@@ -174,10 +176,10 @@ impl Action {
Action::NewPlaylist => "Create a playlist".into(),
Action::ToggleHelp => "Show / hide keybindings".into(),
Action::ToggleViewMode => "Toggle tiles / table view".into(),
Action::OpenDevices => "Connected devices".into(),
Action::OpenCommandLine => "Command line (:help for commands)".into(),
Action::OpenSearch => "Search artists, releases, tracks".into(),
Action::Logout => "Sign out".into(),
Action::EditSelected => "Edit the selected item".into(),
Action::DeleteSelected => "Delete the selected item".into(),
}
}
}
+31 -21
View File
@@ -4,7 +4,6 @@ use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::client::ApiError;
use crate::app::Runtime;
use crate::app::command::{self, Command, Parsed};
use crate::app::event::AppEvent;
@@ -59,7 +58,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
match command {
// One-shot commands have no live effect.
Command::Quit
| Command::Logout
| Command::Import(_)
| Command::Volume(_)
| Command::Seek(_)
| Command::SeekTo(_)
@@ -70,7 +69,6 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
| Command::Prev
| Command::PlayPause
| Command::Help
| Command::Devices
| Command::Logs(_) => {}
Command::Search(query) => {
state.active_tab = Tab::Global;
@@ -96,17 +94,17 @@ fn set_view_cursor_zero(state: &mut AppState) {
/// Debounced, race-free search: every edit bumps the global sequence; the
/// spawned task only queries if it is still the latest after the debounce,
/// and the receiver drops responses that arrive out of date.
fn schedule_search(state: &mut AppState, runtime: &Runtime) {
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
let query = state.search.query.clone();
if query.is_empty() {
state.search.loading = false;
state.search.results = None;
state.search.fed_tracks.clear();
state.search.fed_loading = false;
return;
}
let Some(api) = runtime.api.clone() else {
return;
};
let library = Arc::clone(&runtime.library);
state.search.loading = true;
let tx = runtime.event_tx.clone();
let latest = Arc::clone(&runtime.search_seq);
@@ -115,19 +113,32 @@ fn schedule_search(state: &mut AppState, runtime: &Runtime) {
if latest.load(Ordering::SeqCst) != seq {
return;
}
let event = match api.search(&query, SEARCH_LIMIT).await {
Ok(results) => AppEvent::SearchLoaded {
seq,
result: Ok(results),
},
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::SearchLoaded {
seq,
result: Err(err.to_string()),
},
};
let _ = tx.send(event);
let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT))
.await
.map_err(|err| err.to_string())
.and_then(|result| result.map_err(|err| format!("{err:#}")));
let _ = tx.send(AppEvent::SearchLoaded { seq, result });
});
// The same query also runs against the federated network (when the
// node is up); its results render as a separate, marked section.
state.search.fed_tracks.clear();
state.search.fed_loading = false;
if runtime.federation.settings().enabled {
state.search.fed_loading = true;
let fed = Arc::clone(&runtime.federation);
let query = state.search.query.clone();
let tx = runtime.event_tx.clone();
let latest = Arc::clone(&runtime.search_seq);
tokio::spawn(async move {
tokio::time::sleep(SEARCH_DEBOUNCE).await;
if latest.load(Ordering::SeqCst) != seq {
return;
}
let result = fed.search(&query).await.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::FedSearchLoaded { seq, result });
});
}
}
/// Enter: close the line. Live commands already took effect (their view
@@ -161,7 +172,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
match command {
Command::Search(_) => {}
Command::Quit => state.should_quit = true,
Command::Logout => super::perform_logout(state, runtime),
Command::Import(path) => super::spawn_import(state, runtime, &path),
Command::Volume(value) => {
state.player.volume = value;
super::perform_effect(state, runtime, Effect::SetVolume(value));
@@ -194,7 +205,6 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
Command::Prev => run_action(state, runtime, Action::PrevTrack),
Command::PlayPause => run_action(state, runtime, Action::PlayPause),
Command::Help => state.help_visible = true,
Command::Devices => run_action(state, runtime, Action::OpenDevices),
Command::Logs(level) => {
if let Some(index) = level {
state.logs.level_index = index.min(LOG_LEVELS.len() - 1);
+17 -8
View File
@@ -22,8 +22,9 @@ pub enum Command {
/// `:q` / `:quit` — exit immediately (explicit enough to skip the
/// double-press confirmation).
Quit,
/// `:logout` — sign out and return to the login screen.
Logout,
/// `:import <path>` — import an audio file or a directory into the
/// library.
Import(String),
/// `:volume 40` (also `:vol`) — set the volume precisely.
Volume(u8),
/// `:seek +30` / `:seek -10` — relative seek in seconds.
@@ -43,8 +44,6 @@ pub enum Command {
PlayPause,
/// `:help` — open the keybinding help.
Help,
/// `:devices` — open the connected-devices picker.
Devices,
/// `:logs [error|warn|info|debug|trace]` — jump to the Logs tab,
/// optionally setting the severity filter.
Logs(Option<usize>),
@@ -74,7 +73,15 @@ pub fn parse(input: &str) -> Parsed {
let arg = parts.next();
match name {
"q" | "quit" => Parsed::Command(Command::Quit),
"logout" => Parsed::Command(Command::Logout),
"import" | "add" => {
let path = input.trim_start().split_once(char::is_whitespace);
match path.map(|(_, rest)| rest.trim()) {
Some(path) if !path.is_empty() => {
Parsed::Command(Command::Import(path.to_string()))
}
_ => Parsed::Invalid("usage: :import <file or directory>".to_string()),
}
}
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
@@ -96,7 +103,6 @@ pub fn parse(input: &str) -> Parsed {
"prev" => Parsed::Command(Command::Prev),
"pause" | "play" => Parsed::Command(Command::PlayPause),
"help" => Parsed::Command(Command::Help),
"devices" | "device" => Parsed::Command(Command::Devices),
"logs" => match arg {
None => Parsed::Command(Command::Logs(None)),
Some(level) => match ["error", "warn", "info", "debug", "trace"]
@@ -152,7 +158,11 @@ mod tests {
fn parses_word_commands() {
assert_eq!(parse("q"), Parsed::Command(Command::Quit));
assert_eq!(parse("quit"), Parsed::Command(Command::Quit));
assert_eq!(parse("logout"), Parsed::Command(Command::Logout));
assert_eq!(
parse("import ~/Music/My Album"),
Parsed::Command(Command::Import("~/Music/My Album".to_string()))
);
assert!(matches!(parse("import"), Parsed::Invalid(_)));
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
@@ -162,7 +172,6 @@ mod tests {
Parsed::Command(Command::Repeat(Some(RepeatArg::All)))
);
assert_eq!(parse("clear"), Parsed::Command(Command::ClearQueue));
assert_eq!(parse("devices"), Parsed::Command(Command::Devices));
assert_eq!(parse("logs debug"), Parsed::Command(Command::Logs(Some(3))));
}
+44 -21
View File
@@ -1,25 +1,24 @@
use std::sync::Arc;
use crate::api::auth::AuthSession;
use crate::api::models::{
ArtistDetail, ArtistsPage, DevicePollResponse, PlaylistCard, PlaylistDetail, ReleaseDetail,
SearchResults, TrackItem,
};
use crate::art::ArtImage;
use crate::library::models::{
ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults,
TrackItem,
};
/// Events delivered to the main loop by background tasks (API fetches, the
/// playback engine, device sync). Tasks never touch AppState directly.
/// Events delivered to the main loop by background tasks (library queries,
/// the playback engine, imports). Tasks never touch AppState directly.
#[derive(Debug)]
pub enum AppEvent {
StatusMessage(String),
LoginSucceeded(Box<AuthSession>),
LoginFailed(String),
/// Loopback listener received the browser SSO callback.
SsoCallback(Result<String, String>),
/// Refresh token rejected — stored credentials were deleted.
SessionExpired,
/// A page of the Global artists list arrived (or failed).
/// A page of the artists list arrived (or failed).
ArtistsLoaded(Result<ArtistsPage, String>),
/// A full reload after a library change: replaces the loaded artist
/// list wholesale, so the grid never flashes empty.
ArtistsReloaded {
page: ArtistsPage,
limit: i64,
},
ArtistViewLoaded {
id: i64,
result: Result<ArtistDetail, String>,
@@ -33,7 +32,7 @@ pub enum AppEvent {
seq: u64,
result: Result<SearchResults, String>,
},
/// Artwork fetched and decoded for the shared art cache.
/// Artwork loaded and decoded for the shared art cache.
ArtLoaded {
key: String,
art: Option<Arc<ArtImage>>,
@@ -41,7 +40,7 @@ pub enum AppEvent {
Player(crate::player::PlayerEvent),
/// A command from the OS media keys.
Media(crate::media::MediaCommand),
/// Gapless prefetch could not open the stream; the normal track-switch
/// Gapless prefetch could not open the file; the normal track-switch
/// path takes over when the current track ends.
PrefetchFailed {
pos: usize,
@@ -57,11 +56,6 @@ pub enum AppEvent {
track_id: i64,
liked: bool,
},
/// Connected-devices poll result; carries device list, active id,
/// remote playback state and commands for this TUI.
DevicesPolled(Result<DevicePollResponse, String>),
/// Response from switching the active device.
DeviceActivated(Result<DevicePollResponse, String>),
/// A release fetched for queueing (a / shift-a on a release).
EnqueueTracks {
tracks: Vec<TrackItem>,
@@ -77,4 +71,33 @@ pub enum AppEvent {
playlist_title: String,
result: Result<(), String>,
},
/// The library was mutated (import, edit, delete): cached views must be
/// dropped and reloaded lazily.
LibraryChanged {
message: Option<String>,
},
/// Progress of a running import, shown in the status bar.
ImportProgress {
done: usize,
total: usize,
current: String,
},
/// Fresh copies of the queued tracks after a library change. Tracks
/// missing from the result were deleted and leave the queue.
QueueTracksRefreshed {
tracks: Vec<TrackItem>,
},
/// A status snapshot for the Federation tab.
FederationStatus(crate::federation::FedStatus),
/// Tracks found on the federated network for the live search.
FedSearchLoaded {
seq: u64,
result: Result<Vec<crate::federation::FedTrack>, String>,
},
/// A federated track finished downloading and is ready to play.
FedPlayReady {
result: Result<crate::federation::FedPlayable, String>,
},
/// This peer's connection ticket, requested from the Federation tab.
FedTicket(Result<String, String>),
}
-221
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+285 -74
View File
@@ -1,13 +1,18 @@
//! Modal dialog input: the add-to-playlist picker and new-playlist name
//! entry. The popup is taken out of the state, handled as an owned value
//! and put back unless the action closed it.
//! Modal dialog input: the add-to-playlist picker, new-playlist name entry,
//! metadata edit forms and delete confirmations. The popup is taken out of
//! the state, handled as an owned value and put back unless the action
//! closed it.
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::models::TrackItem;
use crate::app::Runtime;
use crate::app::event::AppEvent;
use crate::app::state::{AppState, Popup, addable_playlists};
use crate::app::state::{
AppState, DeleteTarget, EditField, EditTarget, FedInputField, Popup, addable_playlists,
};
use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem};
pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
let Some(popup) = state.popup.take() else {
@@ -22,7 +27,16 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
input,
busy,
} => handle_name_entry(state, runtime, for_track, input, busy, key),
Popup::Devices { cursor } => handle_devices(state, runtime, cursor, key),
Popup::Edit {
target,
title,
fields,
focus,
error,
} => handle_edit(state, runtime, target, title, fields, focus, error, key),
Popup::ConfirmDelete { target, label } => {
handle_confirm_delete(state, runtime, target, label, key);
}
Popup::TrackInfo {
tracks,
cursor,
@@ -32,54 +46,269 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
_ => state.popup = Some(Popup::LogDetail(entry)),
},
Popup::FedInput { field, input } => handle_fed_input(state, runtime, field, input, key),
Popup::FedText { title, text } => match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
_ => state.popup = Some(Popup::FedText { title, text }),
},
}
}
/// Pasted text goes into the name field when it is open.
pub fn handle_paste(state: &mut AppState, pasted: &str) {
if let Some(Popup::NewPlaylist { input, busy, .. }) = &mut state.popup {
if !*busy {
input.extend(pasted.chars().filter(|c| !c.is_control()));
}
}
}
fn handle_devices(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) {
let len = state.devices.devices.len();
/// One-line text entry on the Federation tab (network id / peer ticket).
fn handle_fed_input(
state: &mut AppState,
runtime: &Runtime,
field: FedInputField,
mut input: String,
key: KeyEvent,
) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {}
KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::Devices {
cursor: cursor.saturating_sub(1),
});
}
KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::Devices {
cursor: if len == 0 {
0
} else {
(cursor + 1).min(len - 1)
},
});
}
KeyCode::Esc => {}
KeyCode::Enter => {
if let Some(device) = state.devices.devices.get(cursor.min(len.saturating_sub(1))) {
let target = device.id.clone();
state.devices.switching_to = Some(target.clone());
state.popup = Some(Popup::Devices { cursor });
spawn_select_device(runtime, target);
} else {
state.popup = Some(Popup::Devices { cursor: 0 });
let value = input.trim().to_string();
match field {
FedInputField::NetworkId => {
state.federation.settings.network_id = value;
// An empty id turns federation off rather than leaving a
// node bound to an unnamed network; a freshly set id
// enables it right away.
state.federation.settings.enabled =
!state.federation.settings.network_id.is_empty();
super::fed_apply_settings(state, runtime);
}
FedInputField::ConnectTicket => {
if value.is_empty() {
state.status_message = Some("ticket is empty".into());
} else {
super::fed_connect(runtime, value);
}
}
}
}
_ => {
state.popup = Some(Popup::Devices {
cursor: cursor.min(len.saturating_sub(1)),
})
KeyCode::Backspace => {
input.pop();
state.popup = Some(Popup::FedInput { field, input });
}
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
input.push(c);
state.popup = Some(Popup::FedInput { field, input });
}
_ => state.popup = Some(Popup::FedInput { field, input }),
}
}
/// Pasted text goes into the focused text field when one is open.
pub fn handle_paste(state: &mut AppState, pasted: &str) {
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
match &mut state.popup {
Some(Popup::NewPlaylist { input, busy, .. }) if !*busy => input.push_str(&cleaned),
Some(Popup::FedInput { input, .. }) => input.push_str(&cleaned),
Some(Popup::Edit { fields, focus, .. }) => {
if let Some(field) = fields.get_mut(*focus) {
field.value.push_str(&cleaned);
}
}
_ => {}
}
}
// ---------------------------------------------------------------------------
// Edit form
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments, reason = "owned popup state passed back in")]
fn handle_edit(
state: &mut AppState,
runtime: &Runtime,
target: EditTarget,
title: String,
mut fields: Vec<EditField>,
mut focus: usize,
error: Option<String>,
key: KeyEvent,
) {
match key.code {
KeyCode::Esc => return,
KeyCode::Enter => {
match save_edit(runtime, target, &fields) {
Ok(message) => {
state.status_message = Some(message);
return;
}
Err(message) => {
state.popup = Some(Popup::Edit {
target,
title,
fields,
focus,
error: Some(message),
});
return;
}
};
}
KeyCode::Tab | KeyCode::Down => focus = (focus + 1) % fields.len().max(1),
KeyCode::BackTab | KeyCode::Up => {
let len = fields.len().max(1);
focus = (focus + len - 1) % len;
}
KeyCode::Backspace => {
if let Some(field) = fields.get_mut(focus) {
field.value.pop();
}
}
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
if let Some(field) = fields.get_mut(focus) {
field.value.push(c);
}
}
_ => {}
}
state.popup = Some(Popup::Edit {
target,
title,
fields,
focus,
error,
});
}
/// Validate the form and write it to the library. Returns the status
/// message on success, the error text to show in the form otherwise.
fn save_edit(
runtime: &Runtime,
target: EditTarget,
fields: &[EditField],
) -> Result<String, String> {
let value = |label: &str| {
fields
.iter()
.find(|field| field.label == label)
.map(|field| field.value.trim().to_string())
.unwrap_or_default()
};
let number = |label: &str| -> Result<Option<i32>, String> {
let raw = value(label);
if raw.is_empty() {
return Ok(None);
}
raw.parse::<i32>()
.map(Some)
.map_err(|_| format!("{label} must be a number"))
};
let names = |label: &str| -> Vec<String> {
value(label)
.split(';')
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.collect()
};
let library = Arc::clone(&runtime.library);
let result = match target {
EditTarget::Track(id) => {
let title = value("Title");
if title.is_empty() {
return Err("title is empty".to_string());
}
let artists = names("Artists");
if artists.is_empty() {
return Err("at least one artist is required".to_string());
}
let edit = TrackEdit {
title,
artists,
featured_artists: names("Featured"),
track_number: number("Track #")?,
disc_number: number("Disc #")?,
};
library.update_track(id, &edit)
}
EditTarget::Release(id) => {
let title = value("Title");
if title.is_empty() {
return Err("title is empty".to_string());
}
let release_type = value("Type").to_lowercase();
let release_type = if release_type.is_empty() {
"album".to_string()
} else {
release_type
};
let edit = ReleaseEdit {
title,
release_type,
year: number("Year")?,
artists: Vec::new(),
};
library.update_release(id, &edit)
}
EditTarget::Artist(id) => {
let name = value("Name");
if name.is_empty() {
return Err("name is empty".to_string());
}
let image = value("Image path");
let image = (!image.is_empty()).then_some(image);
library.update_artist(id, &name, image.as_deref())
}
EditTarget::Playlist(id) => {
let title = value("Title");
if title.is_empty() {
return Err("title is empty".to_string());
}
library.update_playlist(id, &title, None)
}
};
match result {
Ok(()) => {
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
message: Some("saved".to_string()),
});
Ok("saved".to_string())
}
Err(err) => Err(format!("{err:#}")),
}
}
// ---------------------------------------------------------------------------
// Delete confirmation
// ---------------------------------------------------------------------------
fn handle_confirm_delete(
state: &mut AppState,
runtime: &Runtime,
target: DeleteTarget,
label: String,
key: KeyEvent,
) {
match key.code {
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {}
KeyCode::Enter | KeyCode::Char('y') => {
let library = Arc::clone(&runtime.library);
let result = match target {
DeleteTarget::Track(id) => library.delete_track(id),
DeleteTarget::Release(id) => library.delete_release(id),
DeleteTarget::Artist(id) => library.delete_artist(id),
DeleteTarget::Playlist(id) => library.delete_playlist(id),
};
match result {
Ok(()) => {
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
message: Some(format!("deleted {label}")),
});
}
Err(err) => state.status_message = Some(format!("delete failed: {err:#}")),
}
}
_ => state.popup = Some(Popup::ConfirmDelete { target, label }),
}
}
// ---------------------------------------------------------------------------
// Track info
// ---------------------------------------------------------------------------
fn handle_track_info(
state: &mut AppState,
tracks: Vec<TrackItem>,
@@ -132,6 +361,10 @@ fn handle_track_info(
}
}
// ---------------------------------------------------------------------------
// Add-to-playlist picker & new playlist
// ---------------------------------------------------------------------------
fn handle_picker(
state: &mut AppState,
runtime: &Runtime,
@@ -236,35 +469,13 @@ fn handle_name_entry(
}
}
fn spawn_select_device(runtime: &Runtime, target_device_id: String) {
let Some(api) = runtime.api.clone() else {
return;
};
let current_device_id = runtime.device_id.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api
.select_device(&target_device_id, &current_device_id)
.await
{
Ok(response) => AppEvent::DeviceActivated(Ok(response)),
Err(crate::api::client::ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::DeviceActivated(Err(err.to_string())),
};
let _ = tx.send(event);
});
}
fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, track: TrackItem) {
let Some(api) = runtime.api.clone() else {
return;
};
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = api
tokio::task::spawn_blocking(move || {
let result = library
.add_tracks_to_playlist(playlist_id, &[track.id])
.await
.map_err(|e| e.to_string());
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::PlaylistTracksAdded {
playlist_id,
playlist_title,
@@ -274,12 +485,12 @@ fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String,
}
fn spawn_create_playlist(runtime: &Runtime, title: String, add_track: Option<TrackItem>) {
let Some(api) = runtime.api.clone() else {
return;
};
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = api.create_playlist(&title).await.map_err(|e| e.to_string());
tokio::task::spawn_blocking(move || {
let result = library
.create_playlist(&title)
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::PlaylistCreated { result, add_track });
});
}
-134
View File
@@ -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
View File
@@ -1,12 +1,12 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::api::models::{
ArtistCard, ArtistDetail, DeviceDto, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
SearchResults, TrackItem, User,
};
use crate::art::ArtImage;
use crate::config::keymap::KeyContext;
use crate::library::models::{
ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
SearchResults, TrackItem,
};
/// Remote data that a view renders: spinner, content, or error.
#[derive(Debug)]
@@ -83,9 +83,12 @@ pub struct GlobalTab {
pub selected: usize,
pub view: ViewMode,
pub stack: Vec<GlobalView>,
/// Page size, fixed at the first request — the server's offset is
/// Page size, fixed at the first request — the offset is
/// `(page-1) * limit`, so it must not change between pages.
pub page_limit: Option<i64>,
/// A full atomic reload is in flight (after a library change); incoming
/// pages of the old pagination are dropped until it lands.
pub reloading: bool,
}
impl Default for GlobalTab {
@@ -101,6 +104,7 @@ impl Default for GlobalTab {
view: ViewMode::default(),
stack: Vec::new(),
page_limit: None,
reloading: false,
}
}
}
@@ -161,8 +165,8 @@ pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>>
rows
}
/// The virtual server-side Likes playlist id (`kind == "likes"`).
pub const LIKES_PLAYLIST_ID: i64 = -1;
/// The virtual Likes playlist id (`kind == "likes"`).
pub use crate::library::LIKES_PLAYLIST_ID;
#[derive(Debug, Clone, Copy)]
pub struct OpenedPlaylist {
@@ -282,42 +286,45 @@ impl Default for LogsTab {
}
}
#[derive(Debug, Default)]
pub struct DevicesState {
pub device_id: String,
pub active_device_id: Option<String>,
pub devices: Vec<DeviceDto>,
pub poll_error: Option<String>,
pub switching_to: Option<String>,
/// What an open edit form writes to when saved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditTarget {
Track(i64),
Release(i64),
Artist(i64),
Playlist(i64),
}
impl DevicesState {
pub fn is_playback_device(&self) -> bool {
self.active_device_id
.as_deref()
.is_none_or(|active| active == self.device_id)
}
/// What a confirmed delete removes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteTarget {
Track(i64),
Release(i64),
Artist(i64),
Playlist(i64),
}
pub fn remote_target_id(&self) -> Option<&str> {
self.active_device_id
.as_deref()
.filter(|active| *active != self.device_id)
}
/// One text field of an edit form.
#[derive(Debug, Clone)]
pub struct EditField {
pub label: &'static str,
pub value: String,
}
pub fn active_device_name(&self) -> Option<&str> {
let active = self.active_device_id.as_deref()?;
self.devices
.iter()
.find(|device| device.id == active)
.map(|device| device.name.as_str())
impl EditField {
pub fn new(label: &'static str, value: impl Into<String>) -> Self {
Self {
label,
value: value.into(),
}
}
}
/// Modal dialog over the main screen.
#[derive(Debug)]
pub enum Popup {
/// Pick one of the user's playlists (row 0 = "create new"); the track
/// is added on Enter.
/// Pick one of the playlists (row 0 = "create new"); the track is added
/// on Enter.
AddToPlaylist { track: TrackItem, cursor: usize },
/// Name input for a new playlist; when `for_track` is set, the track is
/// added to it right after creation.
@@ -326,8 +333,16 @@ pub enum Popup {
input: String,
busy: bool,
},
/// Connected devices list; Enter transfers active playback to the row.
Devices { cursor: usize },
/// Metadata edit form for a track, release, artist or playlist.
Edit {
target: EditTarget,
title: String,
fields: Vec<EditField>,
focus: usize,
error: Option<String>,
},
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
ConfirmDelete { target: DeleteTarget, label: String },
/// Track metadata viewer; left/right switch between selected tracks.
TrackInfo {
tracks: Vec<TrackItem>,
@@ -336,15 +351,67 @@ pub enum Popup {
},
/// Full, wrapped view of one log entry (Enter on the Logs tab).
LogDetail(crate::config::logging::LogEntry),
/// One-line text entry on the Federation tab (network id, peer ticket).
FedInput {
field: FedInputField,
input: String,
},
/// Wrapped read-only text (this peer's connection ticket).
FedText { title: String, text: String },
}
/// User's own playlists eligible as add-targets (the virtual Likes playlist
/// is managed through likes, not direct adds).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedInputField {
NetworkId,
ConnectTicket,
}
impl FedInputField {
pub fn title(self) -> &'static str {
match self {
FedInputField::NetworkId => "Network ID",
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
}
}
}
/// Rows of the Federation tab, in display order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedRow {
Toggle,
NetworkId,
SaveOnListen,
SyncNow,
ShowTicket,
Connect,
}
impl FedRow {
pub const ALL: [FedRow; 6] = [
FedRow::Toggle,
FedRow::NetworkId,
FedRow::SaveOnListen,
FedRow::SyncNow,
FedRow::ShowTicket,
FedRow::Connect,
];
}
/// The Federation tab: settings mirror + the latest status snapshot.
#[derive(Debug, Default)]
pub struct FederationTab {
pub cursor: usize,
pub settings: crate::federation::FedSettings,
pub status: Option<crate::federation::FedStatus>,
}
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
/// through likes, not direct adds).
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
match &state.playlists.list {
Some(Loadable::Ready(list)) => list
.iter()
.filter(|p| p.is_own && p.kind != "likes")
.filter(|p| p.kind != "likes")
.map(|p| (p.id, p.title.clone()))
.collect(),
_ => Vec::new(),
@@ -367,85 +434,10 @@ pub struct SearchState {
pub query: String,
pub loading: bool,
pub results: Option<SearchResults>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Screen {
#[default]
Main,
Login,
}
/// SSO is the primary sign-in path, so it sits right under the server URL;
/// the password fields below are the rare fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoginField {
#[default]
ServerUrl,
SsoButton,
Username,
Password,
SignInButton,
}
impl LoginField {
const ORDER: [LoginField; 5] = [
LoginField::ServerUrl,
LoginField::SsoButton,
LoginField::Username,
LoginField::Password,
LoginField::SignInButton,
];
pub fn next(self) -> LoginField {
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
Self::ORDER[(i + 1) % Self::ORDER.len()]
}
pub fn prev(self) -> LoginField {
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
Self::ORDER[(i + Self::ORDER.len() - 1) % Self::ORDER.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoginMode {
/// Server / username / password fields plus the SSO button.
#[default]
Form,
/// Browser SSO started; waiting for the pasted callback link or code.
SsoPending,
}
#[derive(Debug)]
pub struct LoginForm {
pub server_url: String,
pub username: String,
pub password: String,
pub sso_paste: String,
pub sso_url: String,
pub sso_port: Option<u16>,
pub focus: LoginField,
pub mode: LoginMode,
pub busy: bool,
pub error: Option<String>,
}
impl Default for LoginForm {
fn default() -> Self {
Self {
server_url: "https://music.hexor.cy".to_string(),
username: String::new(),
password: String::new(),
sso_paste: String::new(),
sso_url: String::new(),
sso_port: None,
focus: LoginField::default(),
mode: LoginMode::default(),
busy: false,
error: None,
}
}
/// Tracks found on the federated network (empty while federation is
/// off); rendered as a separate, marked section.
pub fed_tracks: Vec<crate::federation::FedTrack>,
pub fed_loading: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -454,17 +446,25 @@ pub enum Tab {
Global,
Playlists,
Queue,
Federation,
Logs,
}
impl Tab {
pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Logs];
pub const ALL: [Tab; 5] = [
Tab::Global,
Tab::Playlists,
Tab::Queue,
Tab::Federation,
Tab::Logs,
];
pub fn title(self) -> &'static str {
match self {
Tab::Global => "Global",
Tab::Playlists => "Playlists",
Tab::Queue => "Queue",
Tab::Federation => "Federation",
Tab::Logs => "Logs",
}
}
@@ -490,6 +490,7 @@ impl Tab {
Tab::Global => KeyContext::Library,
Tab::Playlists => KeyContext::Playlists,
Tab::Queue => KeyContext::Queue,
Tab::Federation => KeyContext::Federation,
Tab::Logs => KeyContext::Logs,
}
}
@@ -567,7 +568,6 @@ impl Default for PlayerBar {
/// event handlers in the main loop; views render from `&AppState`.
#[derive(Debug, Default)]
pub struct AppState {
pub screen: Screen,
pub active_tab: Tab,
pub should_quit: bool,
/// Double-press quit confirmation: set by the first Quit press, expires
@@ -577,8 +577,6 @@ pub struct AppState {
pub pending_keys: Option<String>,
pub status_message: Option<String>,
pub player: PlayerBar,
pub login: LoginForm,
pub user: Option<User>,
pub global: GlobalTab,
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
@@ -588,8 +586,8 @@ pub struct AppState {
pub likes: std::collections::HashSet<i64>,
pub likes_loaded: bool,
pub logs: LogsTab,
pub devices: DevicesState,
pub queue_tab: QueueTab,
pub federation: FederationTab,
pub track_selection: TrackSelection,
/// Shift-J jump in flight: focus this (release, track) once the release
/// view finishes loading.
+370 -87
View File
@@ -1,7 +1,7 @@
use std::time::{Duration, Instant};
use super::action::Action;
use crate::api::models::TrackItem;
use crate::library::models::TrackItem;
use super::state::{
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
@@ -31,11 +31,24 @@ pub enum Effect {
ToggleLikes {
track_ids: Vec<i64>,
},
/// Remove tracks from a stored playlist in the library.
RemoveFromPlaylist {
playlist_id: i64,
track_ids: Vec<i64>,
},
RemoveQueueIndices {
indices: Vec<usize>,
restart_paused: Option<bool>,
stop: bool,
},
/// Persist the Federation-tab settings and start/stop the node.
FedApplySettings,
/// Force an immediate library publish into the DHT.
FedSyncNow,
/// Fetch this peer's ticket and show it in a popup.
FedShowTicket,
/// Download (or resolve) a federated track and play it.
FedPlay(crate::federation::FedTrack),
}
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
@@ -195,18 +208,6 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
None
}
Action::OpenDevices => {
let cursor = state
.devices
.devices
.iter()
.position(|device| {
device.id == state.devices.active_device_id.clone().unwrap_or_default()
})
.unwrap_or(0);
state.popup = Some(super::state::Popup::Devices { cursor });
None
}
Action::Select => select_current(state),
Action::Back if state.track_selection.is_active() => {
state.track_selection.clear();
@@ -310,9 +311,243 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
None
}
}
// Needs the Runtime, so it is intercepted in app::handle_main_key
// before reaching update().
Action::Logout => None,
Action::EditSelected => {
open_edit_popup(state);
None
}
Action::DeleteSelected => delete_selected(state),
}
}
/// `e`: open the metadata edit form for whatever is under the cursor —
/// an artist tile, a release, a track or a playlist.
fn open_edit_popup(state: &mut AppState) {
use super::state::{EditField, EditTarget, Popup};
if state.active_tab == Tab::Playlists && state.playlists.opened.is_none() {
let card = match &state.playlists.list {
Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(),
_ => None,
};
let Some(card) = card else {
return;
};
if card.kind == "likes" {
state.status_message = Some("the Likes playlist cannot be edited".into());
return;
}
state.popup = Some(Popup::Edit {
target: EditTarget::Playlist(card.id),
title: format!("Edit playlist — {}", card.title),
fields: vec![EditField::new("Title", card.title.clone())],
focus: 0,
error: None,
});
return;
}
if state.active_tab == Tab::Global && state.global.stack.is_empty() {
let Some(artist) = state.global.artists.get(state.global.selected).cloned() else {
return;
};
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
return;
}
if let Some(artist) = selected_search_artist(state) {
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
return;
}
if let Some(release) = selected_release_card(state) {
state.popup = Some(Popup::Edit {
target: EditTarget::Release(release.id),
title: format!("Edit release — {}", release.title),
fields: vec![
EditField::new("Title", release.title.clone()),
EditField::new("Type", release.release_type.clone()),
EditField::new(
"Year",
release.year.map(|y| y.to_string()).unwrap_or_default(),
),
],
focus: 0,
error: None,
});
return;
}
if let Some(track) = selected_track(state).or_else(|| state.player.current.clone()) {
state.popup = Some(track_edit_popup(&track));
return;
}
state.status_message = Some("nothing to edit here".into());
}
fn artist_edit_popup(
id: i64,
name: &str,
image_path: Option<String>,
) -> super::state::Popup {
use super::state::{EditField, EditTarget, Popup};
Popup::Edit {
target: EditTarget::Artist(id),
title: format!("Edit artist — {name}"),
fields: vec![
EditField::new("Name", name),
EditField::new("Image path", image_path.unwrap_or_default()),
],
focus: 0,
error: None,
}
}
fn track_edit_popup(track: &TrackItem) -> super::state::Popup {
use super::state::{EditField, EditTarget, Popup};
let join = |artists: &[crate::library::models::ArtistRef]| {
artists
.iter()
.map(|a| a.name.clone())
.collect::<Vec<_>>()
.join("; ")
};
Popup::Edit {
target: EditTarget::Track(track.id),
title: format!("Edit track — {}", track.title),
fields: vec![
EditField::new("Title", track.title.clone()),
EditField::new("Artists", join(&track.artists)),
EditField::new("Featured", join(&track.featured_artists)),
EditField::new(
"Track #",
track.track_number.map(|n| n.to_string()).unwrap_or_default(),
),
EditField::new(
"Disc #",
track.disc_number.map(|n| n.to_string()).unwrap_or_default(),
),
],
focus: 0,
error: None,
}
}
/// shift-d: delete whatever is under the cursor. Library entities ask for
/// confirmation; playlist/queue rows are removed directly.
fn delete_selected(state: &mut AppState) -> Option<Effect> {
use super::state::{DeleteTarget, Popup};
if state.active_tab == Tab::Queue {
return remove_selected_from_queue(state);
}
if state.active_tab == Tab::Playlists {
match state.playlists.opened {
None => {
let card = match &state.playlists.list {
Some(Loadable::Ready(list)) => list.get(state.playlists.selected).cloned(),
_ => None,
};
let card = card?;
if card.kind == "likes" {
state.status_message = Some("the Likes playlist cannot be deleted".into());
return None;
}
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Playlist(card.id),
label: format!("playlist \"{}\"", card.title),
});
return None;
}
Some(opened) => {
let tracks = selected_tracks(state);
if tracks.is_empty() {
state.status_message = Some("no track selected".into());
return None;
}
let track_ids: Vec<i64> = tracks.iter().map(|track| track.id).collect();
state.track_selection.clear();
if opened.id == super::state::LIKES_PLAYLIST_ID {
let liked: Vec<i64> = track_ids
.into_iter()
.filter(|id| state.likes.contains(id))
.collect();
state.status_message = Some(format!("removing {} like(s)", liked.len()));
return Some(Effect::ToggleLikes { track_ids: liked });
}
state.status_message =
Some(format!("removing {} track(s) from playlist", track_ids.len()));
return Some(Effect::RemoveFromPlaylist {
playlist_id: opened.id,
track_ids,
});
}
}
}
if state.active_tab != Tab::Global {
return None;
}
if state.global.stack.is_empty() {
let artist = state.global.artists.get(state.global.selected).cloned()?;
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Artist(artist.id),
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
});
return None;
}
if let Some(artist) = selected_search_artist(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Artist(artist.id),
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
});
return None;
}
if let Some(release) = selected_release_card(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Release(release.id),
label: format!("release \"{}\" with all its tracks", release.title),
});
return None;
}
if let Some(track) = selected_track(state) {
state.popup = Some(Popup::ConfirmDelete {
target: DeleteTarget::Track(track.id),
label: format!("track \"{}\" (the audio file stays on disk)", track.title),
});
} else {
state.status_message = Some("nothing to delete here".into());
}
None
}
/// The artist under the cursor in the search view, if any.
fn selected_search_artist(state: &AppState) -> Option<crate::library::models::ArtistCard> {
if state.active_tab != Tab::Global {
return None;
}
let Some(GlobalView::Search { cursor }) = state.global.stack.last() else {
return None;
};
state.search.results.as_ref()?.artists.get(*cursor).cloned()
}
/// The release card under the cursor (artist view tiles/rows or search).
fn selected_release_card(state: &AppState) -> Option<crate::library::models::ReleaseCard> {
if state.active_tab != Tab::Global {
return None;
}
match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
Loadable::Ready(detail) => {
let position = cursor.checked_sub(detail.top_tracks.len())?;
let order = release_display_order(&detail.releases);
order
.get(position)
.map(|&index| detail.releases[index].clone())
}
_ => None,
},
GlobalView::Search { cursor } => {
let results = state.search.results.as_ref()?;
let offset = cursor.checked_sub(results.artists.len())?;
results.releases.get(offset).cloned()
}
GlobalView::Release { .. } => None,
}
}
@@ -386,11 +621,10 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
}
}
TrackSelectionScope::Playlist(id) => {
if let Some(opened) = &mut state.playlists.opened {
if opened.id == *id {
if let Some(opened) = &mut state.playlists.opened
&& opened.id == *id {
opened.cursor = value;
}
}
}
TrackSelectionScope::Queue => {
state.queue_tab.cursor = value;
@@ -438,7 +672,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
state.queue_tab.cursor,
state.player.queue.len(),
)),
Tab::Logs => None,
Tab::Federation | Tab::Logs => None,
}
}
@@ -487,7 +721,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
state.queue_tab.cursor,
&state.player.queue,
)),
Tab::Logs => None,
Tab::Federation | Tab::Logs => None,
}
}
@@ -653,7 +887,7 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
.cloned()
}
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
Tab::Logs => None,
Tab::Federation | Tab::Logs => None,
}
}
@@ -769,11 +1003,10 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
for (offset, track) in tracks.into_iter().enumerate() {
player.queue.insert(insert_at + offset, track);
}
if let Some(prefetched) = &mut player.prefetched_pos {
if insert_at <= *prefetched {
if let Some(prefetched) = &mut player.prefetched_pos
&& insert_at <= *prefetched {
*prefetched += count;
}
}
if insert_at <= player.queue_pos && player.current.is_some() {
player.queue_pos += count;
}
@@ -885,7 +1118,7 @@ pub fn restore_queue_order(player: &mut super::state::PlayerBar) {
}
let tail = player.queue.split_off(start);
let mut used = vec![false; order.len()];
let mut keyed: Vec<(usize, usize, crate::api::models::TrackItem)> = tail
let mut keyed: Vec<(usize, usize, crate::library::models::TrackItem)> = tail
.into_iter()
.enumerate()
.map(|(position, track)| {
@@ -954,6 +1187,14 @@ fn page_step(state: &AppState) -> isize {
}
fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
if state.active_tab == Tab::Federation {
if dy != 0 {
let last = super::state::FedRow::ALL.len() as isize - 1;
state.federation.cursor =
(state.federation.cursor as isize + dy).clamp(0, last) as usize;
}
return;
}
if state.active_tab == Tab::Logs {
// The cursor anchors to an entry's seq, so freshly appended log
// lines (including ones caused by this very keypress) don't shift
@@ -1138,6 +1379,9 @@ fn current_view_len(state: &AppState) -> usize {
if state.active_tab == Tab::Queue {
return state.player.queue.len();
}
if state.active_tab == Tab::Federation {
return super::state::FedRow::ALL.len();
}
match state.global.stack.last() {
None => state.global.artists.len(),
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
@@ -1150,7 +1394,9 @@ fn current_view_len(state: &AppState) -> usize {
Some(Loadable::Ready(d)) => d.tracks.len(),
_ => 0,
},
Some(GlobalView::Search { .. }) => state.search.results.as_ref().map_or(0, |r| r.len()),
Some(GlobalView::Search { .. }) => {
state.search.results.as_ref().map_or(0, |r| r.len()) + state.search.fed_tracks.len()
}
}
}
@@ -1173,15 +1419,19 @@ fn jump_selection(state: &mut AppState, first: bool) {
}
return;
}
if state.active_tab == Tab::Federation {
let last = super::state::FedRow::ALL.len() - 1;
state.federation.cursor = if first { 0 } else { last };
return;
}
if state.active_tab == Tab::Logs {
if first {
let level = super::state::LOG_LEVELS[state.logs.level_index];
if let Some(buffer) = crate::config::logging::buffer() {
if let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
if let Some(buffer) = crate::config::logging::buffer()
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
state.logs.selected_seq = Some(seq);
state.logs.follow = false;
}
}
} else {
state.logs.follow = true;
state.logs.selected_seq = None;
@@ -1257,6 +1507,9 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
}
return None;
}
if state.active_tab == Tab::Federation {
return federation_select(state);
}
// Queue: jump playback to the track under the cursor. Earlier tracks
// stay in the queue as "played"; picking one of them just moves the
// playing position back.
@@ -1274,9 +1527,10 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
enum Outcome {
Push(GlobalView),
Play {
tracks: Vec<crate::api::models::TrackItem>,
tracks: Vec<crate::library::models::TrackItem>,
start: usize,
},
PlayFed(crate::federation::FedTrack),
Nothing,
}
let outcome = match state.global.stack.last().copied() {
@@ -1347,10 +1601,17 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
start: cursor - artists - releases,
}
} else {
Outcome::Nothing
let fed_index = cursor - artists - releases - results.tracks.len();
match state.search.fed_tracks.get(fed_index) {
Some(fed) => Outcome::PlayFed(fed.clone()),
None => Outcome::Nothing,
}
}
}
None => Outcome::Nothing,
None => match state.search.fed_tracks.get(cursor) {
Some(fed) => Outcome::PlayFed(fed.clone()),
None => Outcome::Nothing,
},
},
};
match outcome {
@@ -1364,13 +1625,57 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
on_new_queue(state);
Some(Effect::PlayCurrent)
}
Outcome::PlayFed(fed) => {
state.status_message = Some(format!("federation: fetching \"{}\"", fed.title));
Some(Effect::FedPlay(fed))
}
Outcome::Nothing => None,
}
}
/// Enter on the Federation tab: toggle switches, open text inputs, run
/// one-shot operations. The heavy lifting happens in perform_effect().
fn federation_select(state: &mut AppState) -> Option<Effect> {
use super::state::{FedInputField, FedRow, Popup};
match FedRow::ALL.get(state.federation.cursor)? {
FedRow::Toggle => {
let settings = &mut state.federation.settings;
if !settings.enabled && settings.network_id.trim().is_empty() {
state.popup = Some(Popup::FedInput {
field: FedInputField::NetworkId,
input: String::new(),
});
return None;
}
settings.enabled = !settings.enabled;
Some(Effect::FedApplySettings)
}
FedRow::NetworkId => {
state.popup = Some(Popup::FedInput {
field: FedInputField::NetworkId,
input: state.federation.settings.network_id.clone(),
});
None
}
FedRow::SaveOnListen => {
state.federation.settings.save_on_listen = !state.federation.settings.save_on_listen;
Some(Effect::FedApplySettings)
}
FedRow::SyncNow => Some(Effect::FedSyncNow),
FedRow::ShowTicket => Some(Effect::FedShowTicket),
FedRow::Connect => {
state.popup = Some(Popup::FedInput {
field: FedInputField::ConnectTicket,
input: String::new(),
});
None
}
}
}
/// A freshly created play context: drop the stale pre-shuffle snapshot and,
/// if shuffle is on, shuffle everything after the chosen track right away.
fn on_new_queue(state: &mut AppState) {
pub(super) fn on_new_queue(state: &mut AppState) {
let player = &mut state.player;
player.original_order = None;
if player.shuffle && !player.queue.is_empty() {
@@ -1409,19 +1714,17 @@ fn go_back(state: &mut AppState) {
Tab::Global => {
// Esc on a view opened by Shift-J from another tab goes back to
// that tab, not down the Global stack.
if let Some((origin, depth)) = state.jump_origin {
if state.global.stack.len() == depth + 1 {
if let Some((origin, depth)) = state.jump_origin
&& state.global.stack.len() == depth + 1 {
state.global.stack.pop();
state.jump_origin = None;
state.active_tab = origin;
return;
}
}
if let Some(popped) = state.global.stack.pop() {
if matches!(popped, GlobalView::Search { .. }) {
if let Some(popped) = state.global.stack.pop()
&& matches!(popped, GlobalView::Search { .. }) {
state.search = SearchState::default();
}
}
}
_ => {}
}
@@ -1450,6 +1753,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
state.global.stack.clear();
}
Tab::Playlists => state.playlists.opened = None,
Tab::Federation => state.federation.cursor = 0,
Tab::Logs => {
state.logs.follow = true;
state.logs.selected_seq = None;
@@ -1465,7 +1769,7 @@ fn not_yet(state: &mut AppState, what: &str) {
#[cfg(test)]
mod tests {
use super::*;
use crate::api::models::{ArtistCard, ArtistDetail, TrackItem};
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem};
fn with_artists(n: usize) -> AppState {
let mut state = AppState::default();
@@ -1473,7 +1777,7 @@ mod tests {
.map(|i| ArtistCard {
id: i as i64,
name: format!("artist {i}"),
image_url: None,
image_path: None,
release_count: 1,
track_count: 2,
})
@@ -1493,18 +1797,14 @@ mod tests {
release_id: 1,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/s/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/s/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
}
}
@@ -1619,14 +1919,14 @@ mod tests {
#[test]
fn artist_tiles_move_by_visual_rows_across_groups() {
use crate::api::models::{ArtistDetail, ReleaseCard};
use crate::library::models::{ArtistDetail, ReleaseCard};
let release = |id: i64, kind: &str| ReleaseCard {
id,
title: format!("r{id}"),
release_type: kind.to_string(),
year: None,
cover_url: None,
cover_path: None,
track_count: 1,
};
// columns = 3 in tests (no tty → 80 wide): albums rows [0,1,2],[3],
@@ -1634,7 +1934,7 @@ mod tests {
let detail = ArtistDetail {
id: 1,
name: "a".into(),
image_url: None,
image_path: None,
total_track_count: 0,
total_play_count: 0,
top_tracks: vec![],
@@ -1707,7 +2007,7 @@ mod tests {
#[test]
fn queue_advances_and_respects_repeat() {
use crate::api::models::TrackItem;
use crate::library::models::TrackItem;
use crate::app::state::RepeatMode;
let track = |id: i64| TrackItem {
@@ -1721,18 +2021,14 @@ mod tests {
release_id: 1,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/api/player/stream/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/api/player/stream/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
};
let mut state = AppState::default();
state.player.queue = vec![track(1), track(2)];
@@ -1776,7 +2072,7 @@ mod tests {
#[test]
fn queue_tab_select_and_clear() {
use crate::api::models::TrackItem;
use crate::library::models::TrackItem;
let track = |id: i64| TrackItem {
id,
title: format!("t{id}"),
@@ -1788,18 +2084,14 @@ mod tests {
release_id: 1,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/s/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/s/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
};
let mut state = AppState {
active_tab: Tab::Queue,
@@ -1870,7 +2162,7 @@ mod tests {
Loadable::Ready(ArtistDetail {
id: 9,
name: "artist".into(),
image_url: None,
image_path: None,
total_track_count: 3,
total_play_count: 0,
top_tracks: (1..=3).map(test_track).collect(),
@@ -1942,7 +2234,7 @@ mod tests {
#[test]
fn shuffle_reorders_tail_and_restores() {
use crate::api::models::TrackItem;
use crate::library::models::TrackItem;
let track = |id: i64| TrackItem {
id,
title: format!("t{id}"),
@@ -1954,18 +2246,14 @@ mod tests {
release_id: 1,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/s/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/s/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
};
let mut state = AppState::default();
state.player.queue = (1..=8).map(track).collect();
@@ -1991,7 +2279,7 @@ mod tests {
#[test]
fn shift_j_opens_release_from_queue() {
use crate::api::models::{ReleaseDetail, TrackItem};
use crate::library::models::{ReleaseDetail, TrackItem};
let track = |id: i64, release_id: i64| TrackItem {
id,
title: format!("t{id}"),
@@ -2003,18 +2291,14 @@ mod tests {
release_id,
release_title: "r".into(),
release_year: None,
cover_url: None,
stream_url: format!("/s/{id}"),
uploader_name: String::new(),
cover_path: None,
file_path: format!("/s/{id}"),
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
lastfm_listeners: None,
lastfm_playcount: None,
lastfm_rating: None,
lastfm_updated_at: None,
play_count: 0,
};
let mut state = AppState {
active_tab: Tab::Queue,
@@ -2048,10 +2332,9 @@ mod tests {
title: "r".into(),
release_type: "album".into(),
year: None,
cover_url: None,
cover_path: None,
artists: vec![],
tracks: vec![track(1, 7), track(2, 7)],
uploaders: vec![],
}),
);
state.active_tab = Tab::Queue;
+8 -4
View File
@@ -9,7 +9,7 @@
# command: an Action name, optionally with parameters:
# command = { SeekForward = { seconds = 30 } }
# context: optional view filter — global (default), library, search,
# playlists, queue, devices.
# playlists, queue, logs.
[[keymaps]]
key_sequence = "q"
@@ -47,6 +47,10 @@ command = { GoToTab = 2 }
key_sequence = "4"
command = { GoToTab = 3 }
[[keymaps]]
key_sequence = "5"
command = { GoToTab = 4 }
[[keymaps]]
key_sequence = "a"
command = "QueueAddNext"
@@ -204,12 +208,12 @@ key_sequence = "i"
command = "OpenTrackInfo"
[[keymaps]]
key_sequence = "shift-l"
command = "Logout"
key_sequence = "e"
command = "EditSelected"
[[keymaps]]
key_sequence = "shift-d"
command = "OpenDevices"
command = "DeleteSelected"
[[keymaps]]
key_sequence = "v"
+8 -11
View File
@@ -20,7 +20,7 @@ pub enum KeyContext {
Search,
Playlists,
Queue,
Devices,
Federation,
Logs,
}
@@ -32,7 +32,7 @@ impl KeyContext {
KeyContext::Search => "search",
KeyContext::Playlists => "playlists",
KeyContext::Queue => "queue",
KeyContext::Devices => "devices",
KeyContext::Federation => "federation",
KeyContext::Logs => "logs",
}
}
@@ -81,8 +81,8 @@ impl Keymap {
let mut bindings =
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
let mut warning = None;
if let Some(path) = user_keymap_path() {
if path.exists() {
if let Some(path) = user_keymap_path()
&& path.exists() {
match fs::read_to_string(&path)
.map_err(anyhow::Error::from)
.and_then(|text| parse_bindings(&text))
@@ -96,7 +96,6 @@ impl Keymap {
}
}
}
}
let keymap = Self {
bindings,
pending: Vec::new(),
@@ -219,11 +218,10 @@ enum Lookup {
/// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works);
/// symbols drop it so a "?" binding matches everywhere.
fn normalize(key: KeyCombination) -> KeyCombination {
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes {
if !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes
&& !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
}
}
key
}
@@ -303,8 +301,8 @@ fn parse_chord(chord: &str) -> Result<KeyCombination> {
// crokey only parses single-byte characters; non-ASCII keys (Cyrillic
// bindings) are built directly.
let mut chars = chord.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
if !c.is_ascii() {
if let (Some(c), None) = (chars.next(), chars.next())
&& !c.is_ascii() {
let modifiers = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
@@ -312,7 +310,6 @@ fn parse_chord(chord: &str) -> Result<KeyCombination> {
};
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
}
}
KeyCombination::from_str(chord)
.map_err(|e| anyhow::anyhow!("{e}"))
.map(normalize)
-47
View File
@@ -2,54 +2,7 @@ pub mod keymap;
pub mod logging;
use directories::ProjectDirs;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", "furumi")
}
pub fn device_id_path() -> Option<PathBuf> {
project_dirs().map(|dirs| dirs.config_dir().join("device_id"))
}
pub fn load_or_create_device_id() -> String {
if let Some(path) = device_id_path() {
if let Ok(raw) = fs::read_to_string(&path) {
let id = raw.trim();
if valid_device_id(id) {
return id.to_string();
}
}
let id = generate_device_id();
if let Some(parent) = path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
tracing::warn!(path = %parent.display(), %err, "failed to create config directory");
return id;
}
}
if let Err(err) = fs::write(&path, &id) {
tracing::warn!(path = %path.display(), %err, "failed to persist device id");
}
return id;
}
generate_device_id()
}
fn valid_device_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
}
fn generate_device_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("tui-{nanos:x}-{:x}", std::process::id())
}
+311
View File
@@ -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}");
}
+630
View File
@@ -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,
}
}
+549
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+173
View File
@@ -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
View File
@@ -1,7 +1,8 @@
mod api;
mod app;
mod art;
mod config;
mod federation;
mod library;
mod media;
mod player;
mod ui;
+4 -6
View File
@@ -10,10 +10,9 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::Duration;
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
use stream_download::StreamDownload;
use stream_download::storage::temp::TempStorageProvider;
pub type TrackReader = StreamDownload<TempStorageProvider>;
/// Local audio files are read straight from disk.
pub type TrackReader = std::io::BufReader<std::fs::File>;
/// Perceptual volume: cubic mapping from percent to linear amplitude, so
/// equal percent steps sound like equal loudness steps and low percentages
@@ -258,11 +257,10 @@ fn handle(
*track_loaded = false;
}
Command::Seek(position) => {
if let Some(out) = output {
if let Err(err) = out.player.try_seek(position) {
if let Some(out) = output
&& let Err(err) = out.player.try_seek(position) {
tracing::warn!(%err, "seek failed");
}
}
}
Command::SetVolume(volume) => {
if let Some(out) = output {
+138
View File
@@ -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
View File
@@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Row, Table};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::{art, theme};
use crate::api::models::{ArtistCard, ReleaseCard};
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
use crate::app::state::{
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups,
@@ -315,7 +315,7 @@ fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) {
draw_tile(
frame,
tile,
tile_art(state, artist.image_url.as_ref()),
tile_art(state, artist.image_path.as_ref()),
&artist.name,
&artist_tile_meta(artist),
index == global.selected,
@@ -395,7 +395,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
height: ART_HEADER_HEIGHT.min(art_area.height),
..art_area
},
header_art(state, detail.image_url.as_ref()),
header_art(state, detail.image_path.as_ref()),
);
let mut about = format!("{} releases", detail.releases.len());
if !detail.featured_tracks.is_empty() {
@@ -536,7 +536,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
draw_tile(
frame,
tile,
tile_art(state, release.cover_url.as_ref()),
tile_art(state, release.cover_path.as_ref()),
&release.title,
&release_tile_meta(release),
cursor == tracks + position,
@@ -648,13 +648,12 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
height: ART_HEADER_HEIGHT.min(art_area.height),
..art_area
},
header_art(state, detail.cover_url.as_ref()),
header_art(state, detail.cover_path.as_ref()),
);
let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect();
let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default();
let uploaders: Vec<&str> = detail.uploaders.iter().map(|u| u.name.as_str()).collect();
let mut info = vec![
let info = vec![
Line::default(),
Line::styled(detail.title.clone(), theme::header()),
Line::raw(artists.join(", ")),
@@ -668,12 +667,6 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
theme::dim(),
),
];
if !uploaders.is_empty() {
info.push(Line::styled(
format!("uploaded by {}", uploaders.join(", ")),
theme::dim(),
));
}
frame.render_widget(Paragraph::new(info), info_area);
// Track list with centered scrolling.
@@ -719,15 +712,20 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
}
let inner = bordered(frame, area, title);
let Some(results) = &search.results else {
let hint = if search.query.is_empty() {
"type to search artists, releases and tracks"
} else {
"searching…"
};
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
let empty_results = SearchResults::default();
let results = match &search.results {
Some(results) => results,
None if !state.search.fed_tracks.is_empty() || state.search.fed_loading => &empty_results,
None => {
let hint = if search.query.is_empty() {
"type to search artists, releases and tracks"
} else {
"searching…"
};
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
}
};
if results.len() == 0 {
if results.len() == 0 && state.search.fed_tracks.is_empty() && !state.search.fed_loading {
return centered_line(frame, inner, Line::styled("nothing found", theme::dim()));
}
@@ -784,6 +782,45 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
index += 1;
}
}
// Tracks found on the federated network, marked with the owning peer.
if !state.search.fed_tracks.is_empty() || state.search.fed_loading {
if !rows.is_empty() {
rows.push((Line::default(), None, None));
}
let header = if state.search.fed_loading {
"Federation · searching…"
} else {
"Federation"
};
rows.push((Line::styled(header, theme::header()), None, None));
for fed in &state.search.fed_tracks {
let origin = if fed.own {
"your library".to_string()
} else {
format!("peer {}", fed.owner_short())
};
let mut meta = fed.duration_label();
if let Some(year) = fed.year {
if !meta.is_empty() {
meta.push_str(" · ");
}
meta.push_str(&year.to_string());
}
rows.push((
Line::from(vec![
Span::styled("", theme::accent()),
Span::raw(fed.title.clone()),
Span::styled(
format!(" {} · {}", fed.artist_line(), origin),
theme::dim(),
),
]),
Some(meta),
Some(index),
));
index += 1;
}
}
let cursor_row = rows
.iter()
-230
View File
@@ -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
View File
@@ -1,6 +1,6 @@
pub mod art;
mod federation;
mod global;
mod login;
mod logs;
mod playlists;
mod popup;
@@ -12,14 +12,10 @@ use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
use crate::app::state::{AppState, Screen, Tab, TrackSelectionScope};
use crate::app::state::{AppState, Tab, TrackSelectionScope};
use crate::config::keymap::Keymap;
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
if state.screen == Screen::Login {
login::draw(frame, &state.login);
return;
}
let [tabs_area, main_area, status_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
@@ -32,6 +28,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
Tab::Global => global::draw(frame, main_area, state),
Tab::Playlists => playlists::draw(frame, main_area, state),
Tab::Queue => draw_queue(frame, main_area, state),
Tab::Federation => federation::draw(frame, main_area, state),
Tab::Logs => logs::draw(frame, main_area, state),
}
draw_status(frame, status_area, state);
@@ -60,7 +57,7 @@ pub(crate) fn track_row(
frame: &mut Frame,
area: Rect,
state: &AppState,
track: &crate::api::models::TrackItem,
track: &crate::library::models::TrackItem,
index_label: String,
selected: bool,
visual_selected: bool,
@@ -92,7 +89,7 @@ pub(crate) fn track_row(
}
pub(crate) fn track_meta_suffix(
track: &crate::api::models::TrackItem,
track: &crate::library::models::TrackItem,
include_tech: bool,
) -> String {
let has_tech = track.audio_format.is_some()
@@ -201,8 +198,8 @@ fn format_secs(secs: f64) -> String {
/// Wider consoles get a longer bar and full flags; narrow ones drop pieces.
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(track) = &player.current {
if player.playing {
if let Some(track) = &player.current
&& player.playing {
let bar_width: usize = match width {
0..=59 => 0,
60..=79 => 8,
@@ -227,7 +224,6 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
));
}
}
}
if width >= 80 {
let volume_cells = usize::from(player.volume / 10);
spans.extend([
@@ -260,65 +256,21 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
Line::from(spans)
}
fn truncate_chars(value: &str, max: usize) -> String {
let mut out: String = value.chars().take(max).collect();
if value.chars().count() > max {
out.push('…');
}
out
}
fn device_status_line(state: &AppState) -> Line<'static> {
if state.devices.is_playback_device() {
return Line::from(vec![Span::styled("playing here", theme::accent())]);
}
let name = state
.devices
.active_device_name()
.map(|name| truncate_chars(name, 26))
.unwrap_or_else(|| "remote device".to_string());
Line::from(vec![
Span::styled("controlling ", theme::dim()),
Span::styled(name, theme::accent()),
])
}
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
let [player_row, message_row] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let player = &state.player;
// Layout: track title left, time/progress/flags centered, user right.
// The center block is built first and gets a fixed width; the title
// Layout: track title left, time/progress/flags on the right. The
// right block is built first and gets a fixed width; the title
// truncates into whatever is left.
let center = player_right_line(player, area.width);
let center_width = (center.width() as u16).min(area.width);
let device_line = device_status_line(state);
let device_width = (device_line.width() as u16).min(32);
let user_line = state.user.as_ref().map(|user| {
Line::from(vec![
Span::styled("", theme::accent()),
Span::raw(user.name.clone()),
])
});
let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16);
let [title_area, right_area, device_area, user_area] = Layout::horizontal([
let [title_area, right_area] = Layout::horizontal([
Constraint::Min(8),
Constraint::Length(center_width),
Constraint::Length(device_width.saturating_add(2)),
Constraint::Length(user_width),
])
.areas(player_row);
frame.render_widget(
Paragraph::new(device_line).alignment(Alignment::Right),
device_area,
);
if let Some(user_line) = user_line {
frame.render_widget(
Paragraph::new(user_line).alignment(Alignment::Right),
user_area,
);
}
let mut spans = Vec::new();
match &player.current {
+1 -19
View File
@@ -78,26 +78,8 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
} else {
Span::raw(" ")
};
let mut flags = Vec::new();
if !playlist.is_own {
if let Some(owner) = &playlist.owner_name {
flags.push(format!("by {owner}"));
}
}
if playlist.is_public {
flags.push("public".to_string());
}
let suffix = if flags.is_empty() {
String::new()
} else {
format!(" {}", flags.join(" · "))
};
frame.render_widget(
Paragraph::new(Line::from(vec![
marker,
Span::raw(playlist.title.clone()),
Span::styled(suffix, theme::dim()),
])),
Paragraph::new(Line::from(vec![marker, Span::raw(playlist.title.clone())])),
row,
);
frame.render_widget(
+130 -86
View File
@@ -4,8 +4,8 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
use super::theme;
use crate::api::models::{ArtistRef, TrackItem};
use crate::app::state::{AppState, Loadable, Popup, addable_playlists};
use crate::app::state::{AppState, EditField, Loadable, Popup, addable_playlists};
use crate::library::models::{ArtistRef, TrackItem};
pub fn draw(frame: &mut Frame, state: &AppState) {
match state.popup.as_ref() {
@@ -13,102 +13,156 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
draw_picker(frame, state, &track.title, *cursor)
}
Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy),
Some(Popup::Devices { cursor }) => draw_devices(frame, state, *cursor),
Some(Popup::Edit {
title,
fields,
focus,
error,
..
}) => draw_edit(frame, title, fields, *focus, error.as_deref()),
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label),
Some(Popup::TrackInfo {
tracks,
cursor,
scroll,
}) => draw_track_info(frame, tracks, *cursor, *scroll),
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input),
Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text),
None => {}
}
}
fn draw_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
let rows = state.devices.devices.len().max(1);
let height = (rows as u16 + 4)
.min(frame.area().height.saturating_sub(2))
.max(7);
let area = centered(frame.area(), 64, height);
/// One-line text entry on the Federation tab (network id / peer ticket).
fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) {
let area = centered(frame.area(), 64, 5);
let block = Block::bordered()
.title(" Connected devices ")
.title(format!(" {title} "))
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [entry_area, hint_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
// Keep the tail visible when the value (a ticket) exceeds the width.
let visible: String = {
let width = usize::from(entry_area.width.saturating_sub(2));
let chars: Vec<char> = input.chars().collect();
let skip = chars.len().saturating_sub(width);
chars[skip..].iter().collect()
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(visible),
Span::styled("", theme::accent()),
])),
entry_area,
);
frame.render_widget(
Paragraph::new(Line::styled("enter: apply · esc: cancel", theme::dim()))
.alignment(Alignment::Center),
hint_area,
);
}
/// Read-only wrapped text (this peer's federation ticket).
fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) {
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
let text_width = usize::from(width.saturating_sub(2));
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
let area = centered(frame.area(), width, lines_needed.clamp(5, frame.area().height));
let block = Block::bordered()
.title(format!(" {title} "))
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(text.to_string()).wrap(Wrap { trim: false }),
inner,
);
}
/// Metadata edit form: one bordered input per field, the focused field gets
/// the accent border and a cursor block.
fn draw_edit(
frame: &mut Frame,
title: &str,
fields: &[EditField],
focus: usize,
error: Option<&str>,
) {
let height = (fields.len() as u16 * 3 + 4).min(frame.area().height.saturating_sub(2));
let area = centered(frame.area(), 60, height);
let block = Block::bordered()
.title(format!(" {title} "))
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [list_area, _, footer] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
let mut constraints: Vec<Constraint> = fields.iter().map(|_| Constraint::Length(3)).collect();
constraints.push(Constraint::Min(0));
constraints.push(Constraint::Length(1));
let areas = Layout::vertical(constraints).split(inner);
if state.devices.devices.is_empty() {
frame.render_widget(
Paragraph::new(Line::styled("waiting for device poll…", theme::dim()))
.alignment(Alignment::Center),
list_area,
);
} else {
let visible = usize::from(list_area.height.max(1));
let cursor = cursor.min(state.devices.devices.len() - 1);
let first = cursor
.saturating_sub(visible / 2)
.min(state.devices.devices.len().saturating_sub(visible));
for (index, device) in state
.devices
.devices
.iter()
.enumerate()
.skip(first)
.take(visible)
{
let row = Rect {
x: list_area.x,
y: list_area.y + (index - first) as u16,
width: list_area.width,
height: 1,
};
let marker = if device.is_active {
Span::styled("", theme::accent())
} else {
Span::styled(" ", theme::dim())
};
let current = if device.is_current {
" · this TUI"
} else {
""
};
let switching = if state.devices.switching_to.as_deref() == Some(device.id.as_str()) {
" · switching"
} else {
""
};
let line = Line::from(vec![
marker,
Span::raw(device.name.clone()),
Span::styled(
format!(" · {}{current}{switching}", device.kind),
theme::dim(),
),
]);
frame.render_widget(Paragraph::new(line), row);
if index == cursor {
frame.buffer_mut().set_style(row, theme::tab_active());
}
for (index, field) in fields.iter().enumerate() {
let focused = index == focus;
let field_block = Block::bordered().title(field.label).border_style(if focused {
theme::accent()
} else {
theme::dim()
});
let field_inner = field_block.inner(areas[index]);
frame.render_widget(field_block, areas[index]);
let width = usize::from(field_inner.width.saturating_sub(1));
let mut shown: String = field
.value
.chars()
.skip(field.value.chars().count().saturating_sub(width))
.collect();
if focused {
shown.push('█');
}
frame.render_widget(Paragraph::new(shown), field_inner);
}
let hint = if let Some(error) = &state.devices.poll_error {
Line::styled(format!("sync error: {error}"), theme::dim())
} else {
Line::styled("enter make active · esc close", theme::dim())
let footer = areas[areas.len() - 1];
let hint = match error {
Some(error) => Line::styled(error.to_string(), theme::accent()),
None => Line::styled("tab/↑↓ field · enter save · esc cancel", theme::dim()),
};
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer);
}
fn draw_confirm_delete(frame: &mut Frame, label: &str) {
let width = 64.min(frame.area().width.saturating_sub(4)).max(30);
let area = centered(frame.area(), width, 7);
let block = Block::bordered()
.title(" Delete? ")
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
frame.render_widget(
Paragraph::new(format!("Delete {label}?"))
.wrap(Wrap { trim: false })
.alignment(Alignment::Center),
body,
);
frame.render_widget(
Paragraph::new(Line::styled("enter/y delete · esc/n cancel", theme::dim()))
.alignment(Alignment::Center),
footer,
);
}
fn draw_log_detail(frame: &mut Frame, entry: &crate::config::logging::LogEntry) {
let width = 90.min(frame.area().width.saturating_sub(4)).max(40);
let height = 18.min(frame.area().height.saturating_sub(2)).max(7);
@@ -203,7 +257,6 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
track.duration_seconds
),
),
field("Uploader", empty_dash(&track.uploader_name)),
field("Audio format", opt_string(track.audio_format.clone())),
field(
"Bitrate",
@@ -220,18 +273,9 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
opt_map(track.audio_bit_depth, |v| format!("{v} bit")),
),
field("File size", file_size(track.file_size_bytes)),
field("Last.fm listeners", opt_display(track.lastfm_listeners)),
field("Last.fm plays", opt_display(track.lastfm_playcount)),
field(
"Last.fm rating",
opt_map(track.lastfm_rating, |v| format!("{v:.3}")),
),
field(
"Last.fm updated",
opt_string(track.lastfm_updated_at.clone()),
),
field("Stream URL", empty_dash(&track.stream_url)),
field("Cover URL", opt_string(track.cover_url.clone())),
field("Plays", track.play_count.to_string()),
field("File path", empty_dash(&track.file_path)),
field("Cover path", opt_string(track.cover_path.clone())),
]
}