diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 73ae7d49..670919cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod analysis_cache; pub mod cli; mod discord; pub(crate) mod logging; +mod lib_commands; #[cfg(target_os = "windows")] mod taskbar_win; @@ -18,6 +19,7 @@ use tauri::{ tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent}, Emitter, Manager, }; +use lib_commands::*; // MouseButtonState is only matched on non-Windows targets — on Windows the // tray uses DoubleClick which doesn't carry a button_state. #[cfg(not(target_os = "windows"))] @@ -578,3988 +580,6 @@ struct LoudnessCachePayload { updated_at: i64, } -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}!", name) -} - -#[tauri::command] -fn exit_app(app_handle: tauri::AppHandle) { - stop_audio_engine(&app_handle); - app_handle.exit(0); -} - -/// Writes `psysonic-cli-snapshot.json` for `psysonic --info` (debounced from the frontend). -#[tauri::command] -fn cli_publish_player_snapshot(snapshot: serde_json::Value) -> Result<(), String> { - crate::cli::write_cli_snapshot(&snapshot) -} - -/// Writes `psysonic-cli-library.json` for `psysonic --player library list`. -#[tauri::command] -fn cli_publish_library_list(payload: serde_json::Value) -> Result<(), String> { - crate::cli::write_library_cli_response(&payload) -} - -/// Writes `psysonic-cli-servers.json` for `psysonic --player server list`. -#[tauri::command] -fn cli_publish_server_list(payload: serde_json::Value) -> Result<(), String> { - crate::cli::write_server_list_cli_response(&payload) -} - -/// Writes `psysonic-cli-search.json` for `psysonic --player search …`. -#[tauri::command] -fn cli_publish_search_results(payload: serde_json::Value) -> Result<(), String> { - crate::cli::write_search_cli_response(&payload) -} - -/// Toggle native window decorations at runtime (Linux custom title bar opt-out). -#[tauri::command] -fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) { - if let Some(win) = app_handle.get_webview_window("main") { - let _ = win.set_decorations(enabled); - // Re-enabling native decorations on GTK causes the window manager to - // re-stack the window, which drops focus. Bring it back immediately. - if enabled { - let _ = win.set_focus(); - } - } -} - -/// WebKitGTK: `enable-smooth-scrolling` also drives deferred / kinetic wheel scrolling. -#[cfg(target_os = "linux")] -fn linux_webkit_apply_smooth_scrolling(win: &tauri::WebviewWindow, enabled: bool) -> Result<(), String> { - win.with_webview(move |platform| { - use webkit2gtk::{SettingsExt, WebViewExt}; - if let Some(settings) = platform.inner().settings() { - settings.set_enable_smooth_scrolling(enabled); - } - }) - .map_err(|e| e.to_string()) -} - -/// Called from the frontend settings toggle (Linux); no-op on other platforms. -#[tauri::command] -fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle) -> Result<(), String> { - #[cfg(target_os = "linux")] - { - use tauri::Manager; - // Each WebviewWindow has its own WebKitGTK Settings — main-only left the - // mini player on the default (inertial) wheel until the user toggled again. - for label in ["main", "mini"] { - if let Some(win) = app_handle.get_webview_window(label) { - linux_webkit_apply_smooth_scrolling(&win, enabled)?; - } - } - } - #[cfg(not(target_os = "linux"))] - { - let _ = (enabled, app_handle); - } - Ok(()) -} - -#[tauri::command] -fn set_logging_mode(mode: String) -> Result<(), String> { - crate::logging::set_logging_mode_from_str(&mode) -} - -#[tauri::command] -fn export_runtime_logs(path: String) -> Result { - crate::logging::export_logs_to_file(&path) -} - -#[tauri::command] -fn frontend_debug_log(scope: String, message: String) -> Result<(), String> { - crate::app_deprintln!("[frontend][{}] {}", scope, message); - Ok(()) -} - -#[tauri::command] -fn set_subsonic_wire_user_agent( - user_agent: String, - window_label: String, - app_handle: tauri::AppHandle, -) -> Result<(), String> { - if window_label != "main" { - return Ok(()); - } - let ua = user_agent.trim(); - if ua.is_empty() { - return Err("user agent is empty".to_string()); - } - let mut guard = runtime_subsonic_wire_user_agent() - .write() - .map_err(|_| "user agent state poisoned".to_string())?; - guard.clear(); - guard.push_str(ua); - drop(guard); - - crate::audio::refresh_http_user_agent(&app_handle.state::(), ua); - Ok(()) -} - - -/// Authenticate with Navidrome's own REST API and return a Bearer token. -async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result { - let client = reqwest::Client::new(); - let resp = client - .post(format!("{}/auth/login", server_url)) - .json(&serde_json::json!({ "username": username, "password": password })) - .send() - .await - .map_err(|e| e.to_string())?; - let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; - data["token"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "Navidrome auth: no token in response".to_string()) -} - -#[tauri::command] -async fn upload_playlist_cover( - server_url: String, - playlist_id: String, - username: String, - password: String, - file_bytes: Vec, - mime_type: String, -) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("cover.jpg") - .mime_str(&mime_type) - .map_err(|e| e.to_string())?; - let form = reqwest::multipart::Form::new().part("image", part); - reqwest::Client::new() - .post(format!("{}/api/playlist/{}/image", server_url, playlist_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -async fn upload_radio_cover( - server_url: String, - radio_id: String, - username: String, - password: String, - file_bytes: Vec, - mime_type: String, -) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("cover.jpg") - .mime_str(&mime_type) - .map_err(|e| e.to_string())?; - let form = reqwest::multipart::Form::new().part("image", part); - reqwest::Client::new() - .post(format!("{}/api/radio/{}/image", server_url, radio_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -async fn upload_artist_image( - server_url: String, - artist_id: String, - username: String, - password: String, - file_bytes: Vec, - mime_type: String, -) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("cover.jpg") - .mime_str(&mime_type) - .map_err(|e| e.to_string())?; - let form = reqwest::multipart::Form::new().part("image", part); - reqwest::Client::new() - .post(format!("{}/api/artist/{}/image", server_url, artist_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -async fn delete_radio_cover( - server_url: String, - radio_id: String, - username: String, - password: String, -) -> Result<(), String> { - let token = navidrome_token(&server_url, &username, &password).await?; - let resp = reqwest::Client::new() - .delete(format!("{}/api/radio/{}/image", server_url, radio_id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - .await - .map_err(|e| e.to_string())?; - // 404/503 = no image existed — treat as success - if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE { - resp.error_for_status().map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// Payload returned by Navidrome's `/auth/login`. -#[derive(serde::Serialize)] -struct NdLoginResult { - token: String, - #[serde(rename = "userId")] - user_id: String, - #[serde(rename = "isAdmin")] - is_admin: bool, -} - -/// Flatten an error and its `source` chain into a single readable string so -/// frontend toasts can show the actual transport cause (connection refused, -/// tls handshake fail, cert expired, etc.) instead of reqwest's opaque -/// "error sending request for url (…)" wrapper. -fn nd_err(e: reqwest::Error) -> String { - let mut msg = e.to_string(); - let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e); - while let Some(s) = src { - msg.push_str(" | "); - msg.push_str(&s.to_string()); - src = s.source(); - } - msg -} - -/// Retry a request-building closure on transient transport errors -/// (connect/timeout — includes ECONNRESET, TLS handshake EOF, DNS flakes). -/// Three attempts with calm backoff: 0 → 300ms → 700ms (total worst case -/// ~1s). Retrying too aggressively (5+ attempts, short backoff) can drive -/// an already-stressed nginx upstream-probe into "offline" mode, which -/// turns a transient glitch into a visible outage. Status-level failures -/// (401/403/400 with body) return immediately — we don't retry logic -/// errors. -async fn nd_retry(mut build_and_send: F) -> Result -where - F: FnMut() -> Fut, - Fut: std::future::Future>, -{ - const BACKOFFS_MS: [u64; 1] = [500]; - let mut last: Option = None; - for attempt in 0..=BACKOFFS_MS.len() { - if attempt > 0 { - tokio::time::sleep(std::time::Duration::from_millis(BACKOFFS_MS[attempt - 1])).await; - } - match build_and_send().await { - Ok(resp) => return Ok(resp), - Err(e) => { - if !e.is_connect() && !e.is_timeout() { - return Err(nd_err(e)); - } - last = Some(e); - } - } - } - Err(nd_err(last.expect("loop ran at least once"))) -} - -/// Build a reqwest client for Navidrome's native REST endpoints. Plain -/// `reqwest::Client::new()` defaults to HTTP/2 over ALPN with no User-Agent, -/// which some reverse-proxies (strict nginx rules, Cloudflare Tunnel, CDN -/// WAFs) abort mid-TLS-handshake. Pinning HTTP/1.1 and advertising a real -/// User-Agent makes the handshake match what browsers do for the Subsonic -/// endpoints, so `/auth/*` + `/api/*` go through the same path as `/rest/*`. -/// -/// `pool_max_idle_per_host(0)` disables connection pooling. Keeping stale -/// keep-alive connections in the pool caused intermittent "tls handshake -/// eof" errors on the second call to an admin endpoint when a server or -/// proxy had already closed the TCP connection between calls. -fn nd_http_client() -> reqwest::Client { - // TLS 1.2 only: rustls + nginx with TLS-1.3 session resumption caches - // produces intermittent ECONNRESET mid-handshake when the upstream - // starts churning keep-alive connections. Pinning TLS 1.2 matches what - // the WebKit-side Subsonic calls end up negotiating most of the time - // on these setups. - reqwest::Client::builder() - .user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION"))) - .http1_only() - .pool_max_idle_per_host(0) - .max_tls_version(reqwest::tls::Version::TLS_1_2) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) -} - -/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin. -#[tauri::command] -async fn navidrome_login( - server_url: String, - username: String, - password: String, -) -> Result { - let body = serde_json::json!({ "username": username, "password": password }); - let resp = nd_retry(|| { - nd_http_client() - .post(format!("{}/auth/login", server_url)) - .json(&body) - .send() - }).await?; - if !resp.status().is_success() { - return Err(format!("Navidrome login failed: HTTP {}", resp.status())); - } - let data: serde_json::Value = resp.json().await.map_err(nd_err)?; - let token = data["token"].as_str().ok_or("no token in response")?.to_string(); - let user_id = data["id"].as_str().unwrap_or("").to_string(); - let is_admin = data["isAdmin"].as_bool().unwrap_or(false); - Ok(NdLoginResult { token, user_id, is_admin }) -} - -/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields. -#[tauri::command] -async fn nd_list_users( - server_url: String, - token: String, -) -> Result { - let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/user", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - }).await?; - if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); - } - resp.json::().await.map_err(nd_err) -} - -/// POST `/api/user` — create a user. -#[tauri::command] -async fn nd_create_user( - server_url: String, - token: String, - user_name: String, - name: String, - email: String, - password: String, - is_admin: bool, -) -> Result { - let body = serde_json::json!({ - "userName": user_name, - "name": name, - "email": email, - "password": password, - "isAdmin": is_admin, - }); - let resp = nd_retry(|| { - nd_http_client() - .post(format!("{}/api/user", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - }).await?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(format!("HTTP {}: {}", status, text)); - } - serde_json::from_str(&text).map_err(|e| e.to_string()) -} - -/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged. -#[tauri::command] -async fn nd_update_user( - server_url: String, - token: String, - id: String, - user_name: String, - name: String, - email: String, - password: String, - is_admin: bool, -) -> Result { - let mut body = serde_json::json!({ - "id": id, - "userName": user_name, - "name": name, - "email": email, - "isAdmin": is_admin, - }); - if !password.is_empty() { - body["password"] = serde_json::Value::String(password); - } - let resp = nd_retry(|| { - nd_http_client() - .put(format!("{}/api/user/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - }).await?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) -} - -/// DELETE `/api/user/{id}`. -#[tauri::command] -async fn nd_delete_user( - server_url: String, - token: String, - id: String, -) -> Result<(), String> { - let resp = nd_retry(|| { - nd_http_client() - .delete(format!("{}/api/user/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - }).await?; - let status = resp.status(); - if !status.is_success() { - let text = resp.text().await.unwrap_or_default(); - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(()) -} - -/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list. -/// Available to any authenticated user (no admin required). Returns raw JSON array. -#[tauri::command] -async fn nd_list_songs( - server_url: String, - token: String, - sort: String, - order: String, - start: u32, - end: u32, -) -> Result { - let url = format!( - "{}/api/song?_sort={}&_order={}&_start={}&_end={}", - server_url, sort, order, start, end - ); - let resp = nd_retry(|| { - nd_http_client() - .get(&url) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - }).await?; - if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); - } - resp.json::().await.map_err(nd_err) -} - -/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array. -#[tauri::command] -async fn nd_list_libraries( - server_url: String, - token: String, -) -> Result { - let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/library", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - }).await?; - if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); - } - resp.json::().await.map_err(nd_err) -} - -/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user. -/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400. -#[tauri::command] -async fn nd_set_user_libraries( - server_url: String, - token: String, - id: String, - library_ids: Vec, -) -> Result<(), String> { - let body = serde_json::json!({ "libraryIds": library_ids }); - let resp = nd_retry(|| { - nd_http_client() - .put(format!("{}/api/user/{}/library", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - }).await?; - let status = resp.status(); - if !status.is_success() { - let text = resp.text().await.unwrap_or_default(); - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(()) -} - -/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists. -#[tauri::command] -async fn nd_list_playlists( - server_url: String, - token: String, - smart: Option, -) -> Result { - let resp = nd_retry(|| { - let client = nd_http_client(); - let mut req = client - .get(format!("{}/api/playlist", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)); - if let Some(s) = smart { - req = req.query(&[("smart", s)]); - } - req.send() - }) - .await?; - if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); - } - resp.json::().await.map_err(nd_err) -} - -/// POST `/api/playlist` — create playlist (supports smart rules payload). -#[tauri::command] -async fn nd_create_playlist( - server_url: String, - token: String, - body: serde_json::Value, -) -> Result { - let resp = nd_retry(|| { - nd_http_client() - .post(format!("{}/api/playlist", server_url)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - }) - .await?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(format!("HTTP {}: {}", status, text)); - } - serde_json::from_str(&text).map_err(|e| e.to_string()) -} - -/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload). -#[tauri::command] -async fn nd_update_playlist( - server_url: String, - token: String, - id: String, - body: serde_json::Value, -) -> Result { - let resp = nd_retry(|| { - nd_http_client() - .put(format!("{}/api/playlist/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - }) - .await?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) -} - -/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available). -#[tauri::command] -async fn nd_get_playlist( - server_url: String, - token: String, - id: String, -) -> Result { - let resp = nd_retry(|| { - nd_http_client() - .get(format!("{}/api/playlist/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - }) - .await?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) -} - -/// DELETE `/api/playlist/{id}` — delete playlist. -#[tauri::command] -async fn nd_delete_playlist( - server_url: String, - token: String, - id: String, -) -> Result<(), String> { - let resp = nd_retry(|| { - nd_http_client() - .delete(format!("{}/api/playlist/{}", server_url, id)) - .header("X-ND-Authorization", format!("Bearer {}", token)) - .send() - }) - .await?; - let status = resp.status(); - if !status.is_success() { - let text = resp.text().await.unwrap_or_default(); - return Err(format!("HTTP {}: {}", status, text)); - } - Ok(()) -} - -const RADIO_PAGE_SIZE: u32 = 25; - -/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView). -#[tauri::command] -async fn search_radio_browser(query: String, offset: u32) -> Result, String> { - let client = reqwest::Client::new(); - let limit_s = RADIO_PAGE_SIZE.to_string(); - let offset_s = offset.to_string(); - let resp = client - .get("https://de1.api.radio-browser.info/json/stations/search") - .header("User-Agent", subsonic_wire_user_agent()) - .query(&[ - ("name", query.as_str()), - ("hidebroken", "true"), - ("limit", limit_s.as_str()), - ("offset", offset_s.as_str()), - ]) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - resp.json::>().await.map_err(|e| e.to_string()) -} - -/// Fetch top-voted stations from radio-browser.info for initial suggestions. -#[tauri::command] -async fn get_top_radio_stations(offset: u32) -> Result, String> { - let client = reqwest::Client::new(); - let limit_s = RADIO_PAGE_SIZE.to_string(); - let offset_s = offset.to_string(); - let resp = client - .get("https://de1.api.radio-browser.info/json/stations/topvote") - .header("User-Agent", subsonic_wire_user_agent()) - .query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())]) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - resp.json::>().await.map_err(|e| e.to_string()) -} - -/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS. -/// Returns (bytes, content_type). -#[tauri::command] -async fn fetch_url_bytes(url: String) -> Result<(Vec, String), String> { - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| e.to_string())?; - let resp = client - .get(&url) - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - let content_type = resp - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("image/jpeg") - .split(';') - .next() - .unwrap_or("image/jpeg") - .trim() - .to_string(); - let bytes = resp.bytes().await.map_err(|e| e.to_string())?; - Ok((bytes.to_vec(), content_type)) -} - -/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions. -/// Returns the response body as a UTF-8 string for parsing on the JS side. -#[tauri::command] -async fn fetch_json_url(url: String) -> Result { - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| e.to_string())?; - let resp = client - .get(&url) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| e.to_string())? - .error_for_status() - .map_err(|e| e.to_string())?; - let text = resp.text().await.map_err(|e| e.to_string())?; - Ok(text) -} - -/// ICY metadata response returned to the frontend. -#[derive(serde::Serialize)] -struct IcyMetadata { - /// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`). - stream_title: Option, - /// Value of the `icy-name` response header. - icy_name: Option, - /// Value of the `icy-genre` response header. - icy_genre: Option, - /// Value of the `icy-url` response header. - icy_url: Option, - /// Value of the `icy-description` response header. - icy_description: Option, -} - -/// Extract the first `File1=` stream URL from a PLS playlist file. -fn parse_pls_stream_url(content: &str) -> Option { - content.lines() - .map(str::trim) - .find(|l| l.to_lowercase().starts_with("file1=")) - .and_then(|l| { - let url = l[6..].trim(); - (url.starts_with("http://") || url.starts_with("https://")) - .then(|| url.to_string()) - }) -} - -/// Extract the first non-comment HTTP URL from an M3U/M3U8 playlist file. -fn parse_m3u_stream_url(content: &str) -> Option { - content.lines() - .map(str::trim) - .find(|l| !l.is_empty() && !l.starts_with('#') - && (l.starts_with("http://") || l.starts_with("https://"))) - .map(str::to_string) -} - -/// If `url` points to a PLS or M3U playlist, fetch it and return the first -/// stream URL it contains. Returns `None` for direct stream URLs. -async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option { - let path = url.split('?').next().unwrap_or(url).to_lowercase(); - let is_pls = path.ends_with(".pls"); - let is_m3u = path.ends_with(".m3u") || path.ends_with(".m3u8"); - if !is_pls && !is_m3u { - return None; - } - - let resp = client - .get(url) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await - .ok()?; - - let ct = resp - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_lowercase(); - - let text = resp.text().await.ok()?; - - if is_pls || ct.contains("scpls") || ct.contains("pls+xml") { - parse_pls_stream_url(&text) - } else { - parse_m3u_stream_url(&text) - } -} - -/// Fetch ICY in-stream metadata from a radio stream URL. -/// -/// Sends a GET request with `Icy-MetaData: 1` and reads just enough bytes -/// (up to `icy-metaint` audio bytes plus the following metadata block) to -/// extract the `StreamTitle`. The connection is dropped as soon as the -/// first metadata chunk has been parsed, so bandwidth usage is minimal. -/// -/// If `url` is a PLS or M3U playlist file it is resolved to the first direct -/// stream URL before the ICY request is made. -#[tauri::command] -async fn fetch_icy_metadata(url: String) -> Result { - use futures_util::StreamExt; - - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(std::time::Duration::from_secs(15)) - .build() - .map_err(|e| e.to_string())?; - - // Resolve PLS/M3U playlist files to their first direct stream URL. - let url = resolve_playlist_url(&client, &url).await.unwrap_or(url); - - let resp = client - .get(&url) - .header("Icy-MetaData", "1") - .send() - .await - .map_err(|e| e.to_string())?; - - // Harvest ICY headers before consuming the body. - let headers = resp.headers(); - let icy_name = headers.get("icy-name").and_then(|v| v.to_str().ok()).map(str::to_string); - let icy_genre = headers.get("icy-genre").and_then(|v| v.to_str().ok()).map(str::to_string); - let icy_url = headers.get("icy-url").and_then(|v| v.to_str().ok()).map(str::to_string); - let icy_description = headers.get("icy-description").and_then(|v| v.to_str().ok()).map(str::to_string); - let metaint: Option = headers - .get("icy-metaint") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse().ok()); - - // If the server doesn't advertise a metaint we can still return header info. - let Some(metaint) = metaint else { - return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description }); - }; - - // Cap metaint at 64 KiB to avoid reading unreasonably large audio chunks. - let metaint = metaint.min(65_536); - let needed = metaint + 1; // +1 for the metadata-length byte - - let mut buf: Vec = Vec::with_capacity(needed + 256); - let mut stream = resp.bytes_stream(); - - while buf.len() < needed { - let Some(chunk) = stream.next().await else { break }; - let chunk = chunk.map_err(|e| e.to_string())?; - buf.extend_from_slice(&chunk); - } - - if buf.len() < needed { - // Stream ended before we reached the metadata block. - return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description }); - } - - // The byte immediately after `metaint` audio bytes encodes metadata length: - // actual_bytes = length_byte * 16 - let meta_len = buf[metaint] as usize * 16; - if meta_len == 0 { - return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description }); - } - - // We may need to read a few more chunks to get the full metadata block. - let total_needed = needed + meta_len; - while buf.len() < total_needed { - let Some(chunk) = stream.next().await else { break }; - let chunk = chunk.map_err(|e| e.to_string())?; - buf.extend_from_slice(&chunk); - } - - let meta_start = needed; // index of first metadata byte - let meta_end = (meta_start + meta_len).min(buf.len()); - let meta_bytes = &buf[meta_start..meta_end]; - - // ICY metadata is Latin-1 encoded; convert to a Rust String lossily. - let meta_str: String = meta_bytes - .iter() - .map(|&b| if b == 0 { '\0' } else { b as char }) - .collect::(); - - // Parse StreamTitle='...' — value ends at the next unescaped single-quote. - let stream_title = meta_str - .split("StreamTitle='") - .nth(1) - .and_then(|s| { - // Find closing quote that is NOT preceded by a backslash. - let mut prev = '\0'; - let mut end = s.len(); - for (i, c) in s.char_indices() { - if c == '\'' && prev != '\\' { - end = i; - break; - } - prev = c; - } - let title = s[..end].trim().to_string(); - if title.is_empty() { None } else { Some(title) } - }); - - Ok(IcyMetadata { stream_title, icy_name, icy_genre, icy_url, icy_description }) -} - -/// Resolve a PLS or M3U playlist URL to its first direct stream URL. -/// Returns the original URL unchanged if it is not a recognised playlist format -/// or if the playlist cannot be fetched/parsed. -#[tauri::command] -async fn resolve_stream_url(url: String) -> String { - let Ok(client) = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - else { - return url; - }; - resolve_playlist_url(&client, &url).await.unwrap_or(url) -} - -/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions. -/// `params` is a list of [key, value] pairs (method must be included). -/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made. -#[tauri::command] -async fn lastfm_request( - params: Vec<[String; 2]>, - sign: bool, - get: bool, - api_key: String, - api_secret: String, -) -> Result { - use std::collections::HashMap; - - let mut map: HashMap = params.into_iter().map(|[k, v]| (k, v)).collect(); - map.insert("api_key".into(), api_key.clone()); - - if sign { - let mut keys: Vec = map.keys().cloned().collect(); - keys.sort(); - let sig_str: String = keys.iter() - .filter(|k| k.as_str() != "format" && k.as_str() != "callback") - .map(|k| format!("{}{}", k, map[k])) - .collect::(); - let sig_input = format!("{}{}", sig_str, api_secret); - let digest = md5::compute(sig_input.as_bytes()); - map.insert("api_sig".into(), format!("{:x}", digest)); - } - - map.insert("format".into(), "json".into()); - - let client = reqwest::Client::new(); - let resp = if get { - client - .get("https://ws.audioscrobbler.com/2.0/") - .query(&map) - .header("User-Agent", subsonic_wire_user_agent()) - .send() - .await - } else { - client - .post("https://ws.audioscrobbler.com/2.0/") - .form(&map) - .header("User-Agent", subsonic_wire_user_agent()) - .send() - .await - }.map_err(|e| e.to_string())?; - - let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; - - if let Some(err) = json.get("error") { - return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or(""))); - } - - Ok(json) -} - - -#[tauri::command] -fn register_global_shortcut( - app: tauri::AppHandle, - shortcut_map: tauri::State, - shortcut: String, - action: String, -) -> Result<(), String> { - use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; - - let mut map = shortcut_map.lock().unwrap(); - - // Idempotent: if this exact shortcut+action is already registered, skip. - // This prevents on_shortcut() from accumulating duplicate handlers when - // registerAll() is called again after a JS HMR reload or StrictMode double-effect. - if map.get(&shortcut).map(|a| a == &action).unwrap_or(false) { - return Ok(()); - } - - // Unregister any existing OS grab for this shortcut before re-registering. - if let Ok(s) = shortcut.parse::() { - let _ = app.global_shortcut().unregister(s); - } - map.insert(shortcut.clone(), action.clone()); - drop(map); // release lock before the blocking OS call - - let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; - app.global_shortcut() - .on_shortcut(parsed, move |app, _shortcut, event| { - if event.state == ShortcutState::Pressed { - let event_name = match action.as_str() { - "play-pause" => "media:play-pause", - "next" => "media:next", - "prev" => "media:prev", - "volume-up" => "media:volume-up", - "volume-down" => "media:volume-down", - _ => return, - }; - let _ = app.emit(event_name, ()); - } - }) - .map_err(|e| e.to_string()) -} - -#[tauri::command] -fn unregister_global_shortcut( - app: tauri::AppHandle, - shortcut_map: tauri::State, - shortcut: String, -) -> Result<(), String> { - use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; - shortcut_map.lock().unwrap().remove(&shortcut); - let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; - app.global_shortcut().unregister(parsed).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn mpris_set_metadata( - controls: tauri::State, - title: Option, - artist: Option, - album: Option, - cover_url: Option, - duration_secs: Option, -) -> Result<(), String> { - use souvlaki::MediaMetadata; - use std::time::Duration; - - let duration = duration_secs.map(|s| Duration::from_secs_f64(s)); - let mut guard = controls.lock().unwrap(); - let Some(ctrl) = guard.as_mut() else { return Ok(()); }; - ctrl.set_metadata(MediaMetadata { - title: title.as_deref(), - artist: artist.as_deref(), - album: album.as_deref(), - cover_url: cover_url.as_deref(), - duration, - }) - .map_err(|e| format!("MPRIS set_metadata failed: {e:?}")) -} - -#[tauri::command] -fn mpris_set_playback( - controls: tauri::State, - playing: bool, - position_secs: Option, -) -> Result<(), String> { - use souvlaki::{MediaPlayback, MediaPosition}; - use std::time::Duration; - - let progress = position_secs.map(|s| MediaPosition(Duration::from_secs_f64(s))); - let playback = if playing { - MediaPlayback::Playing { progress } - } else { - MediaPlayback::Paused { progress } - }; - let mut guard = controls.lock().unwrap(); - let Some(ctrl) = guard.as_mut() else { return Ok(()); }; - ctrl.set_playback(playback) - .map_err(|e| format!("MPRIS set_playback failed: {e:?}")) -} - -/// Returns true if `path` is an accessible directory (used for pre-flight checks in the frontend). -#[tauri::command] -fn check_dir_accessible(path: String) -> bool { - std::path::Path::new(&path).is_dir() -} - -#[tauri::command] -fn analysis_get_waveform( - track_id: String, - md5_16kb: String, - cache: tauri::State<'_, analysis_cache::AnalysisCache>, -) -> Result, String> { - let key = analysis_cache::TrackKey { - track_id: track_id.clone(), - md5_16kb: md5_16kb.clone(), - }; - let row = cache.get_waveform(&key)?; - match &row { - Some(v) => { - crate::app_deprintln!( - "[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}", - track_id, - md5_16kb, - v.bins.len(), - v.bin_count, - v.updated_at - ); - } - None => { - crate::app_deprintln!( - "[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}", - track_id, - md5_16kb - ); - } - } - Ok(row.map(|v| WaveformCachePayload { - bins: v.bins, - bin_count: v.bin_count, - is_partial: v.is_partial, - known_until_sec: v.known_until_sec, - duration_sec: v.duration_sec, - updated_at: v.updated_at, - })) -} - -#[tauri::command] -fn analysis_get_waveform_for_track( - track_id: String, - cache: tauri::State<'_, analysis_cache::AnalysisCache>, -) -> Result, String> { - let row = cache.get_latest_waveform_for_track(&track_id)?; - match &row { - Some(v) => { - crate::app_deprintln!( - "[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}", - track_id, - v.bins.len(), - v.bin_count, - v.updated_at - ); - } - None => { - crate::app_deprintln!( - "[analysis][waveform] db miss track_id={}", - track_id - ); - } - } - Ok(row.map(|v| WaveformCachePayload { - bins: v.bins, - bin_count: v.bin_count, - is_partial: v.is_partial, - known_until_sec: v.known_until_sec, - duration_sec: v.duration_sec, - updated_at: v.updated_at, - })) -} - -#[tauri::command] -fn analysis_get_loudness_for_track( - track_id: String, - target_lufs: Option, - cache: tauri::State<'_, analysis_cache::AnalysisCache>, -) -> Result, String> { - let row = cache.get_latest_loudness_for_track(&track_id)?; - Ok(row.map(|v| { - let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0); - let recommended_gain_db = analysis_cache::recommended_gain_for_target( - v.integrated_lufs, - v.true_peak, - requested_target, - ); - LoudnessCachePayload { - integrated_lufs: v.integrated_lufs, - true_peak: v.true_peak, - recommended_gain_db, - target_lufs: requested_target, - updated_at: v.updated_at, - }})) -} - -#[tauri::command] -fn analysis_delete_loudness_for_track( - track_id: String, - cache: tauri::State<'_, analysis_cache::AnalysisCache>, -) -> Result { - cache.delete_loudness_for_track_id(&track_id) -} - -#[tauri::command] -fn analysis_delete_all_waveforms( - cache: tauri::State<'_, analysis_cache::AnalysisCache>, -) -> Result { - cache.delete_all_waveforms() -} - -#[tauri::command] -fn analysis_enqueue_seed_from_url( - track_id: String, - url: String, - force: Option, - app: tauri::AppHandle, -) -> Result<(), String> { - if track_id.trim().is_empty() || url.trim().is_empty() { - return Ok(()); - } - let force = force.unwrap_or(false); - if !force { - if let Some(engine) = app.try_state::() { - if crate::audio::ranged_loudness_backfill_should_defer(&engine, &track_id) { - crate::app_deprintln!( - "[analysis] backfill skip track_id={} reason=ranged_playback_will_seed", - track_id - ); - return Ok(()); - } - } - } - if !force { - if let Some(cache) = app.try_state::() { - if cache.get_latest_loudness_for_track(&track_id)?.is_some() { - crate::app_deprintln!( - "[analysis] backfill skip (already cached): {}", - track_id - ); - return Ok(()); - } - } - } - let tid_log = track_id.clone(); - let high_priority = analysis_backfill_is_current_track(&app, &track_id); - let shared = analysis_backfill_shared(&app); - let kind = { - let mut st = shared - .state - .lock() - .map_err(|_| "analysis backfill lock poisoned".to_string())?; - st.enqueue(track_id, url, high_priority) - }; - match kind { - AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => { - shared.ping_worker(); - crate::app_deprintln!( - "[analysis] backfill enqueued: track_id={} position={}", - tid_log, - if high_priority { "front" } else { "back" } - ); - } - AnalysisBackfillEnqueueKind::ReorderedFront => { - shared.ping_worker(); - crate::app_deprintln!( - "[analysis] backfill bumped to front (current track) track_id={}", - tid_log - ); - } - AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {} - } - Ok(()) -} - -// ─── Offline Track Cache ────────────────────────────────────────────────────── - -/// Streams an HTTP response body directly to `dest_path` in small chunks. -/// Never buffers the full file in memory — keeps RAM flat regardless of file size. -async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path) -> Result<(), String> { - use futures_util::StreamExt; - use tokio::io::AsyncWriteExt; - - let mut file = tokio::fs::File::create(dest_path) - .await - .map_err(|e| e.to_string())?; - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| e.to_string())?; - file.write_all(&chunk).await.map_err(|e| e.to_string())?; - } - file.flush().await.map_err(|e| e.to_string())?; - Ok(()) -} - -async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result { - let high = analysis_backfill_is_current_track(app, track_id); - let outcome = submit_analysis_cpu_seed( - app.clone(), - track_id.to_string(), - bytes.to_vec(), - high, - ) - .await - .map_err(|e| { - crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e); - e - })?; - let has_loudness = app - .try_state::() - .and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten()) - .is_some(); - crate::app_deprintln!( - "[analysis] seed result track_id={} bytes={} has_loudness={} outcome={outcome:?}", - track_id, - bytes.len(), - has_loudness - ); - Ok(has_loudness) -} - -async fn enqueue_analysis_seed_from_file( - app: &tauri::AppHandle, - track_id: &str, - file_path: &std::path::Path, -) { - let bytes = match tokio::fs::read(file_path).await { - Ok(v) => v, - Err(_) => return, - }; - if bytes.is_empty() { - return; - } - let _ = enqueue_analysis_seed(app, track_id, &bytes).await; -} - -/// Downloads a single track to the app's offline cache directory. -/// Returns the absolute file path so TypeScript can store it and later -/// construct a `psysonic-local://` URL for the audio engine. -#[tauri::command] -async fn download_track_offline( - track_id: String, - server_id: String, - url: String, - suffix: String, - custom_dir: Option, - dl_sem: tauri::State<'_, DownloadSemaphore>, - app: tauri::AppHandle, -) -> Result { - // Determine base cache directory. - let cache_dir = if let Some(ref cd) = custom_dir { - let base = std::path::PathBuf::from(cd); - // Check that the volume/directory is still accessible. - if !base.exists() { - return Err("VOLUME_NOT_FOUND".to_string()); - } - base.join(&server_id) - } else { - app.path() - .app_data_dir() - .map_err(|e| e.to_string())? - .join("psysonic-offline") - .join(&server_id) - }; - - tokio::fs::create_dir_all(&cache_dir) - .await - .map_err(|e| e.to_string())?; - - let file_path = cache_dir.join(format!("{}.{}", track_id, suffix)); - let path_str = file_path.to_string_lossy().to_string(); - - // Already cached — skip re-download (no semaphore needed). - if file_path.exists() { - return Ok(path_str); - } - - // Acquire a download slot. The permit is held for the duration of the HTTP transfer - // and released automatically when this function returns (success or error). - let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?; - - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| e.to_string())?; - - let response = client.get(&url).send().await.map_err(|e| e.to_string())?; - if !response.status().is_success() { - return Err(format!("HTTP {}", response.status().as_u16())); - } - - // Stream directly to a .part file; rename on success to avoid partial files. - let part_path = file_path.with_extension(format!("{suffix}.part")); - if let Err(e) = stream_to_file(response, &part_path).await { - let _ = tokio::fs::remove_file(&part_path).await; - return Err(e); - } - tokio::fs::rename(&part_path, &file_path) - .await - .map_err(|e| e.to_string())?; - - enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await; - - Ok(path_str) -} - -/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir). -#[tauri::command] -async fn get_offline_cache_size(custom_dir: Option, app: tauri::AppHandle) -> u64 { - fn dir_size(root: std::path::PathBuf) -> u64 { - if !root.exists() { return 0; } - let mut total: u64 = 0; - let mut stack = vec![root]; - while let Some(dir) = stack.pop() { - let rd = match std::fs::read_dir(&dir) { - Ok(r) => r, - Err(_) => continue, - }; - for entry in rd.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if let Ok(meta) = std::fs::metadata(&path) { - total += meta.len(); - } - } - } - total - } - - let default_dir = match app.path().app_data_dir() { - Ok(d) => d.join("psysonic-offline"), - Err(_) => return 0, - }; - let mut total = dir_size(default_dir); - - if let Some(cd) = custom_dir { - let custom = std::path::PathBuf::from(cd); - if custom != std::path::PathBuf::from("") { - total += dir_size(custom); - } - } - total -} - -/// Removes a cached track from the offline cache. Accepts the full local path -/// (stored in OfflineTrackMeta) so it works regardless of which directory was used. -/// After deleting the file, empty parent directories up to (but not including) -/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`). -#[tauri::command] -async fn delete_offline_track( - local_path: String, - base_dir: Option, - app: tauri::AppHandle, -) -> Result<(), String> { - let file_path = std::path::PathBuf::from(&local_path); - if file_path.exists() { - tokio::fs::remove_file(&file_path) - .await - .map_err(|e| e.to_string())?; - } - - // Determine the safe boundary — never delete at or above this directory. - let boundary = if let Some(bd) = base_dir.filter(|s| !s.is_empty()) { - std::path::PathBuf::from(bd) - } else { - app.path() - .app_data_dir() - .map_err(|e| e.to_string())? - .join("psysonic-offline") - }; - - // Walk upward, pruning directories that have become empty. - // Stops as soon as a non-empty directory or the boundary is reached. - let mut current = file_path.parent().map(|p| p.to_path_buf()); - while let Some(dir) = current { - if dir == boundary || !dir.starts_with(&boundary) { - break; - } - match std::fs::read_dir(&dir) { - Ok(mut entries) => { - if entries.next().is_some() { - break; // Directory still has contents — stop pruning. - } - if std::fs::remove_dir(&dir).is_err() { - break; // Could not remove (e.g. permissions) — stop. - } - current = dir.parent().map(|p| p.to_path_buf()); - } - Err(_) => break, - } - } - - Ok(()) -} - -// ─── Hot playback cache (ephemeral; queue-based prefetch) ───────────────────── - -fn resolve_hot_cache_root( - custom_dir: Option, - app: &tauri::AppHandle, -) -> Result { - if let Some(ref cd) = custom_dir.filter(|s| !s.is_empty()) { - let base = std::path::PathBuf::from(cd); - if !base.exists() { - return Err("VOLUME_NOT_FOUND".to_string()); - } - Ok(base.join("psysonic-hot-cache")) - } else { - Ok(app - .path() - .app_data_dir() - .map_err(|e| e.to_string())? - .join("psysonic-hot-cache")) - } -} - -/// Returns true if the current Linux system is Arch-based -/// (checks /etc/arch-release and /etc/os-release). -#[tauri::command] -fn check_arch_linux() -> bool { - #[cfg(target_os = "linux")] - { - if std::path::Path::new("/etc/arch-release").exists() { - return true; - } - if let Ok(content) = std::fs::read_to_string("/etc/os-release") { - for line in content.lines() { - let lower = line.to_lowercase(); - if lower.starts_with("id=arch") { return true; } - if lower.starts_with("id_like=") && lower.contains("arch") { return true; } - } - } - false - } - #[cfg(not(target_os = "linux"))] - { false } -} - -/// Progress payload emitted during an update binary download. -#[derive(Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdateDownloadProgress { - bytes: u64, - total: Option, -} - -/// Downloads an update installer/package to the user's Downloads folder. -/// Emits `update:download:progress` events with `{ bytes, total }` every 250 ms. -/// Returns the final absolute file path on success. -#[tauri::command] -async fn download_update(url: String, filename: String, app: tauri::AppHandle) -> Result { - use futures_util::StreamExt; - use std::time::{Duration, Instant}; - use tokio::io::AsyncWriteExt; - - const EMIT_INTERVAL: Duration = Duration::from_millis(250); - - let dest_dir = app.path().download_dir().map_err(|e| e.to_string())?; - let dest_path = dest_dir.join(&filename); - let part_path = dest_dir.join(format!("{}.part", filename)); - - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .connect_timeout(Duration::from_secs(30)) - .timeout(Duration::from_secs(3600)) - .build() - .map_err(|e| e.to_string())?; - - let response = client.get(&url).send().await.map_err(|e| e.to_string())?; - if !response.status().is_success() { - return Err(format!("HTTP {}", response.status().as_u16())); - } - - let total = response.content_length(); - - let result: Result = async { - let mut file = tokio::fs::File::create(&part_path) - .await - .map_err(|e| e.to_string())?; - - let mut bytes_done: u64 = 0; - let mut stream = response.bytes_stream(); - let mut last_emit = Instant::now(); - - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| e.to_string())?; - file.write_all(&chunk).await.map_err(|e| e.to_string())?; - bytes_done += chunk.len() as u64; - - if last_emit.elapsed() >= EMIT_INTERVAL { - let _ = app.emit("update:download:progress", UpdateDownloadProgress { - bytes: bytes_done, - total, - }); - last_emit = Instant::now(); - } - } - file.flush().await.map_err(|e| e.to_string())?; - Ok(bytes_done) - }.await; - - match result { - Err(e) => { - let _ = tokio::fs::remove_file(&part_path).await; - Err(e) - } - Ok(bytes_done) => { - let _ = app.emit("update:download:progress", UpdateDownloadProgress { - bytes: bytes_done, - total: Some(bytes_done), - }); - tokio::fs::rename(&part_path, &dest_path) - .await - .map_err(|e| e.to_string())?; - Ok(dest_path.to_string_lossy().into_owned()) - } - } -} - -/// Fetches synced lyrics from Netease Cloud Music for a given artist + title. -/// Performs a track search, then fetches the LRC string for the best match. -/// Returns `None` if no match or no lyrics are found. -#[tauri::command] -async fn fetch_netease_lyrics(artist: String, title: String) -> Result, String> { - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(std::time::Duration::from_secs(8)) - .build() - .map_err(|e| e.to_string())?; - - let query = format!("{} {}", artist, title); - let params = [("s", query.as_str()), ("type", "1"), ("limit", "5")]; - let search: serde_json::Value = client - .post("https://music.163.com/api/search/get") - .header("Referer", "https://music.163.com") - .form(¶ms) - .send() - .await - .map_err(|e| e.to_string())? - .json() - .await - .map_err(|e| e.to_string())?; - - let song_id = match search["result"]["songs"][0]["id"].as_i64() { - Some(id) => id, - None => return Ok(None), - }; - - let lyrics: serde_json::Value = client - .get(format!( - "https://music.163.com/api/song/lyric?id={}&lv=1&kv=1&tv=-1", - song_id - )) - .header("Referer", "https://music.163.com") - .send() - .await - .map_err(|e| e.to_string())? - .json() - .await - .map_err(|e| e.to_string())?; - - let lrc = lyrics["lrc"]["lyric"].as_str().unwrap_or("").trim().to_string(); - Ok(if lrc.is_empty() { None } else { Some(lrc) }) -} - -/// Reads embedded synced / unsynced lyrics from a local audio file. -/// -/// Priority order: -/// MP3 → ID3v2 SYLT (synchronized, ms timestamps) → ID3v2 USLT (plain) -/// FLAC → Vorbis SYNCEDLYRICS (LRC string) → Vorbis LYRICS (plain) -/// -/// Returns a standard LRC string (`[mm:ss.cc]line\n…`) for synced lyrics, -/// or plain text for unsynced lyrics. Returns `None` when no lyrics are found. -/// Errors are silenced and mapped to `None` so the frontend falls through to the -/// next lyrics source without crashing. -#[tauri::command] -fn get_embedded_lyrics(path: String) -> Option { - use lofty::file::FileType; - use lofty::prelude::*; - use lofty::probe::Probe; - - let fpath = std::path::Path::new(&path); - if !fpath.exists() { - return None; - } - - // Detect file type from magic bytes only — no full tag read yet. - // guess_file_type() consumes self and returns Self, so reassign. - let probe = Probe::open(fpath).ok()?; - let probe = probe.guess_file_type().ok()?; - let file_type = probe.file_type(); - - // ── MP3 / MPEG: use the `id3` crate for SYLT / USLT ───────────────────── - // lofty's MpegFile::id3v2_tag field is pub(crate) — not accessible here. - // The `id3` crate exposes a clean public API for typed ID3v2 frames. - if matches!(file_type, Some(FileType::Mpeg)) { - use id3::{Content, Tag as Id3Tag}; - - if let Ok(tag) = Id3Tag::read_from_path(fpath) { - // 1. SYLT — millisecond-timestamped synced lyrics. - for frame in tag.frames() { - if frame.id() != "SYLT" { - continue; - } - if let Content::SynchronisedLyrics(sylt) = frame.content() { - // Only accept millisecond timestamps — MPEG-frame-based - // timestamps can't be converted to wall-clock seconds. - if sylt.timestamp_format != id3::frame::TimestampFormat::Ms { - continue; - } - let lrc: String = sylt - .content - .iter() - .filter_map(|(ms, text)| { - let t = text.trim(); - if t.is_empty() { - return None; - } - let mins = ms / 60_000; - let secs = (ms % 60_000) / 1_000; - let cs = (ms % 1_000) / 10; - // [mm:ss.cc] matches parseLrc's /\d+(?:\.\d*)?/ regex - Some(format!("[{:02}:{:02}.{:02}]{}\n", mins, secs, cs, t)) - }) - .collect(); - if !lrc.is_empty() { - return Some(lrc.trim_end().to_owned()); - } - } - } - - // 2. USLT — unsynchronized lyrics, plain-text fallback. - for frame in tag.frames() { - if frame.id() != "USLT" { - continue; - } - if let Content::Lyrics(uslt) = frame.content() { - let text = uslt.text.trim(); - if !text.is_empty() { - return Some(text.to_owned()); - } - } - } - } - return None; // MPEG file but no usable lyrics found - } - - // ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ──────────────────── - // Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field. - // It is not a known lofty ItemKey, so access it via ItemKey::Unknown. - let tagged = probe.read().ok()?; - for tag in tagged.tags() { - if let Some(lrc) = tag.get_string(&ItemKey::Unknown("SYNCEDLYRICS".to_owned())) { - let lrc = lrc.trim(); - if !lrc.is_empty() { - return Some(lrc.to_owned()); - } - } - if let Some(plain) = tag.get_string(&ItemKey::Lyrics) { - let plain = plain.trim(); - if !plain.is_empty() { - return Some(plain.to_owned()); - } - } - } - - None -} - -/// Opens a directory in the OS file manager (Explorer / Finder / Nautilus). -/// Uses platform-specific process spawning — tauri-plugin-shell's open() only -/// allows https:// URLs per the capability scope and fails silently for paths. -#[tauri::command] -fn open_folder(path: String) -> Result<(), String> { - #[cfg(target_os = "windows")] - { - std::process::Command::new("explorer.exe") - .arg(&path) - .spawn() - .map_err(|e| e.to_string())?; - } - #[cfg(target_os = "macos")] - { - std::process::Command::new("open") - .arg(&path) - .spawn() - .map_err(|e| e.to_string())?; - } - #[cfg(target_os = "linux")] - { - std::process::Command::new("xdg-open") - .arg(&path) - .spawn() - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// Progress payload emitted to the frontend during a ZIP download. -/// `total` is `None` when the server doesn't send a `Content-Length` header -/// (Navidrome on-the-fly ZIPs). -#[derive(Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct ZipProgress { - id: String, - bytes: u64, - total: Option, -} - -/// Downloads a server-generated ZIP (album/playlist) directly to disk via streaming. -/// Emits `download:zip:progress` events every 500 ms so the frontend can show -/// live MB-counter without holding any binary data in the WebView process. -/// Returns the final destination path on success. -#[tauri::command] -async fn download_zip( - id: String, - url: String, - dest_path: String, - app: tauri::AppHandle, -) -> Result { - use futures_util::StreamExt; - use std::time::{Duration, Instant}; - use tokio::io::AsyncWriteExt; - - const EMIT_INTERVAL: Duration = Duration::from_millis(500); - - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .connect_timeout(Duration::from_secs(30)) - .timeout(Duration::from_secs(7200)) // up to 2 h for large on-the-fly ZIPs - .build() - .map_err(|e| e.to_string())?; - - let response = client.get(&url).send().await.map_err(|e| e.to_string())?; - if !response.status().is_success() { - return Err(format!("HTTP {}", response.status().as_u16())); - } - - let total = response.content_length(); // None for Navidrome on-the-fly ZIPs - let part_path = format!("{dest_path}.part"); - - // Stream to .part file; rename on success, delete on error. - let result: Result = async { - let mut file = tokio::fs::File::create(&part_path) - .await - .map_err(|e| e.to_string())?; - - let mut bytes_done: u64 = 0; - let mut stream = response.bytes_stream(); - let mut last_emit = Instant::now(); - - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| e.to_string())?; - file.write_all(&chunk).await.map_err(|e| e.to_string())?; - bytes_done += chunk.len() as u64; - - if last_emit.elapsed() >= EMIT_INTERVAL { - let _ = app.emit("download:zip:progress", ZipProgress { - id: id.clone(), - bytes: bytes_done, - total, - }); - last_emit = Instant::now(); - } - } - file.flush().await.map_err(|e| e.to_string())?; - Ok(bytes_done) - }.await; - - match result { - Err(e) => { - let _ = tokio::fs::remove_file(&part_path).await; - Err(e) - } - Ok(bytes_done) => { - // Final emission so the frontend sees 100 % (or final MB count). - let _ = app.emit("download:zip:progress", ZipProgress { - id: id.clone(), - bytes: bytes_done, - total: Some(bytes_done), - }); - tokio::fs::rename(&part_path, &dest_path) - .await - .map_err(|e| e.to_string())?; - Ok(dest_path) - } - } -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct HotCacheDownloadResult { - path: String, - size: u64, -} - -/// Downloads a single track into the hot playback cache (separate from offline library). -/// Optional `custom_dir`: parent folder; files go under `/psysonic-hot-cache//`. -/// Returns absolute path and file size for `psysonic-local://` URLs. -#[tauri::command] -async fn download_track_hot_cache( - track_id: String, - server_id: String, - url: String, - suffix: String, - custom_dir: Option, - app: tauri::AppHandle, -) -> Result { - let root = resolve_hot_cache_root(custom_dir, &app)?; - let cache_dir = root.join(&server_id); - - tokio::fs::create_dir_all(&cache_dir) - .await - .map_err(|e| e.to_string())?; - - let file_path = cache_dir.join(format!("{}.{}", track_id, suffix)); - let path_str = file_path.to_string_lossy().to_string(); - - if file_path.exists() { - let size = tokio::fs::metadata(&file_path) - .await - .map(|m| m.len()) - .unwrap_or(0); - crate::app_deprintln!( - "[hot-cache] download disk_hit track_id={} server_id={} bytes={}", - track_id, - server_id, - size - ); - // Disk hit: still seed analysis, but do not block the command (full-file read); the - // prefetch worker runs invokes sequentially. - let app_seed = app.clone(); - let tid = track_id.clone(); - let fp = file_path.clone(); - tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; - }); - return Ok(HotCacheDownloadResult { - path: path_str, - size, - }); - } - - crate::app_deprintln!( - "[hot-cache] download http_start track_id={} server_id={}", - track_id, - server_id - ); - - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| e.to_string())?; - - let response = client.get(&url).send().await.map_err(|e| e.to_string())?; - if !response.status().is_success() { - return Err(format!("HTTP {}", response.status().as_u16())); - } - - // Stream directly to a .part file; rename on success to avoid partial files. - let part_path = file_path.with_extension(format!("{suffix}.part")); - if let Err(e) = stream_to_file(response, &part_path).await { - let _ = tokio::fs::remove_file(&part_path).await; - return Err(e); - } - tokio::fs::rename(&part_path, &file_path) - .await - .map_err(|e| e.to_string())?; - - let app_seed = app.clone(); - let tid = track_id.clone(); - let fp = file_path.clone(); - tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; - }); - - let size = tokio::fs::metadata(&file_path) - .await - .map(|m| m.len()) - .unwrap_or(0); - crate::app_deprintln!( - "[hot-cache] download http_done track_id={} server_id={} bytes={}", - track_id, - server_id, - size - ); - Ok(HotCacheDownloadResult { - path: path_str, - size, - }) -} - -/// Promotes bytes captured by the manual streaming path into hot cache on disk. -/// Returns `Ok(None)` when no completed stream cache is available for this URL. -#[tauri::command] -async fn promote_stream_cache_to_hot_cache( - track_id: String, - server_id: String, - url: String, - suffix: String, - custom_dir: Option, - app: tauri::AppHandle, - state: tauri::State<'_, audio::AudioEngine>, -) -> Result, String> { - let root = resolve_hot_cache_root(custom_dir, &app)?; - let cache_dir = root.join(&server_id); - tokio::fs::create_dir_all(&cache_dir) - .await - .map_err(|e| e.to_string())?; - - let file_path = cache_dir.join(format!("{}.{}", track_id, suffix)); - let path_str = file_path.to_string_lossy().to_string(); - - if file_path.exists() { - let size = tokio::fs::metadata(&file_path) - .await - .map(|m| m.len()) - .unwrap_or(0); - crate::app_deprintln!( - "[hot-cache] promote disk_hit track_id={} server_id={} bytes={}", - track_id, - server_id, - size - ); - let app_seed = app.clone(); - let tid = track_id.clone(); - let fp = file_path.clone(); - tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; - }); - return Ok(Some(HotCacheDownloadResult { path: path_str, size })); - } - - let bytes = match audio::take_stream_completed_for_url(&state, &url) { - Some(b) => b, - None => { - crate::app_deprintln!( - "[hot-cache] promote skip track_id={} reason=no_completed_stream_for_url", - track_id - ); - return Ok(None); - } - }; - - let part_path = file_path.with_extension(format!("{suffix}.part")); - if let Err(e) = tokio::fs::write(&part_path, &bytes).await { - let _ = tokio::fs::remove_file(&part_path).await; - return Err(e.to_string()); - } - tokio::fs::rename(&part_path, &file_path) - .await - .map_err(|e| e.to_string())?; - - let _ = enqueue_analysis_seed(&app, &track_id, &bytes).await; - - let size = tokio::fs::metadata(&file_path) - .await - .map(|m| m.len()) - .unwrap_or(0); - crate::app_deprintln!( - "[hot-cache] promote from_stream track_id={} server_id={} bytes={}", - track_id, - server_id, - size - ); - Ok(Some(HotCacheDownloadResult { path: path_str, size })) -} - -#[tauri::command] -async fn get_hot_cache_size(custom_dir: Option, app: tauri::AppHandle) -> u64 { - fn dir_size(root: std::path::PathBuf) -> u64 { - if !root.exists() { - return 0; - } - let mut total: u64 = 0; - let mut stack = vec![root]; - while let Some(dir) = stack.pop() { - let rd = match std::fs::read_dir(&dir) { - Ok(r) => r, - Err(_) => continue, - }; - for entry in rd.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if let Ok(meta) = std::fs::metadata(&path) { - total += meta.len(); - } - } - } - total - } - - resolve_hot_cache_root(custom_dir, &app) - .map(|root| dir_size(root)) - .unwrap_or(0) -} - -#[tauri::command] -async fn delete_hot_cache_track( - local_path: String, - custom_dir: Option, - app: tauri::AppHandle, -) -> Result<(), String> { - let file_path = std::path::PathBuf::from(&local_path); - let existed = file_path.exists(); - if existed { - tokio::fs::remove_file(&file_path) - .await - .map_err(|e| e.to_string())?; - } - crate::app_deprintln!( - "[hot-cache] delete file existed={} path_suffix={}", - existed, - file_path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("?") - ); - - let boundary = resolve_hot_cache_root(custom_dir, &app)?; - - let mut current = file_path.parent().map(|p| p.to_path_buf()); - while let Some(dir) = current { - if dir == boundary || !dir.starts_with(&boundary) { - break; - } - match std::fs::read_dir(&dir) { - Ok(mut entries) => { - if entries.next().is_some() { - break; - } - if std::fs::remove_dir(&dir).is_err() { - break; - } - current = dir.parent().map(|p| p.to_path_buf()); - } - Err(_) => break, - } - } - - Ok(()) -} - -/// Removes the entire hot cache root (`psysonic-hot-cache` for the active location). -#[tauri::command] -async fn purge_hot_cache(custom_dir: Option, app: tauri::AppHandle) -> Result<(), String> { - let dir = resolve_hot_cache_root(custom_dir, &app)?; - let existed = dir.exists(); - if existed { - tokio::fs::remove_dir_all(&dir) - .await - .map_err(|e| e.to_string())?; - } - crate::app_deprintln!( - "[hot-cache] purge root_existed={} dir={}", - existed, - dir.display() - ); - Ok(()) -} - -// ─── Device Sync ───────────────────────────────────────────────────────────── - -/// Information about a single mounted removable drive. -#[derive(Clone, serde::Serialize)] -struct RemovableDrive { - name: String, - mount_point: String, - available_space: u64, - total_space: u64, - file_system: String, - is_removable: bool, -} - -/// Returns all currently mounted removable drives. -/// On Linux these are typically USB sticks / SD cards under /media or /run/media. -/// On macOS they appear under /Volumes. On Windows they are separate drive letters. -#[tauri::command] -fn get_removable_drives() -> Vec { - use sysinfo::Disks; - let disks = Disks::new_with_refreshed_list(); - disks - .list() - .iter() - .filter(|d| d.is_removable()) - .map(|d| RemovableDrive { - name: d.name().to_string_lossy().to_string(), - mount_point: d.mount_point().to_string_lossy().to_string(), - available_space: d.available_space(), - total_space: d.total_space(), - file_system: d.file_system().to_string_lossy().to_string(), - is_removable: true, - }) - .collect() -} - -/// Writes a `psysonic-sync.json` manifest to the root of the target directory. -/// The file records which sources (albums/playlists/artists) are synced to this -/// device so that another machine can pick them up without relying on localStorage. -#[tauri::command] -fn write_device_manifest(dest_dir: String, sources: serde_json::Value) -> Result<(), String> { - let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json"); - // Manifest v2: fixed "{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}" schema, - // no user-configurable filename template. Readers still accept v1 manifests. - let payload = serde_json::json!({ - "version": 2, - "schema": "fixed-v1", - "sources": sources - }); - let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?; - std::fs::write(&path, json).map_err(|e| e.to_string()) -} - -/// Reads `psysonic-sync.json` from the target directory. -/// Returns the parsed JSON value, or null if the file doesn't exist. -#[tauri::command] -fn read_device_manifest(dest_dir: String) -> Option { - let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json"); - let content = std::fs::read_to_string(&path).ok()?; - serde_json::from_str(&content).ok() -} - -/// Per-entry result for `rename_device_files`. -#[derive(serde::Serialize)] -struct RenameResult { - #[serde(rename = "oldPath")] - old_path: String, - #[serde(rename = "newPath")] - new_path: String, - ok: bool, - error: Option, -} - -/// Atomically renames files on the device from their old path to the new fixed- -/// schema path. Intended for the migration flow when switching away from the -/// user-configurable template. All paths are relative to `target_dir`. -/// -/// After renaming, removes any directories left empty under `target_dir` -/// (so stale `{OldArtist}/{OldAlbum}/` trees don't linger). -/// -/// Returns a per-entry result so the UI can show which renames succeeded -/// and which failed. Does not roll back on partial failure — each `fs::rename` -/// is atomic, so nothing can be half-renamed. -#[tauri::command] -fn rename_device_files( - target_dir: String, - pairs: Vec<(String, String)>, -) -> Result, String> { - let root = std::path::PathBuf::from(&target_dir); - if !root.exists() { - return Err("VOLUME_NOT_FOUND".to_string()); - } - if !is_path_on_mounted_volume(&root) { - return Err("NOT_MOUNTED_VOLUME".to_string()); - } - - let mut results = Vec::with_capacity(pairs.len()); - for (old_rel, new_rel) in pairs { - let old_abs = root.join(&old_rel); - let new_abs = root.join(&new_rel); - - let entry = if old_rel == new_rel { - // Nothing to do, count as success so the UI can show "already correct". - RenameResult { old_path: old_rel, new_path: new_rel, ok: true, error: None } - } else if !old_abs.exists() { - RenameResult { - old_path: old_rel, new_path: new_rel, - ok: false, error: Some("source not found".to_string()), - } - } else if new_abs.exists() { - RenameResult { - old_path: old_rel, new_path: new_rel, - ok: false, error: Some("target already exists".to_string()), - } - } else { - // Ensure target parent exists. - if let Some(parent) = new_abs.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - results.push(RenameResult { - old_path: old_rel, new_path: new_rel, - ok: false, error: Some(format!("mkdir: {}", e)), - }); - continue; - } - } - match std::fs::rename(&old_abs, &new_abs) { - Ok(_) => RenameResult { old_path: old_rel, new_path: new_rel, ok: true, error: None }, - Err(e) => RenameResult { - old_path: old_rel, new_path: new_rel, - ok: false, error: Some(e.to_string()), - }, - } - }; - results.push(entry); - } - - // Clean up directories emptied by the renames. Walk depth-first and remove - // any dir whose only remaining contents were the files we moved out. - fn remove_empty_dirs(dir: &std::path::Path, root: &std::path::Path) { - if dir == root { return; } - let rd = match std::fs::read_dir(dir) { - Ok(r) => r, - Err(_) => return, - }; - let mut empty = true; - let mut children: Vec = Vec::new(); - for entry in rd.flatten() { - let p = entry.path(); - if p.is_dir() { children.push(p); } else { empty = false; } - } - for child in children { - remove_empty_dirs(&child, root); - } - // Re-check after recursion cleared subdirs. - let still_empty = std::fs::read_dir(dir).map(|r| r.count() == 0).unwrap_or(false); - if empty && still_empty { - let _ = std::fs::remove_dir(dir); - } - } - remove_empty_dirs(&root, &root); - - Ok(results) -} - -/// Writes an Extended-M3U playlist at `{dest_dir}/Playlists/{name}/{name}.m3u8`. -/// References are sibling filenames (just `01 - Artist - Title.ext`) so the -/// playlist is self-contained — moving/copying the folder anywhere keeps it -/// working. Tracks are expected to be in playlist order (index starts at 1). -#[tauri::command] -fn write_playlist_m3u8( - dest_dir: String, - playlist_name: String, - tracks: Vec, -) -> Result<(), String> { - let safe_name = sanitize_or(&playlist_name, "Unnamed Playlist"); - let playlist_dir = std::path::Path::new(&dest_dir).join("Playlists").join(&safe_name); - std::fs::create_dir_all(&playlist_dir).map_err(|e| e.to_string())?; - let file_path = playlist_dir.join(format!("{}.m3u8", safe_name)); - - let mut body = String::from("#EXTM3U\n"); - for (i, track) in tracks.iter().enumerate() { - let idx = (i as u32) + 1; - let duration = track.duration.map(|d| d as i64).unwrap_or(-1); - let display_artist = if track.artist.trim().is_empty() { &track.album_artist[..] } else { &track.artist[..] }; - let title = track.title.trim(); - body.push_str(&format!("#EXTINF:{},{} - {}\n", duration, display_artist.trim(), title)); - // Sibling filename — same shape as build_track_path's playlist branch. - let artist_safe = sanitize_or(display_artist, "Unknown Artist"); - let title_safe = sanitize_or(title, "Unknown Title"); - body.push_str(&format!("{:02} - {} - {}.{}\n", idx, artist_safe, title_safe, track.suffix)); - } - std::fs::write(&file_path, body).map_err(|e| e.to_string()) -} - -/// Checks whether `path` sits on top of an active mount point (i.e. not the root -/// filesystem). This prevents accidentally writing to `/media/usb` after the -/// USB drive has been unmounted — at that point the path would fall through to `/` -/// and fill the root partition. -fn is_path_on_mounted_volume(path: &std::path::Path) -> bool { - use sysinfo::Disks; - let disks = Disks::new_with_refreshed_list(); - let canonical = match path.canonicalize() { - Ok(c) => c, - Err(_) => return false, // path doesn't exist or isn't accessible - }; - // On Windows, canonicalize() prepends "\\?\" (extended-path prefix). - // Strip it so that "\\?\E:\Music" compares correctly against mount point "E:\". - let canonical_raw = canonical.to_string_lossy().into_owned(); - #[cfg(target_os = "windows")] - let canonical_str = canonical_raw.strip_prefix(r"\\?\").unwrap_or(&canonical_raw).to_string(); - #[cfg(not(target_os = "windows"))] - let canonical_str = canonical_raw; - // Find the longest mount-point prefix that matches this path. - // Exclude the root "/" (or "C:\" on Windows) so we never "match" a fallback. - let mut best_len: usize = 0; - for disk in disks.list() { - let mp = disk.mount_point().to_string_lossy().to_string(); - // Skip root mount points (Linux "/" and non-removable Windows drive roots like "C:\"). - // Do NOT skip removable Windows drives (e.g. "E:\") — those are valid sync targets. - let is_windows_root = mp.len() == 3 && mp.ends_with(":\\") && !disk.is_removable(); - if mp == "/" || is_windows_root { - continue; - } - if canonical_str.starts_with(&mp) && mp.len() > best_len { - best_len = mp.len(); - } - } - best_len > 0 -} - -#[derive(serde::Deserialize, Clone)] -struct TrackSyncInfo { - id: String, - url: String, - suffix: String, - /// Track artist — used in Extended M3U (#EXTINF) entries so playlists display - /// the actual performer rather than the album artist. - artist: String, - /// Album artist — used for the top-level folder so compilation albums stay together. - /// Falls back to `artist` in the frontend when the server has no albumArtist tag. - #[serde(rename = "albumArtist")] - album_artist: String, - album: String, - title: String, - #[serde(rename = "trackNumber")] - track_number: Option, - /// Duration in seconds — needed for Extended M3U (#EXTINF) playlist entries. - #[serde(default)] - duration: Option, - /// When set, the track belongs to a playlist source and is placed under - /// `Playlists/{name}/` with `playlist_index` as its filename prefix. - /// Same track synced from both an album and a playlist source ends up twice - /// on the device — once in the album tree, once in the playlist folder. - #[serde(default, rename = "playlistName")] - playlist_name: Option, - #[serde(default, rename = "playlistIndex")] - playlist_index: Option, -} - -/// Summary returned by `sync_batch_to_device` after all tracks are processed. -#[derive(Clone, serde::Serialize)] -struct SyncBatchResult { - done: u32, - skipped: u32, - failed: u32, -} - -#[derive(serde::Serialize)] -struct SyncTrackResult { - path: String, - skipped: bool, -} - -/// Replaces characters that are invalid in file/directory names on Windows and -/// most Unix filesystems with an underscore, and trims leading/trailing dots and -/// spaces which cause issues on Windows. Underscore (not deletion) so that "AC/DC" -/// and "ACDC" don't collapse into the same folder. -fn sanitize_path_component(s: &str) -> String { - const INVALID: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|']; - let sanitized: String = s - .chars() - .map(|c| if INVALID.contains(&c) || c.is_control() { '_' } else { c }) - .collect(); - sanitized.trim_matches(|c| c == '.' || c == ' ').to_string() -} - -/// Sanitize and replace empty results with a placeholder — prevents paths like -/// `//01 - .flac` when metadata is missing. -fn sanitize_or(s: &str, fallback: &str) -> String { - let cleaned = sanitize_path_component(s); - if cleaned.is_empty() { fallback.to_string() } else { cleaned } -} - -/// Builds the fixed device path for a track. When the track carries a playlist -/// context it goes into the playlist folder, otherwise into the album tree. -/// -/// Album-tree: `{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}` -/// Playlist: `Playlists/{PlaylistName}/{PlaylistIndex:02d} - {Artist} - {Title}.{ext}` -fn build_track_path(track: &TrackSyncInfo) -> String { - let relative = match (&track.playlist_name, track.playlist_index) { - (Some(name), Some(idx)) => { - let playlist = sanitize_or(name, "Unnamed Playlist"); - let artist = sanitize_or(&track.artist, "Unknown Artist"); - let title = sanitize_or(&track.title, "Unknown Title"); - format!("Playlists/{}/{:02} - {} - {}", playlist, idx, artist, title) - } - _ => { - let album_artist = sanitize_or(&track.album_artist, "Unknown Artist"); - let album = sanitize_or(&track.album, "Unknown Album"); - let title = sanitize_or(&track.title, "Unknown Title"); - let track_num = track.track_number.map(|n| format!("{:02}", n)).unwrap_or_else(|| "00".to_string()); - format!("{}/{}/{} - {}", album_artist, album, track_num, title) - } - }; - #[cfg(target_os = "windows")] - let relative = relative.replace('/', "\\"); - relative -} - -/// Downloads a single track to a USB/SD device using the configured filename template. -/// Emits `device:sync:progress` events with `{ jobId, trackId, status, path? }`. -#[tauri::command] -async fn sync_track_to_device( - track: TrackSyncInfo, - dest_dir: String, - job_id: String, - app: tauri::AppHandle, -) -> Result { - let relative = build_track_path(&track); - let file_name = format!("{}.{}", relative, track.suffix); - let dest_path = std::path::Path::new(&dest_dir).join(&file_name); - let path_str = dest_path.to_string_lossy().to_string(); - - if dest_path.exists() { - let _ = app.emit("device:sync:progress", serde_json::json!({ - "jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str, - })); - return Ok(SyncTrackResult { path: path_str, skipped: true }); - } - - if let Some(parent) = dest_path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| e.to_string())?; - } - - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(std::time::Duration::from_secs(300)) - .build() - .map_err(|e| e.to_string())?; - - let response = client.get(&track.url).send().await.map_err(|e| e.to_string())?; - if !response.status().is_success() { - let msg = format!("HTTP {}", response.status().as_u16()); - let _ = app.emit("device:sync:progress", serde_json::json!({ - "jobId": job_id, "trackId": track.id, "status": "error", "error": msg, - })); - return Err(msg); - } - - let part_path = dest_path.with_extension(format!("{}.part", track.suffix)); - if let Err(e) = stream_to_file(response, &part_path).await { - let _ = tokio::fs::remove_file(&part_path).await; - let _ = app.emit("device:sync:progress", serde_json::json!({ - "jobId": job_id, "trackId": track.id, "status": "error", "error": e, - })); - return Err(e); - } - tokio::fs::rename(&part_path, &dest_path) - .await - .map_err(|e| e.to_string())?; - - let _ = app.emit("device:sync:progress", serde_json::json!({ - "jobId": job_id, "trackId": track.id, "status": "done", "path": path_str, - })); - Ok(SyncTrackResult { path: path_str, skipped: false }) -} - -/// Computes the expected file paths for a batch of tracks under the fixed schema. -/// Used by the cleanup flow to find orphans. -#[tauri::command] -fn compute_sync_paths(tracks: Vec, dest_dir: String) -> Vec { - tracks.iter().map(|track| { - let relative = build_track_path(track); - let file_name = format!("{}.{}", relative, track.suffix); - std::path::Path::new(&dest_dir) - .join(&file_name) - .to_string_lossy() - .to_string() - }).collect() -} - -/// Lists all files (recursively) under `dir`. Returns `"VOLUME_NOT_FOUND"` if -/// the directory does not exist (e.g. USB was unplugged). -#[tauri::command] -async fn list_device_dir_files(dir: String) -> Result, String> { - let root = std::path::PathBuf::from(&dir); - if !root.exists() { - return Err("VOLUME_NOT_FOUND".to_string()); - } - let mut files = Vec::new(); - let mut stack = vec![root]; - while let Some(current) = stack.pop() { - let mut rd = match tokio::fs::read_dir(¤t).await { - Ok(r) => r, - Err(_) => continue, - }; - while let Ok(Some(entry)) = rd.next_entry().await { - let path = entry.path(); - // Skip hidden dirs (e.g. .Trash-1000, .Ventoy, .fseventsd) - let is_hidden = path.file_name() - .and_then(|n| n.to_str()) - .map(|n| n.starts_with('.')) - .unwrap_or(false); - if is_hidden { continue; } - if path.is_dir() { - stack.push(path); - } else { - files.push(path.to_string_lossy().to_string()); - } - } - } - Ok(files) -} - -/// Deletes a file from the device and prunes empty parent directories -/// (up to 2 levels: album folder, then artist folder). -#[tauri::command] -async fn delete_device_file(path: String) -> Result<(), String> { - let p = std::path::PathBuf::from(&path); - if p.exists() { - tokio::fs::remove_file(&p).await.map_err(|e| e.to_string())?; - prune_empty_parents(&p, 2).await; - } - Ok(()) -} - -/// Prune empty parent directories up to `levels` levels above `file_path`. -async fn prune_empty_parents(file_path: &std::path::Path, levels: usize) { - let mut current = file_path.parent().map(|d| d.to_path_buf()); - for _ in 0..levels { - let Some(dir) = current else { break }; - let is_empty = std::fs::read_dir(&dir) - .map(|mut rd| rd.next().is_none()) - .unwrap_or(false); - if is_empty { - let _ = tokio::fs::remove_dir(&dir).await; - current = dir.parent().map(|d| d.to_path_buf()); - } else { - break; - } - } -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SubsonicAuthPayload { - base_url: String, - u: String, - t: String, - s: String, - v: String, - c: String, - f: String, -} - -#[derive(serde::Deserialize, Clone)] -struct DeviceSyncSourcePayload { - #[serde(rename = "type")] - source_type: String, - id: String, - /// Playlist display name — only present for playlist sources, used when - /// computing the playlist-folder path on the device. - #[serde(default)] - name: Option, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct SyncDeltaResult { - add_bytes: u64, - add_count: u32, - del_bytes: u64, - del_count: u32, - available_bytes: u64, - tracks: Vec, -} - -async fn fetch_subsonic_songs( - client: &reqwest::Client, - auth: &SubsonicAuthPayload, - endpoint: &str, - id: &str, -) -> Result, String> { - let url = format!("{}/{}", auth.base_url, endpoint); - let query = vec![ - ("u", auth.u.as_str()), - ("t", auth.t.as_str()), - ("s", auth.s.as_str()), - ("v", auth.v.as_str()), - ("c", auth.c.as_str()), - ("f", auth.f.as_str()), - ("id", id), - ]; - let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?; - let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - - let root = json.get("subsonic-response").ok_or("No subsonic-response".to_string())?; - let songs = if endpoint == "getAlbum.view" { - root.get("album").and_then(|a| a.get("song")) - } else if endpoint == "getPlaylist.view" { - root.get("playlist").and_then(|p| p.get("entry")) - } else { - None - }; - - if let Some(arr) = songs.and_then(|s| s.as_array()) { - return Ok(arr.clone()); - } else if let Some(obj) = songs.and_then(|s| s.as_object()) { - return Ok(vec![serde_json::Value::Object(obj.clone())]); - } - Ok(vec![]) -} - -#[tauri::command] -async fn calculate_sync_payload( - sources: Vec, - deletion_ids: Vec, - auth: SubsonicAuthPayload, - target_dir: String, -) -> Result { - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| e.to_string())?; - - let mut add_bytes = 0; - let mut add_count = 0; - let mut del_bytes = 0; - let mut del_count = 0; - - let mut sync_tracks = Vec::new(); - let (mut del_sources, mut add_sources) = (Vec::new(), Vec::new()); - for s in sources { - if deletion_ids.contains(&s.id) { - del_sources.push(s); - } else { - add_sources.push(s); - } - } - - let mut handles: Vec<(DeviceSyncSourcePayload, tokio::task::JoinHandle>)> = Vec::new(); - for source in add_sources { - let auth_clone = SubsonicAuthPayload { - base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(), - v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), - }; - let cli = client.clone(); - let source_snapshot = source.clone(); - let handle = tokio::spawn(async move { - let mut res_tracks = Vec::new(); - if source.source_type == "album" { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } - } else if source.source_type == "playlist" { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } - } else if source.source_type == "artist" { - let url = format!("{}/getArtist.view", auth_clone.base_url); - let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)]; - if let Ok(re) = cli.get(&url).query(&query).send().await { - if let Ok(js) = re.json::().await { - if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) { - let arr = root.as_array().map(|a| a.clone()).unwrap_or_else(|| { - root.as_object().map(|o| vec![serde_json::Value::Object(o.clone())]).unwrap_or_else(|| vec![]) - }); - for al in arr { - if let Some(aid) = al.get("id").and_then(|i| i.as_str()) { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await { - res_tracks.extend(ts); - } - } - } - } - } - } - } - res_tracks - }); - handles.push((source_snapshot, handle)); - } - - let mut del_handles = Vec::new(); - for source in del_sources { - let auth_clone = SubsonicAuthPayload { - base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(), - v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), - }; - let cli = client.clone(); - del_handles.push(tokio::spawn(async move { - let mut res_tracks = Vec::new(); - if source.source_type == "album" { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } - } else if source.source_type == "playlist" { - if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } - } - res_tracks - })); - } - - // Dedup key is (source_id, track_id) rather than just track_id — a track - // appearing in both an album and a playlist needs to end up on the device - // in both locations (album tree + playlist folder). - let mut seen_by_source: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); - for (source, handle) in handles { - if let Ok(ts) = handle.await { - let is_playlist = source.source_type == "playlist"; - let mut playlist_position: u32 = 0; - for track in ts { - if let Some(tid) = track.get("id").and_then(|i| i.as_str()) { - let key = (source.id.clone(), tid.to_string()); - if seen_by_source.contains(&key) { continue; } - seen_by_source.insert(key); - if is_playlist { playlist_position += 1; } - let pl_name = if is_playlist { source.name.clone() } else { None }; - let pl_idx = if is_playlist { Some(playlist_position) } else { None }; - - let already_exists = { - let suffix = track.get("suffix").and_then(|s| s.as_str()).unwrap_or("mp3"); - let artist_raw = track.get("artist").and_then(|v| v.as_str()).unwrap_or(""); - let album_artist = track.get("albumArtist") - .and_then(|v| v.as_str()) - .filter(|s| !s.trim().is_empty()) - .unwrap_or(artist_raw); - let sync_info = TrackSyncInfo { - id: tid.to_string(), - url: String::new(), - suffix: suffix.to_string(), - artist: artist_raw.to_string(), - album_artist: album_artist.to_string(), - album: track.get("album").and_then(|v| v.as_str()).unwrap_or("").to_string(), - title: track.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(), - track_number: track.get("track").and_then(|v| v.as_u64()).map(|n| n as u32), - duration: track.get("duration").and_then(|v| v.as_u64()).map(|n| n as u32), - playlist_name: pl_name.clone(), - playlist_index: pl_idx, - }; - let relative = build_track_path(&sync_info); - let file_name = format!("{}.{}", relative, suffix); - std::path::Path::new(&target_dir).join(&file_name).exists() - }; - if !already_exists { - add_count += 1; - let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { - track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 - }); - add_bytes += size; - // Embed playlist context in the track JSON so the frontend - // can pass it back to sync_batch_to_device without re-computing it. - let mut track_with_ctx = track.clone(); - if let Some(obj) = track_with_ctx.as_object_mut() { - if let Some(name) = &pl_name { - obj.insert("_playlistName".to_string(), serde_json::Value::String(name.clone())); - } - if let Some(idx) = pl_idx { - obj.insert("_playlistIndex".to_string(), serde_json::Value::Number(idx.into())); - } - } - sync_tracks.push(track_with_ctx); - } - } - } - } - } - - for handle in del_handles { - if let Ok(ts) = handle.await { - for track in ts { - del_count += 1; - let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { - track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 - }); - del_bytes += size; - } - } - } - - let mut available_bytes = 0; - for drive in get_removable_drives() { - if target_dir.starts_with(&drive.mount_point) { - available_bytes = drive.available_space; - break; - } - } - - Ok(SyncDeltaResult { - add_bytes, add_count, del_bytes, del_count, available_bytes, tracks: sync_tracks, - }) -} - -/// Signals a running `sync_batch_to_device` job to stop after its current tracks finish. -#[tauri::command] -fn cancel_device_sync(job_id: String, app: tauri::AppHandle) { - if let Ok(flags) = sync_cancel_flags().lock() { - if let Some(flag) = flags.get(&job_id) { - flag.store(true, Ordering::Relaxed); - } - } - let _ = app.emit("device:sync:cancelled", serde_json::json!({ "jobId": job_id })); -} - -/// Downloads a batch of tracks to a USB/SD device with controlled concurrency. -/// At most 2 parallel writes run simultaneously to prevent I/O choking on USB. -/// Emits throttled `device:sync:progress` events (max once per 500ms) and a -/// final `device:sync:complete` event with the summary. -#[tauri::command] -async fn sync_batch_to_device( - tracks: Vec, - dest_dir: String, - job_id: String, - expected_bytes: u64, - app: tauri::AppHandle, -) -> Result { - use std::sync::atomic::{AtomicU32, Ordering}; - use std::time::{Duration, Instant}; - use tokio::sync::Mutex; - - let dest_root = std::path::PathBuf::from(&dest_dir); - if !dest_root.exists() { - return Err("VOLUME_NOT_FOUND".to_string()); - } - // Safety: verify dest_dir is on an actual mounted volume, not the root FS. - // This catches the case where a USB drive was unmounted but the empty - // mount-point directory still exists — writing there fills the root partition. - if !is_path_on_mounted_volume(&dest_root) { - return Err("NOT_MOUNTED_VOLUME".to_string()); - } - - // Safety: Ensure target logic hasn't exceeded physical volume capacities securely stopping dead bytes natively. - let drives = get_removable_drives(); - let dest_canon = dest_root.canonicalize().unwrap_or_else(|_| dest_root.clone()); - let dest_str = dest_canon.to_string_lossy(); - - for drive in drives { - if dest_str.starts_with(&drive.mount_point) { - // Buffer of ~10 MB padding boundary natively mapped - if expected_bytes > drive.available_space.saturating_sub(10_000_000) { - return Err(format!("NOT_ENOUGH_SPACE")); - } - break; - } - } - - // Register a cancellation flag for this job. - let cancel_flag = Arc::new(AtomicBool::new(false)); - if let Ok(mut flags) = sync_cancel_flags().lock() { - flags.insert(job_id.clone(), cancel_flag.clone()); - } - - // Shared reqwest client — reused across all downloads. - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(Duration::from_secs(300)) - .build() - .map_err(|e| e.to_string())?; - - // Concurrency limiter: max 2 parallel USB writes. - let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2)); - - // Counters. - let done = std::sync::Arc::new(AtomicU32::new(0)); - let skipped = std::sync::Arc::new(AtomicU32::new(0)); - let failed = std::sync::Arc::new(AtomicU32::new(0)); - - // Throttled event emission (max once per 500ms). - let last_emit = std::sync::Arc::new(Mutex::new(Instant::now())); - let total = tracks.len() as u32; - - let mut handles = Vec::with_capacity(tracks.len()); - - for track in tracks { - let sem = semaphore.clone(); - let cli = client.clone(); - let app2 = app.clone(); - let job = job_id.clone(); - let dest = dest_dir.clone(); - let d = done.clone(); - let s = skipped.clone(); - let f = failed.clone(); - let le = last_emit.clone(); - let cancel = cancel_flag.clone(); - - handles.push(tokio::spawn(async move { - let _permit = sem.acquire().await.expect("semaphore closed"); - - // Bail out if cancelled while waiting in the semaphore queue. - if cancel.load(Ordering::Relaxed) { return; } - - let relative = build_track_path(&track); - let file_name = format!("{}.{}", relative, track.suffix); - let dest_path = std::path::Path::new(&dest).join(&file_name); - let path_str = dest_path.to_string_lossy().to_string(); - - let status; - if dest_path.exists() { - s.fetch_add(1, Ordering::Relaxed); - status = "skipped"; - } else { - // Ensure parent directories exist. - if let Some(parent) = dest_path.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - f.fetch_add(1, Ordering::Relaxed); - let _ = app2.emit("device:sync:progress", serde_json::json!({ - "jobId": job, "trackId": track.id, "status": "error", - "error": e.to_string(), - })); - return; - } - } - - let response = match cli.get(&track.url).send().await { - Ok(r) if r.status().is_success() => r, - Ok(r) => { - f.fetch_add(1, Ordering::Relaxed); - let _ = app2.emit("device:sync:progress", serde_json::json!({ - "jobId": job, "trackId": track.id, "status": "error", - "error": format!("HTTP {}", r.status().as_u16()), - })); - return; - } - Err(e) => { - f.fetch_add(1, Ordering::Relaxed); - let _ = app2.emit("device:sync:progress", serde_json::json!({ - "jobId": job, "trackId": track.id, "status": "error", - "error": e.to_string(), - })); - return; - } - }; - - let part_path = dest_path.with_extension(format!("{}.part", track.suffix)); - if let Err(e) = stream_to_file(response, &part_path).await { - let _ = tokio::fs::remove_file(&part_path).await; - f.fetch_add(1, Ordering::Relaxed); - let _ = app2.emit("device:sync:progress", serde_json::json!({ - "jobId": job, "trackId": track.id, "status": "error", - "error": e, - })); - return; - } - if let Err(e) = tokio::fs::rename(&part_path, &dest_path).await { - let _ = tokio::fs::remove_file(&part_path).await; - f.fetch_add(1, Ordering::Relaxed); - let _ = app2.emit("device:sync:progress", serde_json::json!({ - "jobId": job, "trackId": track.id, "status": "error", - "error": e.to_string(), - })); - return; - } - - d.fetch_add(1, Ordering::Relaxed); - status = "done"; - } - - // Throttled progress event — max once per 500ms. - let should_emit = { - let mut guard = le.lock().await; - if guard.elapsed() >= Duration::from_millis(500) { - *guard = Instant::now(); - true - } else { - false - } - }; - if should_emit { - let _ = app2.emit("device:sync:progress", serde_json::json!({ - "jobId": job, "trackId": track.id, "status": status, "path": path_str, - "done": d.load(Ordering::Relaxed), - "skipped": s.load(Ordering::Relaxed), - "failed": f.load(Ordering::Relaxed), - "total": total, - })); - } - })); - } - - // Wait for all tasks to complete. - for handle in handles { - let _ = handle.await; - } - - // Clean up the cancellation flag. - let was_cancelled = cancel_flag.load(Ordering::Relaxed); - if let Ok(mut flags) = sync_cancel_flags().lock() { - flags.remove(&job_id); - } - - let result = SyncBatchResult { - done: done.load(Ordering::Relaxed), - skipped: skipped.load(Ordering::Relaxed), - failed: failed.load(Ordering::Relaxed), - }; - - // Final event so the frontend always sees 100%. - let _ = app.emit("device:sync:complete", serde_json::json!({ - "jobId": job_id, - "done": result.done, - "skipped": result.skipped, - "failed": result.failed, - "total": total, - "cancelled": was_cancelled, - })); - - Ok(result) -} - -/// Deletes multiple files from the device in one call and prunes empty parent -/// directories. Returns the number of files successfully deleted. -#[tauri::command] -async fn delete_device_files(paths: Vec) -> Result { - let mut deleted: u32 = 0; - for path in &paths { - let p = std::path::PathBuf::from(path); - if p.exists() { - if tokio::fs::remove_file(&p).await.is_ok() { - deleted += 1; - prune_empty_parents(&p, 2).await; - } - } - } - Ok(deleted) -} - -/// Builds and returns a new system-tray icon with all menu items and event handlers. -/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation). -fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { - let labels = app - .try_state::() - .map(|s| s.lock().unwrap().clone()) - .unwrap_or_default(); - - let play_pause = MenuItemBuilder::with_id("play_pause", &labels.play_pause).build(app)?; - let next = MenuItemBuilder::with_id("next", &labels.next).build(app)?; - let previous = MenuItemBuilder::with_id("previous", &labels.previous).build(app)?; - let sep1 = PredefinedMenuItem::separator(app)?; - let show_hide = MenuItemBuilder::with_id("show_hide", &labels.show_hide).build(app)?; - let sep2 = PredefinedMenuItem::separator(app)?; - let quit = MenuItemBuilder::with_id("quit", &labels.quit).build(app)?; - - let cached_tooltip = app - .try_state::() - .and_then(|s| { - let g = s.lock().ok()?; - if g.is_empty() { None } else { Some(g.clone()) } - }) - .unwrap_or_else(|| "Psysonic".to_string()); - #[cfg(any(target_os = "windows", target_os = "linux"))] - let playback_state = app - .try_state::() - .map(|s| s.0.lock().unwrap().clone()) - .unwrap_or_else(|| "stop".to_string()); - #[cfg(target_os = "windows")] - let tooltip_with_icon = format!("{} {}", tray_state_icon(&playback_state), cached_tooltip); - - // Linux/AppIndicator has no hover tooltip; surface the now-playing track as - // a disabled menu entry at the top instead. The label is updated by - // `set_tray_tooltip` on every track change. - #[cfg(target_os = "linux")] - let (now_playing, sep_now_playing) = { - let icon = tray_state_icon(&playback_state); - let label = if cached_tooltip == "Psysonic" { - format!("{icon} {}", labels.nothing_playing) - } else { - format!("{icon} {cached_tooltip}") - }; - let item = MenuItemBuilder::with_id("now_playing", &label) - .enabled(false) - .build(app)?; - (item, PredefinedMenuItem::separator(app)?) - }; - - #[cfg(target_os = "linux")] - let menu_builder = MenuBuilder::new(app) - .item(&now_playing) - .item(&sep_now_playing); - #[cfg(not(target_os = "linux"))] - let menu_builder = MenuBuilder::new(app); - - let menu = menu_builder - .item(&play_pause) - .item(&previous) - .item(&next) - .item(&sep1) - .item(&show_hide) - .item(&sep2) - .item(&quit) - .build()?; - - // Persist handles so set_tray_menu_labels and set_tray_tooltip can update - // them without rebuilding the whole tray icon. - if let Some(state) = app.try_state::() { - *state.lock().unwrap() = Some(TrayMenuItems { - play_pause: play_pause.clone(), - next: next.clone(), - previous: previous.clone(), - show_hide: show_hide.clone(), - quit: quit.clone(), - #[cfg(target_os = "linux")] - now_playing: Some(now_playing.clone()), - #[cfg(not(target_os = "linux"))] - now_playing: None, - }); - } - - #[cfg(target_os = "windows")] - let tray_builder = TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&menu) - .tooltip(&tooltip_with_icon); - #[cfg(not(target_os = "windows"))] - let tray_builder = TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&menu) - .tooltip(&cached_tooltip); - - tray_builder - .on_menu_event(|app, event| match event.id.as_ref() { - "play_pause" => { let _ = app.emit("tray:play-pause", ()); } - "next" => { let _ = app.emit("tray:next", ()); } - "previous" => { let _ = app.emit("tray:previous", ()); } - "show_hide" => { - if let Some(win) = app.get_webview_window("main") { - if win.is_visible().unwrap_or(false) { - let _ = win.eval(PAUSE_RENDERING_JS); - let _ = win.hide(); - } else { - let _ = win.eval(RESUME_RENDERING_JS); - let _ = win.show(); - let _ = win.set_focus(); - } - } - } - "quit" => { let _ = app.emit("app:force-quit", ()); } - _ => {} - }) - .on_tray_icon_event(|tray, event| { - // Windows fires a Click on *every* half of a double-click, so a - // double-click would toggle the window visibility twice and end up - // back where it started (the bug #cucadmuh reported). Switch to the - // Windows-only DoubleClick event there and ignore single clicks; - // that matches the standard Windows tray convention (Discord, etc). - #[cfg(target_os = "windows")] - let should_toggle = matches!( - event, - TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } - ); - #[cfg(not(target_os = "windows"))] - let should_toggle = matches!( - event, - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } - ); - if should_toggle { - let app = tray.app_handle(); - if let Some(win) = app.get_webview_window("main") { - if win.is_visible().unwrap_or(false) { - let _ = win.eval(PAUSE_RENDERING_JS); - let _ = win.hide(); - } else { - let _ = win.eval(RESUME_RENDERING_JS); - let _ = win.show(); - let _ = win.set_focus(); - } - } - } - }) - .build(app) -} - -/// Creates the tray icon, or `None` if the OS cannot host one. -/// -/// On Linux, `libayatana-appindicator3` / `libappindicator3` may be absent (minimal -/// installs, wrong `LD_LIBRARY_PATH`). The `tray-icon` stack can **panic** on `dlopen` -/// failure instead of returning `Err`, so we catch unwind and keep the app running -/// (e.g. cold start with `--player` still works without tray libraries). -fn try_build_tray_icon(app: &tauri::AppHandle) -> Option { - let app = app.clone(); - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray_icon(&app))) { - Ok(Ok(tray)) => Some(tray), - Ok(Err(e)) => { - crate::app_eprintln!("[Psysonic] System tray unavailable: {e}"); - None - } - Err(_) => { - crate::app_eprintln!( - "[Psysonic] System tray unavailable — missing libayatana-appindicator3 or libappindicator3 \ - (install the distro package or set LD_LIBRARY_PATH)" - ); - None - } - } -} - -/// Updates the system-tray icon tooltip with the currently playing track. -/// -/// `tooltip` should be a compact "Artist – Title" form (no app suffix needed — -/// the tray icon itself identifies the app). An empty string resets to the -/// default `"Psysonic"` tooltip. -/// -/// The text is truncated to 127 chars defensively to stay under the historical -/// Windows `NOTIFYICONDATA.szTip` limit (128 bytes including the null terminator). -/// On Linux the visibility depends on the desktop environment / panel — -/// StatusNotifierItem-aware panels (KDE, Cinnamon, GNOME with AppIndicator -/// extension) show it; pure-GNOME without the extension does not. -#[tauri::command] -fn set_tray_tooltip( - app: tauri::AppHandle, - tray_state: tauri::State, - tooltip_cache: tauri::State, - playback_state_cache: tauri::State, - tooltip: String, - playback_state: Option, -) -> Result<(), String> { - let has_track_input = !tooltip.is_empty(); - let state = playback_state.as_deref().unwrap_or(if has_track_input { "play" } else { "stop" }); - let icon = tray_state_icon(state); - let icon_prefix_len = format!("{icon} ").chars().count(); - let max_text_chars = 127usize.saturating_sub(icon_prefix_len); - let ellipsis_reserve = 3usize; - let truncated = if tooltip.chars().count() > max_text_chars { - let take = max_text_chars.saturating_sub(ellipsis_reserve); - tooltip.chars().take(take).collect::() + "..." - } else { - tooltip - }; - let has_track = !truncated.is_empty(); - let effective = if has_track { truncated.clone() } else { "Psysonic".to_string() }; - #[cfg(target_os = "windows")] - let effective_with_icon = format!("{icon} {effective}"); - - *tooltip_cache.lock().unwrap() = truncated.clone(); - *playback_state_cache.0.lock().unwrap() = state.to_string(); - - if let Some(tray) = tray_state.lock().unwrap().as_ref() { - #[cfg(target_os = "windows")] - tray.set_tooltip(Some(&effective_with_icon)).map_err(|e| e.to_string())?; - #[cfg(not(target_os = "windows"))] - tray.set_tooltip(Some(&effective)).map_err(|e| e.to_string())?; - } - - #[cfg(target_os = "linux")] - { - if let Some(state) = app.try_state::() { - if let Some(items) = state.lock().unwrap().as_ref() { - if let Some(np) = items.now_playing.as_ref() { - let label = if has_track { - format!("{icon} {effective}") - } else { - let nothing = app.try_state::() - .map(|s| s.lock().unwrap().nothing_playing.clone()) - .unwrap_or_else(|| "Nothing playing".to_string()); - format!("{icon} {nothing}") - }; - let _ = np.set_text(&label); - } - } - } - } - #[cfg(not(target_os = "linux"))] - let _ = &app; - - Ok(()) -} - -/// Pushes localized labels into the tray menu. Called from the frontend on -/// startup and whenever the i18n language changes. Updates are applied -/// immediately to live menu items via `set_text` (no tray rebuild required) -/// and cached so the labels survive a tray hide/show cycle. -#[tauri::command] -fn set_tray_menu_labels( - app: tauri::AppHandle, - labels_state: tauri::State, - items_state: tauri::State, - tooltip_cache: tauri::State, - play_pause: String, - next: String, - previous: String, - show_hide: String, - quit: String, - nothing_playing: String, -) -> Result<(), String> { - let new_labels = TrayMenuLabels { - play_pause, - next, - previous, - show_hide, - quit, - nothing_playing, - }; - *labels_state.lock().unwrap() = new_labels.clone(); - - if let Some(items) = items_state.lock().unwrap().as_ref() { - let _ = items.play_pause.set_text(&new_labels.play_pause); - let _ = items.next.set_text(&new_labels.next); - let _ = items.previous.set_text(&new_labels.previous); - let _ = items.show_hide.set_text(&new_labels.show_hide); - let _ = items.quit.set_text(&new_labels.quit); - - // Linux now-playing item: only refresh the placeholder. The track - // text itself is owned by `set_tray_tooltip` and shouldn't be - // overwritten by an unrelated language change. - #[cfg(target_os = "linux")] - if let Some(np) = items.now_playing.as_ref() { - let has_track = !tooltip_cache.lock().unwrap().is_empty(); - if !has_track { - let state = app - .try_state::() - .map(|s| s.0.lock().unwrap().clone()) - .unwrap_or_else(|| "stop".to_string()); - let label = format!("{} {}", tray_state_icon(&state), new_labels.nothing_playing); - let _ = np.set_text(&label); - } - } - } - - let _ = (&app, &tooltip_cache); - Ok(()) -} - -/// Show (`true`) or fully remove (`false`) the system-tray icon. -/// -/// The command is strictly idempotent: -/// - `show=true` when the icon is already present → no-op (prevents duplicate icons). -/// - `show=false` when the icon is already absent → no-op. -/// -/// For removal, `set_visible(false)` is called explicitly before the handle is -/// dropped because some platforms (Windows notification area, certain Linux DEs) -/// process the OS removal asynchronously — hiding first prevents a brief "ghost" -/// icon from appearing alongside a freshly created one. -#[tauri::command] -fn toggle_tray_icon( - app: tauri::AppHandle, - tray_state: tauri::State, - show: bool, -) -> Result<(), String> { - let mut guard = tray_state.lock().unwrap(); - - if show { - // Early-return when already shown — never build a second icon. - if guard.is_some() { - return Ok(()); - } - let Some(tray) = try_build_tray_icon(&app) else { - return Err( - "Tray icon could not be created (missing system libraries on Linux).".into(), - ); - }; - *guard = Some(tray); - } else if let Some(tray) = guard.take() { - // Hide synchronously before dropping so the OS processes the removal - // before any subsequent show=true call can create a new icon. - let _ = tray.set_visible(false); - // `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem). - } - - Ok(()) -} - -/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`). -/// Called before process exit on macOS to ensure audio stops immediately. -fn stop_audio_engine(app: &tauri::AppHandle) { - let engine = app.state::(); - engine.generation.fetch_add(1, Ordering::SeqCst); - *engine.chained_info.lock().unwrap() = None; - drop(engine.radio_state.lock().unwrap().take()); - let mut cur = engine.current.lock().unwrap(); - if let Some(sink) = cur.sink.take() { sink.stop(); } -} - -/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3, -/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables -/// set by the compositor / DE. -#[cfg(target_os = "linux")] -fn is_tiling_wm() -> bool { - // Direct compositor signatures (most reliable). - let direct = [ - "HYPRLAND_INSTANCE_SIGNATURE", // Hyprland - "SWAYSOCK", // Sway - "I3SOCK", // i3 - ] - .iter() - .any(|&var| std::env::var_os(var).is_some()); - - if direct { - return true; - } - - // Check XDG_CURRENT_DESKTOP for known tiling WMs. - if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { - let desktop = desktop.to_lowercase(); - let tiling_wms = [ - "hyprland", "sway", "i3", "bspwm", "awesome", "openbox", - "xmonad", "dwm", "qtile", "herbstluftwm", "leftwm", - ]; - if tiling_wms.iter().any(|&wm| desktop.contains(wm)) { - return true; - } - } - - false -} - -/// Tauri command: returns true when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set. -/// The frontend uses this to apply a CSS class that swaps out GPU-only effects -/// (backdrop-filter, CSS filter, mask-image) for software-friendly equivalents. -#[tauri::command] -fn no_compositing_mode() -> bool { - std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE") - .map(|v| v == "1") - .unwrap_or(false) -} - -/// Tauri command: lets the frontend know whether we're running under a tiling -/// WM so it can decide whether to render the custom TitleBar component. -#[tauri::command] -fn is_tiling_wm_cmd() -> bool { - #[cfg(target_os = "linux")] - { - is_tiling_wm() - } - #[cfg(not(target_os = "linux"))] - { - false - } -} - -// ── Mini Player window ────────────────────────────────────────────────────── -// Secondary always-on-top window with minimal playback controls. Uses the -// same frontend bundle as the main window; disambiguated by window label -// "mini". On tiling WMs (Hyprland, Sway, i3, …) always-on-top is ignored, so -// we fall back to a regular window there. - -/// Persisted geometry for the mini player. Stored in -/// `/mini_player_pos.json` and rewritten (throttled) on -/// every `WindowEvent::Moved` so the window reopens where the user last -/// left it. Coordinates are physical pixels — that's what `set_position` -/// and the move event both report, so we don't need to round-trip through -/// scale factors that may differ across monitors. -#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug)] -struct MiniPlayerPosition { - x: i32, - y: i32, -} - -fn mini_pos_file(app: &tauri::AppHandle) -> Option { - app.path().app_config_dir().ok().map(|p| p.join("mini_player_pos.json")) -} - -fn read_mini_pos(app: &tauri::AppHandle) -> Option { - let path = mini_pos_file(app)?; - let raw = std::fs::read_to_string(&path).ok()?; - serde_json::from_str(&raw).ok() -} - -fn write_mini_pos(app: &tauri::AppHandle, pos: MiniPlayerPosition) { - let Some(path) = mini_pos_file(app) else { return }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string(&pos) { - let _ = std::fs::write(path, json); - } -} - -/// Tracks when we last set the mini player position programmatically. -/// `WindowEvent::Moved` fires for both user drags AND our own `show()` / -/// `set_position` calls — without this guard the WM's "centre on show" -/// behaviour would silently overwrite the user's saved position. -fn last_programmatic_pos_set() -> &'static Mutex { - static LAST: OnceLock> = OnceLock::new(); - LAST.get_or_init(|| Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10))) -} - -fn mark_mini_pos_programmatic() { - *last_programmatic_pos_set().lock().unwrap() = std::time::Instant::now(); -} - -fn is_mini_pos_programmatic() -> bool { - last_programmatic_pos_set().lock().unwrap().elapsed() - < std::time::Duration::from_millis(1000) -} - -/// Throttle disk writes during a drag — `WindowEvent::Moved` fires on -/// every pointer step. 250 ms keeps the file fresh enough that any close -/// or release lands a recent position, without hammering the disk. -/// Programmatic moves (during `show()` / `set_position`) are skipped so -/// WM re-centring on re-show doesn't clobber the saved position. -fn persist_mini_pos_throttled(app: &tauri::AppHandle, x: i32, y: i32) { - if is_mini_pos_programmatic() { - return; - } - static LAST_WRITE: OnceLock> = OnceLock::new(); - let mu = LAST_WRITE.get_or_init(|| { - Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10)) - }); - { - let mut last = mu.lock().unwrap(); - if last.elapsed() < std::time::Duration::from_millis(250) { - return; - } - *last = std::time::Instant::now(); - } - write_mini_pos(app, MiniPlayerPosition { x, y }); -} - -/// Returns true when the saved top-left lands inside an available monitor -/// with enough room (≥ 80 px in each axis) to leave a draggable corner of -/// the window on-screen. Used to drop persisted positions that point at a -/// monitor that is no longer enumerated (unplugged, hot-plug detection -/// race during early boot, resolution change, monitor reorder). -fn mini_position_visible(app: &tauri::AppHandle, x: i32, y: i32) -> bool { - const MIN_VISIBLE: i32 = 80; - let monitors = match app.available_monitors() { - Ok(m) if !m.is_empty() => m, - _ => return false, - }; - monitors.iter().any(|m| { - let mp = m.position(); - let ms = m.size(); - x >= mp.x - && y >= mp.y - && x + MIN_VISIBLE <= mp.x + ms.width as i32 - && y + MIN_VISIBLE <= mp.y + ms.height as i32 - }) -} - -/// Default position when nothing is persisted: bottom-right of the monitor -/// the main window sits on (falls back to primary). A 24 px logical margin -/// keeps it off the screen edge; +56 px on the bottom margin avoids most -/// taskbars/docks since Tauri does not expose work-area rects. -fn default_mini_position(app: &tauri::AppHandle) -> Option> { - let monitor = app - .get_webview_window("main") - .and_then(|w| w.current_monitor().ok().flatten()) - .or_else(|| app.primary_monitor().ok().flatten())?; - - let scale = monitor.scale_factor(); - let m_pos = monitor.position(); - let m_size = monitor.size(); - - let win_w = (340.0 * scale).round() as i32; - let win_h = (260.0 * scale).round() as i32; - let margin_x = (24.0 * scale).round() as i32; - let margin_y = (56.0 * scale).round() as i32; - - Some(tauri::PhysicalPosition::new( - m_pos.x + (m_size.width as i32) - win_w - margin_x, - m_pos.y + (m_size.height as i32) - win_h - margin_y, - )) -} - -/// JS snippet to inject into a hidden webview to reduce compositor work while -/// the host window is invisible. -/// -/// WebView2 on Windows can keep a GPU-backed compositor active even when the -/// native window is hidden. This script does **not** stop arbitrary JS timers -/// or every `requestAnimationFrame` loop — it sets a flag the app reads, zeros -/// `--psy-anim-speed` (for CSS that opts into it), and pauses **@keyframes** -/// animations via `animation-play-state` (not CSS transitions). -/// -/// Also sets `data-psy-native-hidden` on `` so global CSS can pause every -/// animation including `::before`/`::after` and portal content under `` -/// when `document.hidden` stays false on some WebView2 builds after `win.hide()`. -const PAUSE_RENDERING_JS: &str = r#" -window.__psyHidden = true; -document.documentElement.setAttribute('data-psy-native-hidden', 'true'); -document.documentElement.style.setProperty('--psy-anim-speed', '0'); -(function () { - const root = document.getElementById('root'); - if (!root) return; - root.querySelectorAll('*').forEach(function (el) { - el.style.animationPlayState = 'paused'; - }); -})(); -"#; - -/// JS snippet to resume rendering when the window becomes visible again. -const RESUME_RENDERING_JS: &str = r#" -window.__psyHidden = false; -document.documentElement.removeAttribute('data-psy-native-hidden'); -document.documentElement.style.removeProperty('--psy-anim-speed'); -(function () { - const root = document.getElementById('root'); - if (!root) return; - root.querySelectorAll('*').forEach(function (el) { - el.style.animationPlayState = ''; - }); -})(); -"#; - -/// Build the mini player webview window. Caller decides `visible` so the -/// same code path serves both pre-creation (Windows, hidden at app start) -/// and lazy creation (other platforms, shown on demand). -fn build_mini_player_window( - app: &tauri::AppHandle, - visible: bool, -) -> Result { - let use_always_on_top = { - #[cfg(target_os = "linux")] - { !is_tiling_wm() } - #[cfg(not(target_os = "linux"))] - { true } - }; - - // Tiling WMs manage window sizes themselves — enforcing a max width - // there fights the compositor. Everywhere else we cap the width so - // a horizontal drag can't stretch the layout across a whole monitor. - let cap_width = { - #[cfg(target_os = "linux")] - { !is_tiling_wm() } - #[cfg(not(target_os = "linux"))] - { true } - }; - - // Resolve target position BEFORE building so the WM places the window - // correctly from creation. Calling `set_position` after `build()` is - // unreliable on several Linux WMs which re-centre hidden windows. - // Drop the persisted position if it would land on a monitor that is - // no longer enumerated (unplugged second monitor, hot-plug race during - // early boot, resolution change) — fall back to the default placement. - let target_physical = read_mini_pos(app) - .filter(|p| mini_position_visible(app, p.x, p.y)) - .map(|p| tauri::PhysicalPosition::new(p.x, p.y)) - .or_else(|| default_mini_position(app)); - let scale = app - .primary_monitor() - .ok() - .flatten() - .map(|m| m.scale_factor()) - .unwrap_or(1.0); - - // macOS + Windows keep the native titlebar (traffic lights / caption - // buttons + system look). Linux uses a custom in-page titlebar so the - // mini fits a tighter visual style across all WMs (incl. tiling). - let use_decorations = !cfg!(target_os = "linux"); - - let mut builder = tauri::WebviewWindowBuilder::new( - app, - "mini", - tauri::WebviewUrl::App("index.html".into()), - ) - .title("Psysonic Mini") - .inner_size(340.0, 260.0) - .min_inner_size(320.0, 240.0) - .resizable(true) - .decorations(use_decorations) - .always_on_top(use_always_on_top) - .skip_taskbar(false) - .visible(visible); - - // Cap width so horizontal drag can't stretch the layout across a whole - // monitor. Height is intentionally left effectively unlimited so users - // can grow the queue list as tall as they want. Skipped on tiling WMs - // since those manage window sizing themselves. - if cap_width { - builder = builder.max_inner_size(400.0, 4096.0); - } - - if let Some(pos) = target_physical { - builder = builder.position(pos.x as f64 / scale, pos.y as f64 / scale); - } - - // Suppress Moved-event echo for the initial show — Linux WMs sometimes - // fire stray Moved events with default coords during the first paint. - mark_mini_pos_programmatic(); - - let win = builder - .build() - .map_err(|e| format!("failed to build mini player window: {e}"))?; - - // Inject pause script immediately when the window is created hidden. - // On Windows WebView2 keeps the GPU context alive even with - // `SetIsVisible(false)` — this JS stops all rendering work. - if !visible { - let _ = win.eval(PAUSE_RENDERING_JS); - } - - Ok(win) -} - -/// Pre-build the mini player window hidden, so the first `open_mini_player` -/// call becomes a pure show/hide and the user sees content instantly. On -/// Windows this already happens unconditionally in `.setup()` as a hang -/// workaround; this command is used by Linux/macOS when the user opts into -/// the `preloadMiniPlayer` setting. Idempotent — no-op if the window exists. -#[tauri::command] -fn preload_mini_player(app: tauri::AppHandle) -> Result<(), String> { - if app.get_webview_window("mini").is_some() { - return Ok(()); - } - build_mini_player_window(&app, false).map(|_| ()) -} - -/// Open (or toggle) the mini player window. On platforms where the window -/// was pre-created at startup (Windows), this is a pure show/hide. On -/// other platforms the window is created lazily on first call. -/// Opening the mini player minimizes the main window; hiding the mini -/// player restores the main window. -#[tauri::command] -fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { - let win = match app.get_webview_window("mini") { - Some(w) => w, - None => build_mini_player_window(&app, false)?, - }; - - let visible = win.is_visible().unwrap_or(false); - if visible { - // Pause before hide so `__psyHidden` is set while the webview is still - // guaranteed schedulable (mirrors tray / main close ordering). - let _ = win.eval(PAUSE_RENDERING_JS); - win.hide().map_err(|e| e.to_string())?; - if let Some(main) = app.get_webview_window("main") { - let _ = main.unminimize(); - let _ = main.show(); - let _ = main.set_focus(); - } - } else { - // Resume rendering before showing — the window needs to be ready - // to paint as soon as it becomes visible. - let _ = win.eval(RESUME_RENDERING_JS); - // Re-applying the saved position after show() — many Linux WMs - // (Mutter, KWin) re-centre hidden windows when they're shown - // again, ignoring any earlier set_position. Mark the move as - // programmatic so the Moved-event handler doesn't echo the - // intermediate centre coords back to disk. - // Drop the persisted position if its monitor is gone and fall - // back to the default placement so we don't open off-screen. - let target = read_mini_pos(&app) - .filter(|p| mini_position_visible(&app, p.x, p.y)) - .map(|p| tauri::PhysicalPosition::new(p.x, p.y)) - .or_else(|| default_mini_position(&app)); - mark_mini_pos_programmatic(); - win.show().map_err(|e| e.to_string())?; - let _ = win.set_focus(); - if let Some(p) = target { - let _ = win.set_position(p); - } - if let Some(main) = app.get_webview_window("main") { - let _ = main.minimize(); - } - } - Ok(()) -} - -/// Hide the mini player window if it exists and restore the main window. -/// Does not destroy the mini window so its state is preserved for next open. -#[tauri::command] -fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> { - if let Some(win) = app.get_webview_window("mini") { - let _ = win.eval(PAUSE_RENDERING_JS); - win.hide().map_err(|e| e.to_string())?; - } - if let Some(main) = app.get_webview_window("main") { - let _ = main.unminimize(); - let _ = main.show(); - let _ = main.set_focus(); - } - Ok(()) -} - -/// Unminimize + show + focus the main window. Called from the mini player's -/// "expand" button. Can't rely on a JS event bridge here because the main -/// window's JS is paused while minimized on WebKitGTK. Also hides the mini -/// window so the two don't sit on screen at the same time. -#[tauri::command] -fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { - if let Some(mini) = app.get_webview_window("mini") { - let _ = mini.eval(PAUSE_RENDERING_JS); - let _ = mini.hide(); - } - if let Some(main) = app.get_webview_window("main") { - let _ = main.eval(RESUME_RENDERING_JS); - main.unminimize().map_err(|e| e.to_string())?; - main.show().map_err(|e| e.to_string())?; - main.set_focus().map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// Inject the pause script into this webview (CSS @keyframes pause + `__psyHidden`). -#[tauri::command] -fn pause_rendering(window: tauri::WebviewWindow) -> Result<(), String> { - window.eval(PAUSE_RENDERING_JS).map_err(|e| e.to_string()) -} - -/// Resume rendering work in the current webview. Called when the window -/// becomes visible again. -#[tauri::command] -fn resume_rendering(window: tauri::WebviewWindow) -> Result<(), String> { - window.eval(RESUME_RENDERING_JS).map_err(|e| e.to_string()) -} - -/// Toggle always-on-top on the mini player window. -/// -/// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland) -/// silently ignore `set_always_on_top(true)` when the internal flag is -/// already `true` — which happens whenever the window was hidden and -/// re-shown, or focus was lost and the WM dropped the constraint. We -/// always force a `false → true` cycle so the WM re-evaluates the layer. -#[tauri::command] -fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> { - if let Some(win) = app.get_webview_window("mini") { - if on_top { - let _ = win.set_always_on_top(false); - } - win.set_always_on_top(on_top).map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// Resize the mini player window (logical pixels). Used when toggling the -/// queue panel to expand/collapse without a capability dance. Optional -/// `minWidth` / `minHeight` adjust the window's resize floor so the user -/// can't shrink past the layout's minimum (e.g. 2 visible queue rows when -/// the queue panel is open). -#[tauri::command] -fn resize_mini_player( - app: tauri::AppHandle, - width: f64, - height: f64, - min_width: Option, - min_height: Option, -) -> Result<(), String> { - if let Some(win) = app.get_webview_window("mini") { - // Lower the floor first; otherwise set_size to a value below the - // existing min would silently clamp. - if let (Some(mw), Some(mh)) = (min_width, min_height) { - win.set_min_size(Some(tauri::LogicalSize::new(mw, mh))) - .map_err(|e| e.to_string())?; - } - win.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; - } - Ok(()) -} - -// ── Bandsintown ────────────────────────────────────────────────────────────── -// Public REST API: https://rest.bandsintown.com/artists/{name}/events?app_id=… -// Bandsintown whitelists app IDs — arbitrary strings now return 403 Forbidden. -// `js_app_id` is the ID their own embeddable widget uses and is broadly accepted. -const BANDSINTOWN_APP_ID: &str = "js_app_id"; - -#[derive(serde::Serialize, Default)] -struct BandsintownEvent { - datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00") - venue_name: String, - venue_city: String, - venue_region: String, - venue_country: String, - url: String, - on_sale_datetime: String, - lineup: Vec, -} - -/// Fetch upcoming Bandsintown events for an artist by name. -/// Returns an empty list on any failure (404, network, parse) — the UI -/// just hides the section in that case. -#[tauri::command] -async fn fetch_bandsintown_events(artist_name: String) -> Result, String> { - let trimmed = artist_name.trim(); - if trimmed.is_empty() { - return Ok(vec![]); - } - // Bandsintown expects the artist name URL-encoded; their API treats `/` as a - // path separator (so e.g. AC/DC must be encoded as AC%252FDC). - let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect(); - let url = format!( - "https://rest.bandsintown.com/artists/{}/events?app_id={}", - encoded, BANDSINTOWN_APP_ID - ); - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - { - Ok(c) => c, - Err(_) => return Ok(vec![]), - }; - let resp = match client.get(&url).send().await { - Ok(r) => r, - Err(_) => return Ok(vec![]), - }; - if !resp.status().is_success() { - return Ok(vec![]); - } - let raw: serde_json::Value = match resp.json().await { - Ok(v) => v, - Err(_) => return Ok(vec![]), - }; - let arr = match raw.as_array() { - Some(a) => a, - None => return Ok(vec![]), - }; - let mut out: Vec = Vec::with_capacity(arr.len().min(20)); - for item in arr.iter().take(20) { - let venue = item.get("venue").cloned().unwrap_or(serde_json::Value::Null); - let lineup = item - .get("lineup") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()) - .unwrap_or_default(); - out.push(BandsintownEvent { - datetime: item.get("datetime").and_then(|v| v.as_str()).unwrap_or("").to_string(), - venue_name: venue.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(), - venue_city: venue.get("city").and_then(|v| v.as_str()).unwrap_or("").to_string(), - venue_region: venue.get("region").and_then(|v| v.as_str()).unwrap_or("").to_string(), - venue_country: venue.get("country").and_then(|v| v.as_str()).unwrap_or("").to_string(), - url: item.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(), - on_sale_datetime: item.get("on_sale_datetime").and_then(|v| v.as_str()).unwrap_or("").to_string(), - lineup, - }); - } - Ok(out) -} - pub fn run() { // Linux: second `psysonic --player …` forwards over D-Bus before heavy startup. #[cfg(target_os = "linux")] diff --git a/src-tauri/src/lib_commands/app_api/analysis.rs b/src-tauri/src/lib_commands/app_api/analysis.rs new file mode 100644 index 00000000..95dbd417 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/analysis.rs @@ -0,0 +1,176 @@ +use super::*; + +#[tauri::command] +pub(crate) fn analysis_get_waveform( + track_id: String, + md5_16kb: String, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result, String> { + let key = analysis_cache::TrackKey { + track_id: track_id.clone(), + md5_16kb: md5_16kb.clone(), + }; + let row = cache.get_waveform(&key)?; + match &row { + Some(v) => { + crate::app_deprintln!( + "[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}", + track_id, + md5_16kb, + v.bins.len(), + v.bin_count, + v.updated_at + ); + } + None => { + crate::app_deprintln!( + "[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}", + track_id, + md5_16kb + ); + } + } + Ok(row.map(|v| WaveformCachePayload { + bins: v.bins, + bin_count: v.bin_count, + is_partial: v.is_partial, + known_until_sec: v.known_until_sec, + duration_sec: v.duration_sec, + updated_at: v.updated_at, + })) +} + +#[tauri::command] +pub(crate) fn analysis_get_waveform_for_track( + track_id: String, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result, String> { + let row = cache.get_latest_waveform_for_track(&track_id)?; + match &row { + Some(v) => { + crate::app_deprintln!( + "[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}", + track_id, + v.bins.len(), + v.bin_count, + v.updated_at + ); + } + None => { + crate::app_deprintln!( + "[analysis][waveform] db miss track_id={}", + track_id + ); + } + } + Ok(row.map(|v| WaveformCachePayload { + bins: v.bins, + bin_count: v.bin_count, + is_partial: v.is_partial, + known_until_sec: v.known_until_sec, + duration_sec: v.duration_sec, + updated_at: v.updated_at, + })) +} + +#[tauri::command] +pub(crate) fn analysis_get_loudness_for_track( + track_id: String, + target_lufs: Option, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result, String> { + let row = cache.get_latest_loudness_for_track(&track_id)?; + Ok(row.map(|v| { + let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0); + let recommended_gain_db = analysis_cache::recommended_gain_for_target( + v.integrated_lufs, + v.true_peak, + requested_target, + ); + LoudnessCachePayload { + integrated_lufs: v.integrated_lufs, + true_peak: v.true_peak, + recommended_gain_db, + target_lufs: requested_target, + updated_at: v.updated_at, + }})) +} + +#[tauri::command] +pub(crate) fn analysis_delete_loudness_for_track( + track_id: String, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + cache.delete_loudness_for_track_id(&track_id) +} + +#[tauri::command] +pub(crate) fn analysis_delete_all_waveforms( + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + cache.delete_all_waveforms() +} + +#[tauri::command] +pub(crate) fn analysis_enqueue_seed_from_url( + track_id: String, + url: String, + force: Option, + app: tauri::AppHandle, +) -> Result<(), String> { + if track_id.trim().is_empty() || url.trim().is_empty() { + return Ok(()); + } + let force = force.unwrap_or(false); + if !force { + if let Some(engine) = app.try_state::() { + if crate::audio::ranged_loudness_backfill_should_defer(&engine, &track_id) { + crate::app_deprintln!( + "[analysis] backfill skip track_id={} reason=ranged_playback_will_seed", + track_id + ); + return Ok(()); + } + } + } + if !force { + if let Some(cache) = app.try_state::() { + if cache.get_latest_loudness_for_track(&track_id)?.is_some() { + crate::app_deprintln!( + "[analysis] backfill skip (already cached): {}", + track_id + ); + return Ok(()); + } + } + } + let tid_log = track_id.clone(); + let high_priority = analysis_backfill_is_current_track(&app, &track_id); + let shared = analysis_backfill_shared(&app); + let kind = { + let mut st = shared + .state + .lock() + .map_err(|_| "analysis backfill lock poisoned".to_string())?; + st.enqueue(track_id, url, high_priority) + }; + match kind { + AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => { + shared.ping_worker(); + crate::app_deprintln!( + "[analysis] backfill enqueued: track_id={} position={}", + tid_log, + if high_priority { "front" } else { "back" } + ); + } + AnalysisBackfillEnqueueKind::ReorderedFront => { + shared.ping_worker(); + crate::app_deprintln!( + "[analysis] backfill bumped to front (current track) track_id={}", + tid_log + ); + } + AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {} + } + Ok(()) +} diff --git a/src-tauri/src/lib_commands/app_api/core.rs b/src-tauri/src/lib_commands/app_api/core.rs new file mode 100644 index 00000000..8f8577f3 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/core.rs @@ -0,0 +1,126 @@ +use super::*; + +use super::*; + +#[tauri::command] +pub(crate) fn greet(name: &str) -> String { + format!("Hello, {}!", name) +} + +#[tauri::command] +pub(crate) fn exit_app(app_handle: tauri::AppHandle) { + stop_audio_engine(&app_handle); + app_handle.exit(0); +} + +/// Writes `psysonic-cli-snapshot.json` for `psysonic --info` (debounced from the frontend). +#[tauri::command] +pub(crate) fn cli_publish_player_snapshot(snapshot: serde_json::Value) -> Result<(), String> { + crate::cli::write_cli_snapshot(&snapshot) +} + +/// Writes `psysonic-cli-library.json` for `psysonic --player library list`. +#[tauri::command] +pub(crate) fn cli_publish_library_list(payload: serde_json::Value) -> Result<(), String> { + crate::cli::write_library_cli_response(&payload) +} + +/// Writes `psysonic-cli-servers.json` for `psysonic --player server list`. +#[tauri::command] +pub(crate) fn cli_publish_server_list(payload: serde_json::Value) -> Result<(), String> { + crate::cli::write_server_list_cli_response(&payload) +} + +/// Writes `psysonic-cli-search.json` for `psysonic --player search …`. +#[tauri::command] +pub(crate) fn cli_publish_search_results(payload: serde_json::Value) -> Result<(), String> { + crate::cli::write_search_cli_response(&payload) +} + +/// Toggle native window decorations at runtime (Linux custom title bar opt-out). +#[tauri::command] +pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) { + if let Some(win) = app_handle.get_webview_window("main") { + let _ = win.set_decorations(enabled); + // Re-enabling native decorations on GTK causes the window manager to + // re-stack the window, which drops focus. Bring it back immediately. + if enabled { + let _ = win.set_focus(); + } + } +} + +/// WebKitGTK: `enable-smooth-scrolling` also drives deferred / kinetic wheel scrolling. +#[cfg(target_os = "linux")] +pub(crate) fn linux_webkit_apply_smooth_scrolling(win: &tauri::WebviewWindow, enabled: bool) -> Result<(), String> { + win.with_webview(move |platform| { + use webkit2gtk::{SettingsExt, WebViewExt}; + if let Some(settings) = platform.inner().settings() { + settings.set_enable_smooth_scrolling(enabled); + } + }) + .map_err(|e| e.to_string()) +} + +/// Called from the frontend settings toggle (Linux); no-op on other platforms. +#[tauri::command] +pub(crate) fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + use tauri::Manager; + // Each WebviewWindow has its own WebKitGTK Settings — main-only left the + // mini player on the default (inertial) wheel until the user toggled again. + for label in ["main", "mini"] { + if let Some(win) = app_handle.get_webview_window(label) { + linux_webkit_apply_smooth_scrolling(&win, enabled)?; + } + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (enabled, app_handle); + } + Ok(()) +} + +#[tauri::command] +pub(crate) fn set_logging_mode(mode: String) -> Result<(), String> { + crate::logging::set_logging_mode_from_str(&mode) +} + +#[tauri::command] +pub(crate) fn export_runtime_logs(path: String) -> Result { + crate::logging::export_logs_to_file(&path) +} + +#[tauri::command] +pub(crate) fn frontend_debug_log(scope: String, message: String) -> Result<(), String> { + crate::app_deprintln!("[frontend][{}] {}", scope, message); + Ok(()) +} + +#[tauri::command] +pub(crate) fn set_subsonic_wire_user_agent( + user_agent: String, + window_label: String, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + if window_label != "main" { + return Ok(()); + } + let ua = user_agent.trim(); + if ua.is_empty() { + return Err("user agent is empty".to_string()); + } + let mut guard = runtime_subsonic_wire_user_agent() + .write() + .map_err(|_| "user agent state poisoned".to_string())?; + guard.clear(); + guard.push_str(ua); + drop(guard); + + crate::audio::refresh_http_user_agent(&app_handle.state::(), ua); + Ok(()) +} + + diff --git a/src-tauri/src/lib_commands/app_api/integration.rs b/src-tauri/src/lib_commands/app_api/integration.rs new file mode 100644 index 00000000..ab57aa0e --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/integration.rs @@ -0,0 +1,108 @@ +use super::*; + +#[tauri::command] +pub(crate) fn register_global_shortcut( + app: tauri::AppHandle, + shortcut_map: tauri::State, + shortcut: String, + action: String, +) -> Result<(), String> { + use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + + let mut map = shortcut_map.lock().unwrap(); + + // Idempotent: if this exact shortcut+action is already registered, skip. + // This prevents on_shortcut() from accumulating duplicate handlers when + // registerAll() is called again after a JS HMR reload or StrictMode double-effect. + if map.get(&shortcut).map(|a| a == &action).unwrap_or(false) { + return Ok(()); + } + + // Unregister any existing OS grab for this shortcut before re-registering. + if let Ok(s) = shortcut.parse::() { + let _ = app.global_shortcut().unregister(s); + } + map.insert(shortcut.clone(), action.clone()); + drop(map); // release lock before the blocking OS call + + let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; + app.global_shortcut() + .on_shortcut(parsed, move |app, _shortcut, event| { + if event.state == ShortcutState::Pressed { + let event_name = match action.as_str() { + "play-pause" => "media:play-pause", + "next" => "media:next", + "prev" => "media:prev", + "volume-up" => "media:volume-up", + "volume-down" => "media:volume-down", + _ => return, + }; + let _ = app.emit(event_name, ()); + } + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) fn unregister_global_shortcut( + app: tauri::AppHandle, + shortcut_map: tauri::State, + shortcut: String, +) -> Result<(), String> { + use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; + shortcut_map.lock().unwrap().remove(&shortcut); + let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; + app.global_shortcut().unregister(parsed).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) fn mpris_set_metadata( + controls: tauri::State, + title: Option, + artist: Option, + album: Option, + cover_url: Option, + duration_secs: Option, +) -> Result<(), String> { + use souvlaki::MediaMetadata; + use std::time::Duration; + + let duration = duration_secs.map(|s| Duration::from_secs_f64(s)); + let mut guard = controls.lock().unwrap(); + let Some(ctrl) = guard.as_mut() else { return Ok(()); }; + ctrl.set_metadata(MediaMetadata { + title: title.as_deref(), + artist: artist.as_deref(), + album: album.as_deref(), + cover_url: cover_url.as_deref(), + duration, + }) + .map_err(|e| format!("MPRIS set_metadata failed: {e:?}")) +} + +#[tauri::command] +pub(crate) fn mpris_set_playback( + controls: tauri::State, + playing: bool, + position_secs: Option, +) -> Result<(), String> { + use souvlaki::{MediaPlayback, MediaPosition}; + use std::time::Duration; + + let progress = position_secs.map(|s| MediaPosition(Duration::from_secs_f64(s))); + let playback = if playing { + MediaPlayback::Playing { progress } + } else { + MediaPlayback::Paused { progress } + }; + let mut guard = controls.lock().unwrap(); + let Some(ctrl) = guard.as_mut() else { return Ok(()); }; + ctrl.set_playback(playback) + .map_err(|e| format!("MPRIS set_playback failed: {e:?}")) +} + +/// Returns true if `path` is an accessible directory (used for pre-flight checks in the frontend). +#[tauri::command] +pub(crate) fn check_dir_accessible(path: String) -> bool { + std::path::Path::new(&path).is_dir() +} diff --git a/src-tauri/src/lib_commands/app_api/mod.rs b/src-tauri/src/lib_commands/app_api/mod.rs new file mode 100644 index 00000000..1cc36df2 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/mod.rs @@ -0,0 +1,13 @@ +use super::*; + +mod core; +mod navidrome; +mod remote; +mod integration; +mod analysis; + +pub(crate) use core::*; +pub(crate) use navidrome::*; +pub(crate) use remote::*; +pub(crate) use integration::*; +pub(crate) use analysis::*; diff --git a/src-tauri/src/lib_commands/app_api/navidrome.rs b/src-tauri/src/lib_commands/app_api/navidrome.rs new file mode 100644 index 00000000..b6a3d1b0 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/navidrome.rs @@ -0,0 +1,520 @@ +use super::*; + +/// Authenticate with Navidrome's own REST API and return a Bearer token. +pub(crate) async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/auth/login", server_url)) + .json(&serde_json::json!({ "username": username, "password": password })) + .send() + .await + .map_err(|e| e.to_string())?; + let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + data["token"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Navidrome auth: no token in response".to_string()) +} + +#[tauri::command] +pub(crate) async fn upload_playlist_cover( + server_url: String, + playlist_id: String, + username: String, + password: String, + file_bytes: Vec, + mime_type: String, +) -> Result<(), String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let part = reqwest::multipart::Part::bytes(file_bytes) + .file_name("cover.jpg") + .mime_str(&mime_type) + .map_err(|e| e.to_string())?; + let form = reqwest::multipart::Form::new().part("image", part); + reqwest::Client::new() + .post(format!("{}/api/playlist/{}/image", server_url, playlist_id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn upload_radio_cover( + server_url: String, + radio_id: String, + username: String, + password: String, + file_bytes: Vec, + mime_type: String, +) -> Result<(), String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let part = reqwest::multipart::Part::bytes(file_bytes) + .file_name("cover.jpg") + .mime_str(&mime_type) + .map_err(|e| e.to_string())?; + let form = reqwest::multipart::Form::new().part("image", part); + reqwest::Client::new() + .post(format!("{}/api/radio/{}/image", server_url, radio_id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn upload_artist_image( + server_url: String, + artist_id: String, + username: String, + password: String, + file_bytes: Vec, + mime_type: String, +) -> Result<(), String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let part = reqwest::multipart::Part::bytes(file_bytes) + .file_name("cover.jpg") + .mime_str(&mime_type) + .map_err(|e| e.to_string())?; + let form = reqwest::multipart::Form::new().part("image", part); + reqwest::Client::new() + .post(format!("{}/api/artist/{}/image", server_url, artist_id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn delete_radio_cover( + server_url: String, + radio_id: String, + username: String, + password: String, +) -> Result<(), String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let resp = reqwest::Client::new() + .delete(format!("{}/api/radio/{}/image", server_url, radio_id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + .await + .map_err(|e| e.to_string())?; + // 404/503 = no image existed — treat as success + if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE { + resp.error_for_status().map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Payload returned by Navidrome's `/auth/login`. +#[derive(serde::Serialize)] +pub(crate) struct NdLoginResult { + token: String, + #[serde(rename = "userId")] + user_id: String, + #[serde(rename = "isAdmin")] + is_admin: bool, +} + +/// Flatten an error and its `source` chain into a single readable string so +/// frontend toasts can show the actual transport cause (connection refused, +/// tls handshake fail, cert expired, etc.) instead of reqwest's opaque +/// "error sending request for url (…)" wrapper. +pub(crate) fn nd_err(e: reqwest::Error) -> String { + let mut msg = e.to_string(); + let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e); + while let Some(s) = src { + msg.push_str(" | "); + msg.push_str(&s.to_string()); + src = s.source(); + } + msg +} + +/// Retry a request-building closure on transient transport errors +/// (connect/timeout — includes ECONNRESET, TLS handshake EOF, DNS flakes). +/// Three attempts with calm backoff: 0 → 300ms → 700ms (total worst case +/// ~1s). Retrying too aggressively (5+ attempts, short backoff) can drive +/// an already-stressed nginx upstream-probe into "offline" mode, which +/// turns a transient glitch into a visible outage. Status-level failures +/// (401/403/400 with body) return immediately — we don't retry logic +/// errors. +pub(crate) async fn nd_retry(mut build_and_send: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + const BACKOFFS_MS: [u64; 1] = [500]; + let mut last: Option = None; + for attempt in 0..=BACKOFFS_MS.len() { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_millis(BACKOFFS_MS[attempt - 1])).await; + } + match build_and_send().await { + Ok(resp) => return Ok(resp), + Err(e) => { + if !e.is_connect() && !e.is_timeout() { + return Err(nd_err(e)); + } + last = Some(e); + } + } + } + Err(nd_err(last.expect("loop ran at least once"))) +} + +/// Build a reqwest client for Navidrome's native REST endpoints. Plain +/// `reqwest::Client::new()` defaults to HTTP/2 over ALPN with no User-Agent, +/// which some reverse-proxies (strict nginx rules, Cloudflare Tunnel, CDN +/// WAFs) abort mid-TLS-handshake. Pinning HTTP/1.1 and advertising a real +/// User-Agent makes the handshake match what browsers do for the Subsonic +/// endpoints, so `/auth/*` + `/api/*` go through the same path as `/rest/*`. +/// +/// `pool_max_idle_per_host(0)` disables connection pooling. Keeping stale +/// keep-alive connections in the pool caused intermittent "tls handshake +/// eof" errors on the second call to an admin endpoint when a server or +/// proxy had already closed the TCP connection between calls. +pub(crate) fn nd_http_client() -> reqwest::Client { + // TLS 1.2 only: rustls + nginx with TLS-1.3 session resumption caches + // produces intermittent ECONNRESET mid-handshake when the upstream + // starts churning keep-alive connections. Pinning TLS 1.2 matches what + // the WebKit-side Subsonic calls end up negotiating most of the time + // on these setups. + reqwest::Client::builder() + .user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION"))) + .http1_only() + .pool_max_idle_per_host(0) + .max_tls_version(reqwest::tls::Version::TLS_1_2) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin. +#[tauri::command] +pub(crate) async fn navidrome_login( + server_url: String, + username: String, + password: String, +) -> Result { + let body = serde_json::json!({ "username": username, "password": password }); + let resp = nd_retry(|| { + nd_http_client() + .post(format!("{}/auth/login", server_url)) + .json(&body) + .send() + }).await?; + if !resp.status().is_success() { + return Err(format!("Navidrome login failed: HTTP {}", resp.status())); + } + let data: serde_json::Value = resp.json().await.map_err(nd_err)?; + let token = data["token"].as_str().ok_or("no token in response")?.to_string(); + let user_id = data["id"].as_str().unwrap_or("").to_string(); + let is_admin = data["isAdmin"].as_bool().unwrap_or(false); + Ok(NdLoginResult { token, user_id, is_admin }) +} + +/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields. +#[tauri::command] +pub(crate) async fn nd_list_users( + server_url: String, + token: String, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .get(format!("{}/api/user", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }).await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.json::().await.map_err(nd_err) +} + +/// POST `/api/user` — create a user. +#[tauri::command] +pub(crate) async fn nd_create_user( + server_url: String, + token: String, + user_name: String, + name: String, + email: String, + password: String, + is_admin: bool, +) -> Result { + let body = serde_json::json!({ + "userName": user_name, + "name": name, + "email": email, + "password": password, + "isAdmin": is_admin, + }); + let resp = nd_retry(|| { + nd_http_client() + .post(format!("{}/api/user", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }).await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("HTTP {}: {}", status, text)); + } + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged. +#[tauri::command] +pub(crate) async fn nd_update_user( + server_url: String, + token: String, + id: String, + user_name: String, + name: String, + email: String, + password: String, + is_admin: bool, +) -> Result { + let mut body = serde_json::json!({ + "id": id, + "userName": user_name, + "name": name, + "email": email, + "isAdmin": is_admin, + }); + if !password.is_empty() { + body["password"] = serde_json::Value::String(password); + } + let resp = nd_retry(|| { + nd_http_client() + .put(format!("{}/api/user/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }).await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) +} + +/// DELETE `/api/user/{id}`. +#[tauri::command] +pub(crate) async fn nd_delete_user( + server_url: String, + token: String, + id: String, +) -> Result<(), String> { + let resp = nd_retry(|| { + nd_http_client() + .delete(format!("{}/api/user/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }).await?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(()) +} + +/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list. +/// Available to any authenticated user (no admin required). Returns raw JSON array. +#[tauri::command] +pub(crate) async fn nd_list_songs( + server_url: String, + token: String, + sort: String, + order: String, + start: u32, + end: u32, +) -> Result { + let url = format!( + "{}/api/song?_sort={}&_order={}&_start={}&_end={}", + server_url, sort, order, start, end + ); + let resp = nd_retry(|| { + nd_http_client() + .get(&url) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }).await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.json::().await.map_err(nd_err) +} + +/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array. +#[tauri::command] +pub(crate) async fn nd_list_libraries( + server_url: String, + token: String, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .get(format!("{}/api/library", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }).await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.json::().await.map_err(nd_err) +} + +/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user. +/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400. +#[tauri::command] +pub(crate) async fn nd_set_user_libraries( + server_url: String, + token: String, + id: String, + library_ids: Vec, +) -> Result<(), String> { + let body = serde_json::json!({ "libraryIds": library_ids }); + let resp = nd_retry(|| { + nd_http_client() + .put(format!("{}/api/user/{}/library", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }).await?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(()) +} + +/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists. +#[tauri::command] +pub(crate) async fn nd_list_playlists( + server_url: String, + token: String, + smart: Option, +) -> Result { + let resp = nd_retry(|| { + let client = nd_http_client(); + let mut req = client + .get(format!("{}/api/playlist", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)); + if let Some(s) = smart { + req = req.query(&[("smart", s)]); + } + req.send() + }) + .await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.json::().await.map_err(nd_err) +} + +/// POST `/api/playlist` — create playlist (supports smart rules payload). +#[tauri::command] +pub(crate) async fn nd_create_playlist( + server_url: String, + token: String, + body: serde_json::Value, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .post(format!("{}/api/playlist", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }) + .await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("HTTP {}: {}", status, text)); + } + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload). +#[tauri::command] +pub(crate) async fn nd_update_playlist( + server_url: String, + token: String, + id: String, + body: serde_json::Value, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .put(format!("{}/api/playlist/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }) + .await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) +} + +/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available). +#[tauri::command] +pub(crate) async fn nd_get_playlist( + server_url: String, + token: String, + id: String, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .get(format!("{}/api/playlist/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }) + .await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) +} + +/// DELETE `/api/playlist/{id}` — delete playlist. +#[tauri::command] +pub(crate) async fn nd_delete_playlist( + server_url: String, + token: String, + id: String, +) -> Result<(), String> { + let resp = nd_retry(|| { + nd_http_client() + .delete(format!("{}/api/playlist/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }) + .await?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(()) +} diff --git a/src-tauri/src/lib_commands/app_api/remote.rs b/src-tauri/src/lib_commands/app_api/remote.rs new file mode 100644 index 00000000..2bc807bc --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/remote.rs @@ -0,0 +1,344 @@ +use super::*; + +pub(crate) const RADIO_PAGE_SIZE: u32 = 25; + +/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView). +#[tauri::command] +pub(crate) async fn search_radio_browser(query: String, offset: u32) -> Result, String> { + let client = reqwest::Client::new(); + let limit_s = RADIO_PAGE_SIZE.to_string(); + let offset_s = offset.to_string(); + let resp = client + .get("https://de1.api.radio-browser.info/json/stations/search") + .header("User-Agent", subsonic_wire_user_agent()) + .query(&[ + ("name", query.as_str()), + ("hidebroken", "true"), + ("limit", limit_s.as_str()), + ("offset", offset_s.as_str()), + ]) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + resp.json::>().await.map_err(|e| e.to_string()) +} + +/// Fetch top-voted stations from radio-browser.info for initial suggestions. +#[tauri::command] +pub(crate) async fn get_top_radio_stations(offset: u32) -> Result, String> { + let client = reqwest::Client::new(); + let limit_s = RADIO_PAGE_SIZE.to_string(); + let offset_s = offset.to_string(); + let resp = client + .get("https://de1.api.radio-browser.info/json/stations/topvote") + .header("User-Agent", subsonic_wire_user_agent()) + .query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())]) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + resp.json::>().await.map_err(|e| e.to_string()) +} + +/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS. +/// Returns (bytes, content_type). +#[tauri::command] +pub(crate) async fn fetch_url_bytes(url: String) -> Result<(Vec, String), String> { + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + let resp = client + .get(&url) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/jpeg") + .split(';') + .next() + .unwrap_or("image/jpeg") + .trim() + .to_string(); + let bytes = resp.bytes().await.map_err(|e| e.to_string())?; + Ok((bytes.to_vec(), content_type)) +} + +/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions. +/// Returns the response body as a UTF-8 string for parsing on the JS side. +#[tauri::command] +pub(crate) async fn fetch_json_url(url: String) -> Result { + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + let resp = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + let text = resp.text().await.map_err(|e| e.to_string())?; + Ok(text) +} + +/// ICY metadata response returned to the frontend. +#[derive(serde::Serialize)] +pub(crate) struct IcyMetadata { + /// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`). + stream_title: Option, + /// Value of the `icy-name` response header. + icy_name: Option, + /// Value of the `icy-genre` response header. + icy_genre: Option, + /// Value of the `icy-url` response header. + icy_url: Option, + /// Value of the `icy-description` response header. + icy_description: Option, +} + +/// Extract the first `File1=` stream URL from a PLS playlist file. +pub(crate) fn parse_pls_stream_url(content: &str) -> Option { + content.lines() + .map(str::trim) + .find(|l| l.to_lowercase().starts_with("file1=")) + .and_then(|l| { + let url = l[6..].trim(); + (url.starts_with("http://") || url.starts_with("https://")) + .then(|| url.to_string()) + }) +} + +/// Extract the first non-comment HTTP URL from an M3U/M3U8 playlist file. +pub(crate) fn parse_m3u_stream_url(content: &str) -> Option { + content.lines() + .map(str::trim) + .find(|l| !l.is_empty() && !l.starts_with('#') + && (l.starts_with("http://") || l.starts_with("https://"))) + .map(str::to_string) +} + +/// If `url` points to a PLS or M3U playlist, fetch it and return the first +/// stream URL it contains. Returns `None` for direct stream URLs. +pub(crate) async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option { + let path = url.split('?').next().unwrap_or(url).to_lowercase(); + let is_pls = path.ends_with(".pls"); + let is_m3u = path.ends_with(".m3u") || path.ends_with(".m3u8"); + if !is_pls && !is_m3u { + return None; + } + + let resp = client + .get(url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .ok()?; + + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_lowercase(); + + let text = resp.text().await.ok()?; + + if is_pls || ct.contains("scpls") || ct.contains("pls+xml") { + parse_pls_stream_url(&text) + } else { + parse_m3u_stream_url(&text) + } +} + +/// Fetch ICY in-stream metadata from a radio stream URL. +/// +/// Sends a GET request with `Icy-MetaData: 1` and reads just enough bytes +/// (up to `icy-metaint` audio bytes plus the following metadata block) to +/// extract the `StreamTitle`. The connection is dropped as soon as the +/// first metadata chunk has been parsed, so bandwidth usage is minimal. +/// +/// If `url` is a PLS or M3U playlist file it is resolved to the first direct +/// stream URL before the ICY request is made. +#[tauri::command] +pub(crate) async fn fetch_icy_metadata(url: String) -> Result { + use futures_util::StreamExt; + + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| e.to_string())?; + + // Resolve PLS/M3U playlist files to their first direct stream URL. + let url = resolve_playlist_url(&client, &url).await.unwrap_or(url); + + let resp = client + .get(&url) + .header("Icy-MetaData", "1") + .send() + .await + .map_err(|e| e.to_string())?; + + // Harvest ICY headers before consuming the body. + let headers = resp.headers(); + let icy_name = headers.get("icy-name").and_then(|v| v.to_str().ok()).map(str::to_string); + let icy_genre = headers.get("icy-genre").and_then(|v| v.to_str().ok()).map(str::to_string); + let icy_url = headers.get("icy-url").and_then(|v| v.to_str().ok()).map(str::to_string); + let icy_description = headers.get("icy-description").and_then(|v| v.to_str().ok()).map(str::to_string); + let metaint: Option = headers + .get("icy-metaint") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse().ok()); + + // If the server doesn't advertise a metaint we can still return header info. + let Some(metaint) = metaint else { + return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description }); + }; + + // Cap metaint at 64 KiB to avoid reading unreasonably large audio chunks. + let metaint = metaint.min(65_536); + let needed = metaint + 1; // +1 for the metadata-length byte + + let mut buf: Vec = Vec::with_capacity(needed + 256); + let mut stream = resp.bytes_stream(); + + while buf.len() < needed { + let Some(chunk) = stream.next().await else { break }; + let chunk = chunk.map_err(|e| e.to_string())?; + buf.extend_from_slice(&chunk); + } + + if buf.len() < needed { + // Stream ended before we reached the metadata block. + return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description }); + } + + // The byte immediately after `metaint` audio bytes encodes metadata length: + // actual_bytes = length_byte * 16 + let meta_len = buf[metaint] as usize * 16; + if meta_len == 0 { + return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description }); + } + + // We may need to read a few more chunks to get the full metadata block. + let total_needed = needed + meta_len; + while buf.len() < total_needed { + let Some(chunk) = stream.next().await else { break }; + let chunk = chunk.map_err(|e| e.to_string())?; + buf.extend_from_slice(&chunk); + } + + let meta_start = needed; // index of first metadata byte + let meta_end = (meta_start + meta_len).min(buf.len()); + let meta_bytes = &buf[meta_start..meta_end]; + + // ICY metadata is Latin-1 encoded; convert to a Rust String lossily. + let meta_str: String = meta_bytes + .iter() + .map(|&b| if b == 0 { '\0' } else { b as char }) + .collect::(); + + // Parse StreamTitle='...' — value ends at the next unescaped single-quote. + let stream_title = meta_str + .split("StreamTitle='") + .nth(1) + .and_then(|s| { + // Find closing quote that is NOT preceded by a backslash. + let mut prev = '\0'; + let mut end = s.len(); + for (i, c) in s.char_indices() { + if c == '\'' && prev != '\\' { + end = i; + break; + } + prev = c; + } + let title = s[..end].trim().to_string(); + if title.is_empty() { None } else { Some(title) } + }); + + Ok(IcyMetadata { stream_title, icy_name, icy_genre, icy_url, icy_description }) +} + +/// Resolve a PLS or M3U playlist URL to its first direct stream URL. +/// Returns the original URL unchanged if it is not a recognised playlist format +/// or if the playlist cannot be fetched/parsed. +#[tauri::command] +pub(crate) async fn resolve_stream_url(url: String) -> String { + let Ok(client) = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + else { + return url; + }; + resolve_playlist_url(&client, &url).await.unwrap_or(url) +} + +/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions. +/// `params` is a list of [key, value] pairs (method must be included). +/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made. +#[tauri::command] +pub(crate) async fn lastfm_request( + params: Vec<[String; 2]>, + sign: bool, + get: bool, + api_key: String, + api_secret: String, +) -> Result { + use std::collections::HashMap; + + let mut map: HashMap = params.into_iter().map(|[k, v]| (k, v)).collect(); + map.insert("api_key".into(), api_key.clone()); + + if sign { + let mut keys: Vec = map.keys().cloned().collect(); + keys.sort(); + let sig_str: String = keys.iter() + .filter(|k| k.as_str() != "format" && k.as_str() != "callback") + .map(|k| format!("{}{}", k, map[k])) + .collect::(); + let sig_input = format!("{}{}", sig_str, api_secret); + let digest = md5::compute(sig_input.as_bytes()); + map.insert("api_sig".into(), format!("{:x}", digest)); + } + + map.insert("format".into(), "json".into()); + + let client = reqwest::Client::new(); + let resp = if get { + client + .get("https://ws.audioscrobbler.com/2.0/") + .query(&map) + .header("User-Agent", subsonic_wire_user_agent()) + .send() + .await + } else { + client + .post("https://ws.audioscrobbler.com/2.0/") + .form(&map) + .header("User-Agent", subsonic_wire_user_agent()) + .send() + .await + }.map_err(|e| e.to_string())?; + + let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + + if let Some(err) = json.get("error") { + return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or(""))); + } + + Ok(json) +} diff --git a/src-tauri/src/lib_commands/cache/downloads.rs b/src-tauri/src/lib_commands/cache/downloads.rs new file mode 100644 index 00000000..538f43c7 --- /dev/null +++ b/src-tauri/src/lib_commands/cache/downloads.rs @@ -0,0 +1,400 @@ +use super::*; + +pub(crate) fn resolve_hot_cache_root( + custom_dir: Option, + app: &tauri::AppHandle, +) -> Result { + if let Some(ref cd) = custom_dir.filter(|s| !s.is_empty()) { + let base = std::path::PathBuf::from(cd); + if !base.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + Ok(base.join("psysonic-hot-cache")) + } else { + Ok(app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("psysonic-hot-cache")) + } +} + +/// Returns true if the current Linux system is Arch-based +/// (checks /etc/arch-release and /etc/os-release). +#[tauri::command] +pub(crate) fn check_arch_linux() -> bool { + #[cfg(target_os = "linux")] + { + if std::path::Path::new("/etc/arch-release").exists() { + return true; + } + if let Ok(content) = std::fs::read_to_string("/etc/os-release") { + for line in content.lines() { + let lower = line.to_lowercase(); + if lower.starts_with("id=arch") { return true; } + if lower.starts_with("id_like=") && lower.contains("arch") { return true; } + } + } + false + } + #[cfg(not(target_os = "linux"))] + { false } +} + +/// Progress payload emitted during an update binary download. +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateDownloadProgress { + bytes: u64, + total: Option, +} + +/// Downloads an update installer/package to the user's Downloads folder. +/// Emits `update:download:progress` events with `{ bytes, total }` every 250 ms. +/// Returns the final absolute file path on success. +#[tauri::command] +pub(crate) async fn download_update(url: String, filename: String, app: tauri::AppHandle) -> Result { + use futures_util::StreamExt; + use std::time::{Duration, Instant}; + use tokio::io::AsyncWriteExt; + + const EMIT_INTERVAL: Duration = Duration::from_millis(250); + + let dest_dir = app.path().download_dir().map_err(|e| e.to_string())?; + let dest_path = dest_dir.join(&filename); + let part_path = dest_dir.join(format!("{}.part", filename)); + + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(3600)) + .build() + .map_err(|e| e.to_string())?; + + let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + let total = response.content_length(); + + let result: Result = async { + let mut file = tokio::fs::File::create(&part_path) + .await + .map_err(|e| e.to_string())?; + + let mut bytes_done: u64 = 0; + let mut stream = response.bytes_stream(); + let mut last_emit = Instant::now(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + file.write_all(&chunk).await.map_err(|e| e.to_string())?; + bytes_done += chunk.len() as u64; + + if last_emit.elapsed() >= EMIT_INTERVAL { + let _ = app.emit("update:download:progress", UpdateDownloadProgress { + bytes: bytes_done, + total, + }); + last_emit = Instant::now(); + } + } + file.flush().await.map_err(|e| e.to_string())?; + Ok(bytes_done) + }.await; + + match result { + Err(e) => { + let _ = tokio::fs::remove_file(&part_path).await; + Err(e) + } + Ok(bytes_done) => { + let _ = app.emit("update:download:progress", UpdateDownloadProgress { + bytes: bytes_done, + total: Some(bytes_done), + }); + tokio::fs::rename(&part_path, &dest_path) + .await + .map_err(|e| e.to_string())?; + Ok(dest_path.to_string_lossy().into_owned()) + } + } +} + +/// Fetches synced lyrics from Netease Cloud Music for a given artist + title. +/// Performs a track search, then fetches the LRC string for the best match. +/// Returns `None` if no match or no lyrics are found. +#[tauri::command] +pub(crate) async fn fetch_netease_lyrics(artist: String, title: String) -> Result, String> { + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(std::time::Duration::from_secs(8)) + .build() + .map_err(|e| e.to_string())?; + + let query = format!("{} {}", artist, title); + let params = [("s", query.as_str()), ("type", "1"), ("limit", "5")]; + let search: serde_json::Value = client + .post("https://music.163.com/api/search/get") + .header("Referer", "https://music.163.com") + .form(¶ms) + .send() + .await + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + let song_id = match search["result"]["songs"][0]["id"].as_i64() { + Some(id) => id, + None => return Ok(None), + }; + + let lyrics: serde_json::Value = client + .get(format!( + "https://music.163.com/api/song/lyric?id={}&lv=1&kv=1&tv=-1", + song_id + )) + .header("Referer", "https://music.163.com") + .send() + .await + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + let lrc = lyrics["lrc"]["lyric"].as_str().unwrap_or("").trim().to_string(); + Ok(if lrc.is_empty() { None } else { Some(lrc) }) +} + +/// Reads embedded synced / unsynced lyrics from a local audio file. +/// +/// Priority order: +/// MP3 → ID3v2 SYLT (synchronized, ms timestamps) → ID3v2 USLT (plain) +/// FLAC → Vorbis SYNCEDLYRICS (LRC string) → Vorbis LYRICS (plain) +/// +/// Returns a standard LRC string (`[mm:ss.cc]line\n…`) for synced lyrics, +/// or plain text for unsynced lyrics. Returns `None` when no lyrics are found. +/// Errors are silenced and mapped to `None` so the frontend falls through to the +/// next lyrics source without crashing. +#[tauri::command] +pub(crate) fn get_embedded_lyrics(path: String) -> Option { + use lofty::file::FileType; + use lofty::prelude::*; + use lofty::probe::Probe; + + let fpath = std::path::Path::new(&path); + if !fpath.exists() { + return None; + } + + // Detect file type from magic bytes only — no full tag read yet. + // guess_file_type() consumes self and returns Self, so reassign. + let probe = Probe::open(fpath).ok()?; + let probe = probe.guess_file_type().ok()?; + let file_type = probe.file_type(); + + // ── MP3 / MPEG: use the `id3` crate for SYLT / USLT ───────────────────── + // lofty's MpegFile::id3v2_tag field is pub(crate) — not accessible here. + // The `id3` crate exposes a clean public API for typed ID3v2 frames. + if matches!(file_type, Some(FileType::Mpeg)) { + use id3::{Content, Tag as Id3Tag}; + + if let Ok(tag) = Id3Tag::read_from_path(fpath) { + // 1. SYLT — millisecond-timestamped synced lyrics. + for frame in tag.frames() { + if frame.id() != "SYLT" { + continue; + } + if let Content::SynchronisedLyrics(sylt) = frame.content() { + // Only accept millisecond timestamps — MPEG-frame-based + // timestamps can't be converted to wall-clock seconds. + if sylt.timestamp_format != id3::frame::TimestampFormat::Ms { + continue; + } + let lrc: String = sylt + .content + .iter() + .filter_map(|(ms, text)| { + let t = text.trim(); + if t.is_empty() { + return None; + } + let mins = ms / 60_000; + let secs = (ms % 60_000) / 1_000; + let cs = (ms % 1_000) / 10; + // [mm:ss.cc] matches parseLrc's /\d+(?:\.\d*)?/ regex + Some(format!("[{:02}:{:02}.{:02}]{}\n", mins, secs, cs, t)) + }) + .collect(); + if !lrc.is_empty() { + return Some(lrc.trim_end().to_owned()); + } + } + } + + // 2. USLT — unsynchronized lyrics, plain-text fallback. + for frame in tag.frames() { + if frame.id() != "USLT" { + continue; + } + if let Content::Lyrics(uslt) = frame.content() { + let text = uslt.text.trim(); + if !text.is_empty() { + return Some(text.to_owned()); + } + } + } + } + return None; // MPEG file but no usable lyrics found + } + + // ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ──────────────────── + // Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field. + // It is not a known lofty ItemKey, so access it via ItemKey::Unknown. + let tagged = probe.read().ok()?; + for tag in tagged.tags() { + if let Some(lrc) = tag.get_string(&ItemKey::Unknown("SYNCEDLYRICS".to_owned())) { + let lrc = lrc.trim(); + if !lrc.is_empty() { + return Some(lrc.to_owned()); + } + } + if let Some(plain) = tag.get_string(&ItemKey::Lyrics) { + let plain = plain.trim(); + if !plain.is_empty() { + return Some(plain.to_owned()); + } + } + } + + None +} + +/// Opens a directory in the OS file manager (Explorer / Finder / Nautilus). +/// Uses platform-specific process spawning — tauri-plugin-shell's open() only +/// allows https:// URLs per the capability scope and fails silently for paths. +#[tauri::command] +pub(crate) fn open_folder(path: String) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + std::process::Command::new("explorer.exe") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Progress payload emitted to the frontend during a ZIP download. +/// `total` is `None` when the server doesn't send a `Content-Length` header +/// (Navidrome on-the-fly ZIPs). +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ZipProgress { + id: String, + bytes: u64, + total: Option, +} + +/// Downloads a server-generated ZIP (album/playlist) directly to disk via streaming. +/// Emits `download:zip:progress` events every 500 ms so the frontend can show +/// live MB-counter without holding any binary data in the WebView process. +/// Returns the final destination path on success. +#[tauri::command] +pub(crate) async fn download_zip( + id: String, + url: String, + dest_path: String, + app: tauri::AppHandle, +) -> Result { + use futures_util::StreamExt; + use std::time::{Duration, Instant}; + use tokio::io::AsyncWriteExt; + + const EMIT_INTERVAL: Duration = Duration::from_millis(500); + + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(7200)) // up to 2 h for large on-the-fly ZIPs + .build() + .map_err(|e| e.to_string())?; + + let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + let total = response.content_length(); // None for Navidrome on-the-fly ZIPs + let part_path = format!("{dest_path}.part"); + + // Stream to .part file; rename on success, delete on error. + let result: Result = async { + let mut file = tokio::fs::File::create(&part_path) + .await + .map_err(|e| e.to_string())?; + + let mut bytes_done: u64 = 0; + let mut stream = response.bytes_stream(); + let mut last_emit = Instant::now(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + file.write_all(&chunk).await.map_err(|e| e.to_string())?; + bytes_done += chunk.len() as u64; + + if last_emit.elapsed() >= EMIT_INTERVAL { + let _ = app.emit("download:zip:progress", ZipProgress { + id: id.clone(), + bytes: bytes_done, + total, + }); + last_emit = Instant::now(); + } + } + file.flush().await.map_err(|e| e.to_string())?; + Ok(bytes_done) + }.await; + + match result { + Err(e) => { + let _ = tokio::fs::remove_file(&part_path).await; + Err(e) + } + Ok(bytes_done) => { + // Final emission so the frontend sees 100 % (or final MB count). + let _ = app.emit("download:zip:progress", ZipProgress { + id: id.clone(), + bytes: bytes_done, + total: Some(bytes_done), + }); + tokio::fs::rename(&part_path, &dest_path) + .await + .map_err(|e| e.to_string())?; + Ok(dest_path) + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HotCacheDownloadResult { + pub(crate) path: String, + pub(crate) size: u64, +} diff --git a/src-tauri/src/lib_commands/cache/hot.rs b/src-tauri/src/lib_commands/cache/hot.rs new file mode 100644 index 00000000..537532b0 --- /dev/null +++ b/src-tauri/src/lib_commands/cache/hot.rs @@ -0,0 +1,264 @@ +use super::*; + +#[tauri::command] +pub(crate) async fn download_track_hot_cache( + track_id: String, + server_id: String, + url: String, + suffix: String, + custom_dir: Option, + app: tauri::AppHandle, +) -> Result { + let root = resolve_hot_cache_root(custom_dir, &app)?; + let cache_dir = root.join(&server_id); + + tokio::fs::create_dir_all(&cache_dir) + .await + .map_err(|e| e.to_string())?; + + let file_path = cache_dir.join(format!("{}.{}", track_id, suffix)); + let path_str = file_path.to_string_lossy().to_string(); + + if file_path.exists() { + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + crate::app_deprintln!( + "[hot-cache] download disk_hit track_id={} server_id={} bytes={}", + track_id, + server_id, + size + ); + // Disk hit: still seed analysis, but do not block the command (full-file read); the + // prefetch worker runs invokes sequentially. + let app_seed = app.clone(); + let tid = track_id.clone(); + let fp = file_path.clone(); + tokio::spawn(async move { + enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + }); + return Ok(HotCacheDownloadResult { + path: path_str, + size, + }); + } + + crate::app_deprintln!( + "[hot-cache] download http_start track_id={} server_id={}", + track_id, + server_id + ); + + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| e.to_string())?; + + let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + // Stream directly to a .part file; rename on success to avoid partial files. + let part_path = file_path.with_extension(format!("{suffix}.part")); + if let Err(e) = stream_to_file(response, &part_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + return Err(e); + } + tokio::fs::rename(&part_path, &file_path) + .await + .map_err(|e| e.to_string())?; + + let app_seed = app.clone(); + let tid = track_id.clone(); + let fp = file_path.clone(); + tokio::spawn(async move { + enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + }); + + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + crate::app_deprintln!( + "[hot-cache] download http_done track_id={} server_id={} bytes={}", + track_id, + server_id, + size + ); + Ok(HotCacheDownloadResult { + path: path_str, + size, + }) +} + +/// Promotes bytes captured by the manual streaming path into hot cache on disk. +/// Returns `Ok(None)` when no completed stream cache is available for this URL. +#[tauri::command] +pub(crate) async fn promote_stream_cache_to_hot_cache( + track_id: String, + server_id: String, + url: String, + suffix: String, + custom_dir: Option, + app: tauri::AppHandle, + state: tauri::State<'_, audio::AudioEngine>, +) -> Result, String> { + let root = resolve_hot_cache_root(custom_dir, &app)?; + let cache_dir = root.join(&server_id); + tokio::fs::create_dir_all(&cache_dir) + .await + .map_err(|e| e.to_string())?; + + let file_path = cache_dir.join(format!("{}.{}", track_id, suffix)); + let path_str = file_path.to_string_lossy().to_string(); + + if file_path.exists() { + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + crate::app_deprintln!( + "[hot-cache] promote disk_hit track_id={} server_id={} bytes={}", + track_id, + server_id, + size + ); + let app_seed = app.clone(); + let tid = track_id.clone(); + let fp = file_path.clone(); + tokio::spawn(async move { + enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + }); + return Ok(Some(HotCacheDownloadResult { path: path_str, size })); + } + + let bytes = match audio::take_stream_completed_for_url(&state, &url) { + Some(b) => b, + None => { + crate::app_deprintln!( + "[hot-cache] promote skip track_id={} reason=no_completed_stream_for_url", + track_id + ); + return Ok(None); + } + }; + + let part_path = file_path.with_extension(format!("{suffix}.part")); + if let Err(e) = tokio::fs::write(&part_path, &bytes).await { + let _ = tokio::fs::remove_file(&part_path).await; + return Err(e.to_string()); + } + tokio::fs::rename(&part_path, &file_path) + .await + .map_err(|e| e.to_string())?; + + let _ = enqueue_analysis_seed(&app, &track_id, &bytes).await; + + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + crate::app_deprintln!( + "[hot-cache] promote from_stream track_id={} server_id={} bytes={}", + track_id, + server_id, + size + ); + Ok(Some(HotCacheDownloadResult { path: path_str, size })) +} + +#[tauri::command] +pub(crate) async fn get_hot_cache_size(custom_dir: Option, app: tauri::AppHandle) -> u64 { + fn dir_size(root: std::path::PathBuf) -> u64 { + if !root.exists() { + return 0; + } + let mut total: u64 = 0; + let mut stack = vec![root]; + while let Some(dir) = stack.pop() { + let rd = match std::fs::read_dir(&dir) { + Ok(r) => r, + Err(_) => continue, + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if let Ok(meta) = std::fs::metadata(&path) { + total += meta.len(); + } + } + } + total + } + + resolve_hot_cache_root(custom_dir, &app) + .map(|root| dir_size(root)) + .unwrap_or(0) +} + +#[tauri::command] +pub(crate) async fn delete_hot_cache_track( + local_path: String, + custom_dir: Option, + app: tauri::AppHandle, +) -> Result<(), String> { + let file_path = std::path::PathBuf::from(&local_path); + let existed = file_path.exists(); + if existed { + tokio::fs::remove_file(&file_path) + .await + .map_err(|e| e.to_string())?; + } + crate::app_deprintln!( + "[hot-cache] delete file existed={} path_suffix={}", + existed, + file_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("?") + ); + + let boundary = resolve_hot_cache_root(custom_dir, &app)?; + + let mut current = file_path.parent().map(|p| p.to_path_buf()); + while let Some(dir) = current { + if dir == boundary || !dir.starts_with(&boundary) { + break; + } + match std::fs::read_dir(&dir) { + Ok(mut entries) => { + if entries.next().is_some() { + break; + } + if std::fs::remove_dir(&dir).is_err() { + break; + } + current = dir.parent().map(|p| p.to_path_buf()); + } + Err(_) => break, + } + } + + Ok(()) +} + +/// Removes the entire hot cache root (`psysonic-hot-cache` for the active location). +#[tauri::command] +pub(crate) async fn purge_hot_cache(custom_dir: Option, app: tauri::AppHandle) -> Result<(), String> { + let root = resolve_hot_cache_root(custom_dir, &app)?; + if !root.exists() { + return Ok(()); + } + tokio::fs::remove_dir_all(&root) + .await + .map_err(|e| e.to_string())?; + crate::app_deprintln!( + "[hot-cache] purge root={} status=ok", + root.to_string_lossy() + ); + Ok(()) +} diff --git a/src-tauri/src/lib_commands/cache/mod.rs b/src-tauri/src/lib_commands/cache/mod.rs new file mode 100644 index 00000000..011e3e72 --- /dev/null +++ b/src-tauri/src/lib_commands/cache/mod.rs @@ -0,0 +1,9 @@ +use super::*; + +mod offline; +mod downloads; +mod hot; + +pub(crate) use offline::*; +pub(crate) use downloads::*; +pub(crate) use hot::*; diff --git a/src-tauri/src/lib_commands/cache/offline.rs b/src-tauri/src/lib_commands/cache/offline.rs new file mode 100644 index 00000000..117e8900 --- /dev/null +++ b/src-tauri/src/lib_commands/cache/offline.rs @@ -0,0 +1,226 @@ +use super::*; + +use super::*; + +// ─── Offline Track Cache ────────────────────────────────────────────────────── + +/// Streams an HTTP response body directly to `dest_path` in small chunks. +/// Never buffers the full file in memory — keeps RAM flat regardless of file size. +pub(crate) async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path) -> Result<(), String> { + use futures_util::StreamExt; + use tokio::io::AsyncWriteExt; + + let mut file = tokio::fs::File::create(dest_path) + .await + .map_err(|e| e.to_string())?; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + file.write_all(&chunk).await.map_err(|e| e.to_string())?; + } + file.flush().await.map_err(|e| e.to_string())?; + Ok(()) +} + +pub(crate) async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result { + let high = analysis_backfill_is_current_track(app, track_id); + let outcome = submit_analysis_cpu_seed( + app.clone(), + track_id.to_string(), + bytes.to_vec(), + high, + ) + .await + .map_err(|e| { + crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e); + e + })?; + let has_loudness = app + .try_state::() + .and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten()) + .is_some(); + crate::app_deprintln!( + "[analysis] seed result track_id={} bytes={} has_loudness={} outcome={outcome:?}", + track_id, + bytes.len(), + has_loudness + ); + Ok(has_loudness) +} + +pub(crate) async fn enqueue_analysis_seed_from_file( + app: &tauri::AppHandle, + track_id: &str, + file_path: &std::path::Path, +) { + let bytes = match tokio::fs::read(file_path).await { + Ok(v) => v, + Err(_) => return, + }; + if bytes.is_empty() { + return; + } + let _ = enqueue_analysis_seed(app, track_id, &bytes).await; +} + +/// Downloads a single track to the app's offline cache directory. +/// Returns the absolute file path so TypeScript can store it and later +/// construct a `psysonic-local://` URL for the audio engine. +#[tauri::command] +pub(crate) async fn download_track_offline( + track_id: String, + server_id: String, + url: String, + suffix: String, + custom_dir: Option, + dl_sem: tauri::State<'_, DownloadSemaphore>, + app: tauri::AppHandle, +) -> Result { + // Determine base cache directory. + let cache_dir = if let Some(ref cd) = custom_dir { + let base = std::path::PathBuf::from(cd); + // Check that the volume/directory is still accessible. + if !base.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + base.join(&server_id) + } else { + app.path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("psysonic-offline") + .join(&server_id) + }; + + tokio::fs::create_dir_all(&cache_dir) + .await + .map_err(|e| e.to_string())?; + + let file_path = cache_dir.join(format!("{}.{}", track_id, suffix)); + let path_str = file_path.to_string_lossy().to_string(); + + // Already cached — skip re-download (no semaphore needed). + if file_path.exists() { + return Ok(path_str); + } + + // Acquire a download slot. The permit is held for the duration of the HTTP transfer + // and released automatically when this function returns (success or error). + let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?; + + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| e.to_string())?; + + let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + // Stream directly to a .part file; rename on success to avoid partial files. + let part_path = file_path.with_extension(format!("{suffix}.part")); + if let Err(e) = stream_to_file(response, &part_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + return Err(e); + } + tokio::fs::rename(&part_path, &file_path) + .await + .map_err(|e| e.to_string())?; + + enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await; + + Ok(path_str) +} + +/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir). +#[tauri::command] +pub(crate) async fn get_offline_cache_size(custom_dir: Option, app: tauri::AppHandle) -> u64 { + fn dir_size(root: std::path::PathBuf) -> u64 { + if !root.exists() { return 0; } + let mut total: u64 = 0; + let mut stack = vec![root]; + while let Some(dir) = stack.pop() { + let rd = match std::fs::read_dir(&dir) { + Ok(r) => r, + Err(_) => continue, + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if let Ok(meta) = std::fs::metadata(&path) { + total += meta.len(); + } + } + } + total + } + + let default_dir = match app.path().app_data_dir() { + Ok(d) => d.join("psysonic-offline"), + Err(_) => return 0, + }; + let mut total = dir_size(default_dir); + + if let Some(cd) = custom_dir { + let custom = std::path::PathBuf::from(cd); + if custom != std::path::PathBuf::from("") { + total += dir_size(custom); + } + } + total +} + +/// Removes a cached track from the offline cache. Accepts the full local path +/// (stored in OfflineTrackMeta) so it works regardless of which directory was used. +/// After deleting the file, empty parent directories up to (but not including) +/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`). +#[tauri::command] +pub(crate) async fn delete_offline_track( + local_path: String, + base_dir: Option, + app: tauri::AppHandle, +) -> Result<(), String> { + let file_path = std::path::PathBuf::from(&local_path); + if file_path.exists() { + tokio::fs::remove_file(&file_path) + .await + .map_err(|e| e.to_string())?; + } + + // Determine the safe boundary — never delete at or above this directory. + let boundary = if let Some(bd) = base_dir.filter(|s| !s.is_empty()) { + std::path::PathBuf::from(bd) + } else { + app.path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("psysonic-offline") + }; + + // Walk upward, pruning directories that have become empty. + // Stops as soon as a non-empty directory or the boundary is reached. + let mut current = file_path.parent().map(|p| p.to_path_buf()); + while let Some(dir) = current { + if dir == boundary || !dir.starts_with(&boundary) { + break; + } + match std::fs::read_dir(&dir) { + Ok(mut entries) => { + if entries.next().is_some() { + break; // Directory still has contents — stop pruning. + } + if std::fs::remove_dir(&dir).is_err() { + break; // Could not remove (e.g. permissions) — stop. + } + current = dir.parent().map(|p| p.to_path_buf()); + } + Err(_) => break, + } + } + + Ok(()) +} + diff --git a/src-tauri/src/lib_commands/mod.rs b/src-tauri/src/lib_commands/mod.rs new file mode 100644 index 00000000..06dac471 --- /dev/null +++ b/src-tauri/src/lib_commands/mod.rs @@ -0,0 +1,11 @@ +use super::*; + +mod app_api; +mod cache; +mod sync; +mod ui; + +pub(crate) use app_api::*; +pub(crate) use cache::*; +pub(crate) use sync::*; +pub(crate) use ui::*; diff --git a/src-tauri/src/lib_commands/sync/batch.rs b/src-tauri/src/lib_commands/sync/batch.rs new file mode 100644 index 00000000..43406b0e --- /dev/null +++ b/src-tauri/src/lib_commands/sync/batch.rs @@ -0,0 +1,539 @@ +use super::*; + +#[tauri::command] +pub(crate) async fn list_device_dir_files(dir: String) -> Result, String> { + let root = std::path::PathBuf::from(&dir); + if !root.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + let mut files = Vec::new(); + let mut stack = vec![root]; + while let Some(current) = stack.pop() { + let mut rd = match tokio::fs::read_dir(¤t).await { + Ok(r) => r, + Err(_) => continue, + }; + while let Ok(Some(entry)) = rd.next_entry().await { + let path = entry.path(); + // Skip hidden dirs (e.g. .Trash-1000, .Ventoy, .fseventsd) + let is_hidden = path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(false); + if is_hidden { continue; } + if path.is_dir() { + stack.push(path); + } else { + files.push(path.to_string_lossy().to_string()); + } + } + } + Ok(files) +} + +/// Deletes a file from the device and prunes empty parent directories +/// (up to 2 levels: album folder, then artist folder). +#[tauri::command] +pub(crate) async fn delete_device_file(path: String) -> Result<(), String> { + let p = std::path::PathBuf::from(&path); + if p.exists() { + tokio::fs::remove_file(&p).await.map_err(|e| e.to_string())?; + prune_empty_parents(&p, 2).await; + } + Ok(()) +} + +/// Prune empty parent directories up to `levels` levels above `file_path`. +pub(crate) async fn prune_empty_parents(file_path: &std::path::Path, levels: usize) { + let mut current = file_path.parent().map(|d| d.to_path_buf()); + for _ in 0..levels { + let Some(dir) = current else { break }; + let is_empty = std::fs::read_dir(&dir) + .map(|mut rd| rd.next().is_none()) + .unwrap_or(false); + if is_empty { + let _ = tokio::fs::remove_dir(&dir).await; + current = dir.parent().map(|d| d.to_path_buf()); + } else { + break; + } + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SubsonicAuthPayload { + base_url: String, + u: String, + t: String, + s: String, + v: String, + c: String, + f: String, +} + +#[derive(serde::Deserialize, Clone)] +pub(crate) struct DeviceSyncSourcePayload { + #[serde(rename = "type")] + source_type: String, + id: String, + /// Playlist display name — only present for playlist sources, used when + /// computing the playlist-folder path on the device. + #[serde(default)] + name: Option, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SyncDeltaResult { + add_bytes: u64, + add_count: u32, + del_bytes: u64, + del_count: u32, + available_bytes: u64, + tracks: Vec, +} + +pub(crate) async fn fetch_subsonic_songs( + client: &reqwest::Client, + auth: &SubsonicAuthPayload, + endpoint: &str, + id: &str, +) -> Result, String> { + let url = format!("{}/{}", auth.base_url, endpoint); + let query = vec![ + ("u", auth.u.as_str()), + ("t", auth.t.as_str()), + ("s", auth.s.as_str()), + ("v", auth.v.as_str()), + ("c", auth.c.as_str()), + ("f", auth.f.as_str()), + ("id", id), + ]; + let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?; + let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; + + let root = json.get("subsonic-response").ok_or("No subsonic-response".to_string())?; + let songs = if endpoint == "getAlbum.view" { + root.get("album").and_then(|a| a.get("song")) + } else if endpoint == "getPlaylist.view" { + root.get("playlist").and_then(|p| p.get("entry")) + } else { + None + }; + + if let Some(arr) = songs.and_then(|s| s.as_array()) { + return Ok(arr.clone()); + } else if let Some(obj) = songs.and_then(|s| s.as_object()) { + return Ok(vec![serde_json::Value::Object(obj.clone())]); + } + Ok(vec![]) +} + +#[tauri::command] +pub(crate) async fn calculate_sync_payload( + sources: Vec, + deletion_ids: Vec, + auth: SubsonicAuthPayload, + target_dir: String, +) -> Result { + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + + let mut add_bytes = 0; + let mut add_count = 0; + let mut del_bytes = 0; + let mut del_count = 0; + + let mut sync_tracks = Vec::new(); + let (mut del_sources, mut add_sources) = (Vec::new(), Vec::new()); + for s in sources { + if deletion_ids.contains(&s.id) { + del_sources.push(s); + } else { + add_sources.push(s); + } + } + + let mut handles: Vec<(DeviceSyncSourcePayload, tokio::task::JoinHandle>)> = Vec::new(); + for source in add_sources { + let auth_clone = SubsonicAuthPayload { + base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(), + v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), + }; + let cli = client.clone(); + let source_snapshot = source.clone(); + let handle = tokio::spawn(async move { + let mut res_tracks = Vec::new(); + if source.source_type == "album" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } + } else if source.source_type == "playlist" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } + } else if source.source_type == "artist" { + let url = format!("{}/getArtist.view", auth_clone.base_url); + let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)]; + if let Ok(re) = cli.get(&url).query(&query).send().await { + if let Ok(js) = re.json::().await { + if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) { + let arr = root.as_array().map(|a| a.clone()).unwrap_or_else(|| { + root.as_object().map(|o| vec![serde_json::Value::Object(o.clone())]).unwrap_or_else(|| vec![]) + }); + for al in arr { + if let Some(aid) = al.get("id").and_then(|i| i.as_str()) { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await { + res_tracks.extend(ts); + } + } + } + } + } + } + } + res_tracks + }); + handles.push((source_snapshot, handle)); + } + + let mut del_handles = Vec::new(); + for source in del_sources { + let auth_clone = SubsonicAuthPayload { + base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(), + v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), + }; + let cli = client.clone(); + del_handles.push(tokio::spawn(async move { + let mut res_tracks = Vec::new(); + if source.source_type == "album" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } + } else if source.source_type == "playlist" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } + } + res_tracks + })); + } + + // Dedup key is (source_id, track_id) rather than just track_id — a track + // appearing in both an album and a playlist needs to end up on the device + // in both locations (album tree + playlist folder). + let mut seen_by_source: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); + for (source, handle) in handles { + if let Ok(ts) = handle.await { + let is_playlist = source.source_type == "playlist"; + let mut playlist_position: u32 = 0; + for track in ts { + if let Some(tid) = track.get("id").and_then(|i| i.as_str()) { + let key = (source.id.clone(), tid.to_string()); + if seen_by_source.contains(&key) { continue; } + seen_by_source.insert(key); + if is_playlist { playlist_position += 1; } + let pl_name = if is_playlist { source.name.clone() } else { None }; + let pl_idx = if is_playlist { Some(playlist_position) } else { None }; + + let already_exists = { + let suffix = track.get("suffix").and_then(|s| s.as_str()).unwrap_or("mp3"); + let artist_raw = track.get("artist").and_then(|v| v.as_str()).unwrap_or(""); + let album_artist = track.get("albumArtist") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or(artist_raw); + let sync_info = TrackSyncInfo { + id: tid.to_string(), + url: String::new(), + suffix: suffix.to_string(), + artist: artist_raw.to_string(), + album_artist: album_artist.to_string(), + album: track.get("album").and_then(|v| v.as_str()).unwrap_or("").to_string(), + title: track.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(), + track_number: track.get("track").and_then(|v| v.as_u64()).map(|n| n as u32), + duration: track.get("duration").and_then(|v| v.as_u64()).map(|n| n as u32), + playlist_name: pl_name.clone(), + playlist_index: pl_idx, + }; + let relative = build_track_path(&sync_info); + let file_name = format!("{}.{}", relative, suffix); + std::path::Path::new(&target_dir).join(&file_name).exists() + }; + if !already_exists { + add_count += 1; + let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { + track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 + }); + add_bytes += size; + // Embed playlist context in the track JSON so the frontend + // can pass it back to sync_batch_to_device without re-computing it. + let mut track_with_ctx = track.clone(); + if let Some(obj) = track_with_ctx.as_object_mut() { + if let Some(name) = &pl_name { + obj.insert("_playlistName".to_string(), serde_json::Value::String(name.clone())); + } + if let Some(idx) = pl_idx { + obj.insert("_playlistIndex".to_string(), serde_json::Value::Number(idx.into())); + } + } + sync_tracks.push(track_with_ctx); + } + } + } + } + } + + for handle in del_handles { + if let Ok(ts) = handle.await { + for track in ts { + del_count += 1; + let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { + track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 + }); + del_bytes += size; + } + } + } + + let mut available_bytes = 0; + for drive in get_removable_drives() { + if target_dir.starts_with(&drive.mount_point) { + available_bytes = drive.available_space; + break; + } + } + + Ok(SyncDeltaResult { + add_bytes, add_count, del_bytes, del_count, available_bytes, tracks: sync_tracks, + }) +} + +/// Signals a running `sync_batch_to_device` job to stop after its current tracks finish. +#[tauri::command] +pub(crate) fn cancel_device_sync(job_id: String, app: tauri::AppHandle) { + if let Ok(flags) = sync_cancel_flags().lock() { + if let Some(flag) = flags.get(&job_id) { + flag.store(true, Ordering::Relaxed); + } + } + let _ = app.emit("device:sync:cancelled", serde_json::json!({ "jobId": job_id })); +} + +/// Downloads a batch of tracks to a USB/SD device with controlled concurrency. +/// At most 2 parallel writes run simultaneously to prevent I/O choking on USB. +/// Emits throttled `device:sync:progress` events (max once per 500ms) and a +/// final `device:sync:complete` event with the summary. +#[tauri::command] +pub(crate) async fn sync_batch_to_device( + tracks: Vec, + dest_dir: String, + job_id: String, + expected_bytes: u64, + app: tauri::AppHandle, +) -> Result { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::time::{Duration, Instant}; + use tokio::sync::Mutex; + + let dest_root = std::path::PathBuf::from(&dest_dir); + if !dest_root.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + // Safety: verify dest_dir is on an actual mounted volume, not the root FS. + // This catches the case where a USB drive was unmounted but the empty + // mount-point directory still exists — writing there fills the root partition. + if !is_path_on_mounted_volume(&dest_root) { + return Err("NOT_MOUNTED_VOLUME".to_string()); + } + + // Safety: Ensure target logic hasn't exceeded physical volume capacities securely stopping dead bytes natively. + let drives = get_removable_drives(); + let dest_canon = dest_root.canonicalize().unwrap_or_else(|_| dest_root.clone()); + let dest_str = dest_canon.to_string_lossy(); + + for drive in drives { + if dest_str.starts_with(&drive.mount_point) { + // Buffer of ~10 MB padding boundary natively mapped + if expected_bytes > drive.available_space.saturating_sub(10_000_000) { + return Err(format!("NOT_ENOUGH_SPACE")); + } + break; + } + } + + // Register a cancellation flag for this job. + let cancel_flag = Arc::new(AtomicBool::new(false)); + if let Ok(mut flags) = sync_cancel_flags().lock() { + flags.insert(job_id.clone(), cancel_flag.clone()); + } + + // Shared reqwest client — reused across all downloads. + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(Duration::from_secs(300)) + .build() + .map_err(|e| e.to_string())?; + + // Concurrency limiter: max 2 parallel USB writes. + let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2)); + + // Counters. + let done = std::sync::Arc::new(AtomicU32::new(0)); + let skipped = std::sync::Arc::new(AtomicU32::new(0)); + let failed = std::sync::Arc::new(AtomicU32::new(0)); + + // Throttled event emission (max once per 500ms). + let last_emit = std::sync::Arc::new(Mutex::new(Instant::now())); + let total = tracks.len() as u32; + + let mut handles = Vec::with_capacity(tracks.len()); + + for track in tracks { + let sem = semaphore.clone(); + let cli = client.clone(); + let app2 = app.clone(); + let job = job_id.clone(); + let dest = dest_dir.clone(); + let d = done.clone(); + let s = skipped.clone(); + let f = failed.clone(); + let le = last_emit.clone(); + let cancel = cancel_flag.clone(); + + handles.push(tokio::spawn(async move { + let _permit = sem.acquire().await.expect("semaphore closed"); + + // Bail out if cancelled while waiting in the semaphore queue. + if cancel.load(Ordering::Relaxed) { return; } + + let relative = build_track_path(&track); + let file_name = format!("{}.{}", relative, track.suffix); + let dest_path = std::path::Path::new(&dest).join(&file_name); + let path_str = dest_path.to_string_lossy().to_string(); + + let status; + if dest_path.exists() { + s.fetch_add(1, Ordering::Relaxed); + status = "skipped"; + } else { + // Ensure parent directories exist. + if let Some(parent) = dest_path.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e.to_string(), + })); + return; + } + } + + let response = match cli.get(&track.url).send().await { + Ok(r) if r.status().is_success() => r, + Ok(r) => { + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": format!("HTTP {}", r.status().as_u16()), + })); + return; + } + Err(e) => { + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e.to_string(), + })); + return; + } + }; + + let part_path = dest_path.with_extension(format!("{}.part", track.suffix)); + if let Err(e) = stream_to_file(response, &part_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e, + })); + return; + } + if let Err(e) = tokio::fs::rename(&part_path, &dest_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e.to_string(), + })); + return; + } + + d.fetch_add(1, Ordering::Relaxed); + status = "done"; + } + + // Throttled progress event — max once per 500ms. + let should_emit = { + let mut guard = le.lock().await; + if guard.elapsed() >= Duration::from_millis(500) { + *guard = Instant::now(); + true + } else { + false + } + }; + if should_emit { + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": status, "path": path_str, + "done": d.load(Ordering::Relaxed), + "skipped": s.load(Ordering::Relaxed), + "failed": f.load(Ordering::Relaxed), + "total": total, + })); + } + })); + } + + // Wait for all tasks to complete. + for handle in handles { + let _ = handle.await; + } + + // Clean up the cancellation flag. + let was_cancelled = cancel_flag.load(Ordering::Relaxed); + if let Ok(mut flags) = sync_cancel_flags().lock() { + flags.remove(&job_id); + } + + let result = SyncBatchResult { + done: done.load(Ordering::Relaxed), + skipped: skipped.load(Ordering::Relaxed), + failed: failed.load(Ordering::Relaxed), + }; + + // Final event so the frontend always sees 100%. + let _ = app.emit("device:sync:complete", serde_json::json!({ + "jobId": job_id, + "done": result.done, + "skipped": result.skipped, + "failed": result.failed, + "total": total, + "cancelled": was_cancelled, + })); + + Ok(result) +} + +/// Deletes multiple files from the device in one call and prunes empty parent +/// directories. Returns the number of files successfully deleted. +#[tauri::command] +pub(crate) async fn delete_device_files(paths: Vec) -> Result { + let mut deleted: u32 = 0; + for path in &paths { + let p = std::path::PathBuf::from(path); + if p.exists() { + if tokio::fs::remove_file(&p).await.is_ok() { + deleted += 1; + prune_empty_parents(&p, 2).await; + } + } + } + Ok(deleted) +} diff --git a/src-tauri/src/lib_commands/sync/device.rs b/src-tauri/src/lib_commands/sync/device.rs new file mode 100644 index 00000000..fca83df4 --- /dev/null +++ b/src-tauri/src/lib_commands/sync/device.rs @@ -0,0 +1,393 @@ +use super::*; + +// ─── Device Sync ───────────────────────────────────────────────────────────── + +/// Information about a single mounted removable drive. +#[derive(Clone, serde::Serialize)] +pub(crate) struct RemovableDrive { + pub(crate) name: String, + pub(crate) mount_point: String, + pub(crate) available_space: u64, + pub(crate) total_space: u64, + pub(crate) file_system: String, + pub(crate) is_removable: bool, +} + +/// Returns all currently mounted removable drives. +/// On Linux these are typically USB sticks / SD cards under /media or /run/media. +/// On macOS they appear under /Volumes. On Windows they are separate drive letters. +#[tauri::command] +pub(crate) fn get_removable_drives() -> Vec { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + disks + .list() + .iter() + .filter(|d| d.is_removable()) + .map(|d| RemovableDrive { + name: d.name().to_string_lossy().to_string(), + mount_point: d.mount_point().to_string_lossy().to_string(), + available_space: d.available_space(), + total_space: d.total_space(), + file_system: d.file_system().to_string_lossy().to_string(), + is_removable: true, + }) + .collect() +} + +/// Writes a `psysonic-sync.json` manifest to the root of the target directory. +/// The file records which sources (albums/playlists/artists) are synced to this +/// device so that another machine can pick them up without relying on localStorage. +#[tauri::command] +pub(crate) fn write_device_manifest(dest_dir: String, sources: serde_json::Value) -> Result<(), String> { + let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json"); + // Manifest v2: fixed "{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}" schema, + // no user-configurable filename template. Readers still accept v1 manifests. + let payload = serde_json::json!({ + "version": 2, + "schema": "fixed-v1", + "sources": sources + }); + let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?; + std::fs::write(&path, json).map_err(|e| e.to_string()) +} + +/// Reads `psysonic-sync.json` from the target directory. +/// Returns the parsed JSON value, or null if the file doesn't exist. +#[tauri::command] +pub(crate) fn read_device_manifest(dest_dir: String) -> Option { + let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json"); + let content = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() +} + +/// Per-entry result for `rename_device_files`. +#[derive(serde::Serialize)] +pub(crate) struct RenameResult { + #[serde(rename = "oldPath")] + old_path: String, + #[serde(rename = "newPath")] + new_path: String, + ok: bool, + error: Option, +} + +/// Atomically renames files on the device from their old path to the new fixed- +/// schema path. Intended for the migration flow when switching away from the +/// user-configurable template. All paths are relative to `target_dir`. +/// +/// After renaming, removes any directories left empty under `target_dir` +/// (so stale `{OldArtist}/{OldAlbum}/` trees don't linger). +/// +/// Returns a per-entry result so the UI can show which renames succeeded +/// and which failed. Does not roll back on partial failure — each `fs::rename` +/// is atomic, so nothing can be half-renamed. +#[tauri::command] +pub(crate) fn rename_device_files( + target_dir: String, + pairs: Vec<(String, String)>, +) -> Result, String> { + let root = std::path::PathBuf::from(&target_dir); + if !root.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + if !is_path_on_mounted_volume(&root) { + return Err("NOT_MOUNTED_VOLUME".to_string()); + } + + let mut results = Vec::with_capacity(pairs.len()); + for (old_rel, new_rel) in pairs { + let old_abs = root.join(&old_rel); + let new_abs = root.join(&new_rel); + + let entry = if old_rel == new_rel { + // Nothing to do, count as success so the UI can show "already correct". + RenameResult { old_path: old_rel, new_path: new_rel, ok: true, error: None } + } else if !old_abs.exists() { + RenameResult { + old_path: old_rel, new_path: new_rel, + ok: false, error: Some("source not found".to_string()), + } + } else if new_abs.exists() { + RenameResult { + old_path: old_rel, new_path: new_rel, + ok: false, error: Some("target already exists".to_string()), + } + } else { + // Ensure target parent exists. + if let Some(parent) = new_abs.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + results.push(RenameResult { + old_path: old_rel, new_path: new_rel, + ok: false, error: Some(format!("mkdir: {}", e)), + }); + continue; + } + } + match std::fs::rename(&old_abs, &new_abs) { + Ok(_) => RenameResult { old_path: old_rel, new_path: new_rel, ok: true, error: None }, + Err(e) => RenameResult { + old_path: old_rel, new_path: new_rel, + ok: false, error: Some(e.to_string()), + }, + } + }; + results.push(entry); + } + + // Clean up directories emptied by the renames. Walk depth-first and remove + // any dir whose only remaining contents were the files we moved out. + fn remove_empty_dirs(dir: &std::path::Path, root: &std::path::Path) { + if dir == root { return; } + let rd = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return, + }; + let mut empty = true; + let mut children: Vec = Vec::new(); + for entry in rd.flatten() { + let p = entry.path(); + if p.is_dir() { children.push(p); } else { empty = false; } + } + for child in children { + remove_empty_dirs(&child, root); + } + // Re-check after recursion cleared subdirs. + let still_empty = std::fs::read_dir(dir).map(|r| r.count() == 0).unwrap_or(false); + if empty && still_empty { + let _ = std::fs::remove_dir(dir); + } + } + remove_empty_dirs(&root, &root); + + Ok(results) +} + +/// Writes an Extended-M3U playlist at `{dest_dir}/Playlists/{name}/{name}.m3u8`. +/// References are sibling filenames (just `01 - Artist - Title.ext`) so the +/// playlist is self-contained — moving/copying the folder anywhere keeps it +/// working. Tracks are expected to be in playlist order (index starts at 1). +#[tauri::command] +pub(crate) fn write_playlist_m3u8( + dest_dir: String, + playlist_name: String, + tracks: Vec, +) -> Result<(), String> { + let safe_name = sanitize_or(&playlist_name, "Unnamed Playlist"); + let playlist_dir = std::path::Path::new(&dest_dir).join("Playlists").join(&safe_name); + std::fs::create_dir_all(&playlist_dir).map_err(|e| e.to_string())?; + let file_path = playlist_dir.join(format!("{}.m3u8", safe_name)); + + let mut body = String::from("#EXTM3U\n"); + for (i, track) in tracks.iter().enumerate() { + let idx = (i as u32) + 1; + let duration = track.duration.map(|d| d as i64).unwrap_or(-1); + let display_artist = if track.artist.trim().is_empty() { &track.album_artist[..] } else { &track.artist[..] }; + let title = track.title.trim(); + body.push_str(&format!("#EXTINF:{},{} - {}\n", duration, display_artist.trim(), title)); + // Sibling filename — same shape as build_track_path's playlist branch. + let artist_safe = sanitize_or(display_artist, "Unknown Artist"); + let title_safe = sanitize_or(title, "Unknown Title"); + body.push_str(&format!("{:02} - {} - {}.{}\n", idx, artist_safe, title_safe, track.suffix)); + } + std::fs::write(&file_path, body).map_err(|e| e.to_string()) +} + +/// Checks whether `path` sits on top of an active mount point (i.e. not the root +/// filesystem). This prevents accidentally writing to `/media/usb` after the +/// USB drive has been unmounted — at that point the path would fall through to `/` +/// and fill the root partition. +pub(crate) fn is_path_on_mounted_volume(path: &std::path::Path) -> bool { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + let canonical = match path.canonicalize() { + Ok(c) => c, + Err(_) => return false, // path doesn't exist or isn't accessible + }; + // On Windows, canonicalize() prepends "\\?\" (extended-path prefix). + // Strip it so that "\\?\E:\Music" compares correctly against mount point "E:\". + let canonical_raw = canonical.to_string_lossy().into_owned(); + #[cfg(target_os = "windows")] + let canonical_str = canonical_raw.strip_prefix(r"\\?\").unwrap_or(&canonical_raw).to_string(); + #[cfg(not(target_os = "windows"))] + let canonical_str = canonical_raw; + // Find the longest mount-point prefix that matches this path. + // Exclude the root "/" (or "C:\" on Windows) so we never "match" a fallback. + let mut best_len: usize = 0; + for disk in disks.list() { + let mp = disk.mount_point().to_string_lossy().to_string(); + // Skip root mount points (Linux "/" and non-removable Windows drive roots like "C:\"). + // Do NOT skip removable Windows drives (e.g. "E:\") — those are valid sync targets. + let is_windows_root = mp.len() == 3 && mp.ends_with(":\\") && !disk.is_removable(); + if mp == "/" || is_windows_root { + continue; + } + if canonical_str.starts_with(&mp) && mp.len() > best_len { + best_len = mp.len(); + } + } + best_len > 0 +} + +#[derive(serde::Deserialize, Clone)] +pub(crate) struct TrackSyncInfo { + pub(crate) id: String, + pub(crate) url: String, + pub(crate) suffix: String, + /// Track artist — used in Extended M3U (#EXTINF) entries so playlists display + /// the actual performer rather than the album artist. + pub(crate) artist: String, + /// Album artist — used for the top-level folder so compilation albums stay together. + /// Falls back to `artist` in the frontend when the server has no albumArtist tag. + #[serde(rename = "albumArtist")] + pub(crate) album_artist: String, + pub(crate) album: String, + pub(crate) title: String, + #[serde(rename = "trackNumber")] + pub(crate) track_number: Option, + /// Duration in seconds — needed for Extended M3U (#EXTINF) playlist entries. + #[serde(default)] + pub(crate) duration: Option, + /// When set, the track belongs to a playlist source and is placed under + /// `Playlists/{name}/` with `playlist_index` as its filename prefix. + /// Same track synced from both an album and a playlist source ends up twice + /// on the device — once in the album tree, once in the playlist folder. + #[serde(default, rename = "playlistName")] + pub(crate) playlist_name: Option, + #[serde(default, rename = "playlistIndex")] + pub(crate) playlist_index: Option, +} + +/// Summary returned by `sync_batch_to_device` after all tracks are processed. +#[derive(Clone, serde::Serialize)] +pub(crate) struct SyncBatchResult { + pub(crate) done: u32, + pub(crate) skipped: u32, + pub(crate) failed: u32, +} + +#[derive(serde::Serialize)] +pub(crate) struct SyncTrackResult { + pub(crate) path: String, + pub(crate) skipped: bool, +} + +/// Replaces characters that are invalid in file/directory names on Windows and +/// most Unix filesystems with an underscore, and trims leading/trailing dots and +/// spaces which cause issues on Windows. Underscore (not deletion) so that "AC/DC" +/// and "ACDC" don't collapse into the same folder. +pub(crate) fn sanitize_path_component(s: &str) -> String { + const INVALID: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|']; + let sanitized: String = s + .chars() + .map(|c| if INVALID.contains(&c) || c.is_control() { '_' } else { c }) + .collect(); + sanitized.trim_matches(|c| c == '.' || c == ' ').to_string() +} + +/// Sanitize and replace empty results with a placeholder — prevents paths like +/// `//01 - .flac` when metadata is missing. +pub(crate) fn sanitize_or(s: &str, fallback: &str) -> String { + let cleaned = sanitize_path_component(s); + if cleaned.is_empty() { fallback.to_string() } else { cleaned } +} + +/// Builds the fixed device path for a track. When the track carries a playlist +/// context it goes into the playlist folder, otherwise into the album tree. +/// +/// Album-tree: `{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}` +/// Playlist: `Playlists/{PlaylistName}/{PlaylistIndex:02d} - {Artist} - {Title}.{ext}` +pub(crate) fn build_track_path(track: &TrackSyncInfo) -> String { + let relative = match (&track.playlist_name, track.playlist_index) { + (Some(name), Some(idx)) => { + let playlist = sanitize_or(name, "Unnamed Playlist"); + let artist = sanitize_or(&track.artist, "Unknown Artist"); + let title = sanitize_or(&track.title, "Unknown Title"); + format!("Playlists/{}/{:02} - {} - {}", playlist, idx, artist, title) + } + _ => { + let album_artist = sanitize_or(&track.album_artist, "Unknown Artist"); + let album = sanitize_or(&track.album, "Unknown Album"); + let title = sanitize_or(&track.title, "Unknown Title"); + let track_num = track.track_number.map(|n| format!("{:02}", n)).unwrap_or_else(|| "00".to_string()); + format!("{}/{}/{} - {}", album_artist, album, track_num, title) + } + }; + #[cfg(target_os = "windows")] + let relative = relative.replace('/', "\\"); + relative +} + +/// Downloads a single track to a USB/SD device using the configured filename template. +/// Emits `device:sync:progress` events with `{ jobId, trackId, status, path? }`. +#[tauri::command] +pub(crate) async fn sync_track_to_device( + track: TrackSyncInfo, + dest_dir: String, + job_id: String, + app: tauri::AppHandle, +) -> Result { + let relative = build_track_path(&track); + let file_name = format!("{}.{}", relative, track.suffix); + let dest_path = std::path::Path::new(&dest_dir).join(&file_name); + let path_str = dest_path.to_string_lossy().to_string(); + + if dest_path.exists() { + let _ = app.emit("device:sync:progress", serde_json::json!({ + "jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str, + })); + return Ok(SyncTrackResult { path: path_str, skipped: true }); + } + + if let Some(parent) = dest_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + + let client = reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(std::time::Duration::from_secs(300)) + .build() + .map_err(|e| e.to_string())?; + + let response = client.get(&track.url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + let msg = format!("HTTP {}", response.status().as_u16()); + let _ = app.emit("device:sync:progress", serde_json::json!({ + "jobId": job_id, "trackId": track.id, "status": "error", "error": msg, + })); + return Err(msg); + } + + let part_path = dest_path.with_extension(format!("{}.part", track.suffix)); + if let Err(e) = stream_to_file(response, &part_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + let _ = app.emit("device:sync:progress", serde_json::json!({ + "jobId": job_id, "trackId": track.id, "status": "error", "error": e, + })); + return Err(e); + } + tokio::fs::rename(&part_path, &dest_path) + .await + .map_err(|e| e.to_string())?; + + let _ = app.emit("device:sync:progress", serde_json::json!({ + "jobId": job_id, "trackId": track.id, "status": "done", "path": path_str, + })); + Ok(SyncTrackResult { path: path_str, skipped: false }) +} + +/// Computes the expected file paths for a batch of tracks under the fixed schema. +/// Used by the cleanup flow to find orphans. +#[tauri::command] +pub(crate) fn compute_sync_paths(tracks: Vec, dest_dir: String) -> Vec { + tracks.iter().map(|track| { + let relative = build_track_path(track); + let file_name = format!("{}.{}", relative, track.suffix); + std::path::Path::new(&dest_dir) + .join(&file_name) + .to_string_lossy() + .to_string() + }).collect() +} diff --git a/src-tauri/src/lib_commands/sync/mod.rs b/src-tauri/src/lib_commands/sync/mod.rs new file mode 100644 index 00000000..c826f798 --- /dev/null +++ b/src-tauri/src/lib_commands/sync/mod.rs @@ -0,0 +1,9 @@ +use super::*; + +mod device; +mod batch; +mod tray; + +pub(crate) use device::*; +pub(crate) use batch::*; +pub(crate) use tray::*; diff --git a/src-tauri/src/lib_commands/sync/tray.rs b/src-tauri/src/lib_commands/sync/tray.rs new file mode 100644 index 00000000..ff1543da --- /dev/null +++ b/src-tauri/src/lib_commands/sync/tray.rs @@ -0,0 +1,398 @@ +use super::*; + +pub(crate) fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { + let labels = app + .try_state::() + .map(|s| s.lock().unwrap().clone()) + .unwrap_or_default(); + + let play_pause = MenuItemBuilder::with_id("play_pause", &labels.play_pause).build(app)?; + let next = MenuItemBuilder::with_id("next", &labels.next).build(app)?; + let previous = MenuItemBuilder::with_id("previous", &labels.previous).build(app)?; + let sep1 = PredefinedMenuItem::separator(app)?; + let show_hide = MenuItemBuilder::with_id("show_hide", &labels.show_hide).build(app)?; + let sep2 = PredefinedMenuItem::separator(app)?; + let quit = MenuItemBuilder::with_id("quit", &labels.quit).build(app)?; + + let cached_tooltip = app + .try_state::() + .and_then(|s| { + let g = s.lock().ok()?; + if g.is_empty() { None } else { Some(g.clone()) } + }) + .unwrap_or_else(|| "Psysonic".to_string()); + #[cfg(any(target_os = "windows", target_os = "linux"))] + let playback_state = app + .try_state::() + .map(|s| s.0.lock().unwrap().clone()) + .unwrap_or_else(|| "stop".to_string()); + #[cfg(target_os = "windows")] + let tooltip_with_icon = format!("{} {}", tray_state_icon(&playback_state), cached_tooltip); + + // Linux/AppIndicator has no hover tooltip; surface the now-playing track as + // a disabled menu entry at the top instead. The label is updated by + // `set_tray_tooltip` on every track change. + #[cfg(target_os = "linux")] + let (now_playing, sep_now_playing) = { + let icon = tray_state_icon(&playback_state); + let label = if cached_tooltip == "Psysonic" { + format!("{icon} {}", labels.nothing_playing) + } else { + format!("{icon} {cached_tooltip}") + }; + let item = MenuItemBuilder::with_id("now_playing", &label) + .enabled(false) + .build(app)?; + (item, PredefinedMenuItem::separator(app)?) + }; + + #[cfg(target_os = "linux")] + let menu_builder = MenuBuilder::new(app) + .item(&now_playing) + .item(&sep_now_playing); + #[cfg(not(target_os = "linux"))] + let menu_builder = MenuBuilder::new(app); + + let menu = menu_builder + .item(&play_pause) + .item(&previous) + .item(&next) + .item(&sep1) + .item(&show_hide) + .item(&sep2) + .item(&quit) + .build()?; + + // Persist handles so set_tray_menu_labels and set_tray_tooltip can update + // them without rebuilding the whole tray icon. + if let Some(state) = app.try_state::() { + *state.lock().unwrap() = Some(TrayMenuItems { + play_pause: play_pause.clone(), + next: next.clone(), + previous: previous.clone(), + show_hide: show_hide.clone(), + quit: quit.clone(), + #[cfg(target_os = "linux")] + now_playing: Some(now_playing.clone()), + #[cfg(not(target_os = "linux"))] + now_playing: None, + }); + } + + #[cfg(target_os = "windows")] + let tray_builder = TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .tooltip(&tooltip_with_icon); + #[cfg(not(target_os = "windows"))] + let tray_builder = TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .tooltip(&cached_tooltip); + + tray_builder + .on_menu_event(|app, event| match event.id.as_ref() { + "play_pause" => { let _ = app.emit("tray:play-pause", ()); } + "next" => { let _ = app.emit("tray:next", ()); } + "previous" => { let _ = app.emit("tray:previous", ()); } + "show_hide" => { + if let Some(win) = app.get_webview_window("main") { + if win.is_visible().unwrap_or(false) { + let _ = win.eval(PAUSE_RENDERING_JS); + let _ = win.hide(); + } else { + let _ = win.eval(RESUME_RENDERING_JS); + let _ = win.show(); + let _ = win.set_focus(); + } + } + } + "quit" => { let _ = app.emit("app:force-quit", ()); } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + // Windows fires a Click on *every* half of a double-click, so a + // double-click would toggle the window visibility twice and end up + // back where it started (the bug #cucadmuh reported). Switch to the + // Windows-only DoubleClick event there and ignore single clicks; + // that matches the standard Windows tray convention (Discord, etc). + #[cfg(target_os = "windows")] + let should_toggle = matches!( + event, + TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } + ); + #[cfg(not(target_os = "windows"))] + let should_toggle = matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + ); + if should_toggle { + let app = tray.app_handle(); + if let Some(win) = app.get_webview_window("main") { + if win.is_visible().unwrap_or(false) { + let _ = win.eval(PAUSE_RENDERING_JS); + let _ = win.hide(); + } else { + let _ = win.eval(RESUME_RENDERING_JS); + let _ = win.show(); + let _ = win.set_focus(); + } + } + } + }) + .build(app) +} + +/// Creates the tray icon, or `None` if the OS cannot host one. +/// +/// On Linux, `libayatana-appindicator3` / `libappindicator3` may be absent (minimal +/// installs, wrong `LD_LIBRARY_PATH`). The `tray-icon` stack can **panic** on `dlopen` +/// failure instead of returning `Err`, so we catch unwind and keep the app running +/// (e.g. cold start with `--player` still works without tray libraries). +pub(crate) fn try_build_tray_icon(app: &tauri::AppHandle) -> Option { + let app = app.clone(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray_icon(&app))) { + Ok(Ok(tray)) => Some(tray), + Ok(Err(e)) => { + crate::app_eprintln!("[Psysonic] System tray unavailable: {e}"); + None + } + Err(_) => { + crate::app_eprintln!( + "[Psysonic] System tray unavailable — missing libayatana-appindicator3 or libappindicator3 \ + (install the distro package or set LD_LIBRARY_PATH)" + ); + None + } + } +} + +/// Updates the system-tray icon tooltip with the currently playing track. +/// +/// `tooltip` should be a compact "Artist – Title" form (no app suffix needed — +/// the tray icon itself identifies the app). An empty string resets to the +/// default `"Psysonic"` tooltip. +/// +/// The text is truncated to 127 chars defensively to stay under the historical +/// Windows `NOTIFYICONDATA.szTip` limit (128 bytes including the null terminator). +/// On Linux the visibility depends on the desktop environment / panel — +/// StatusNotifierItem-aware panels (KDE, Cinnamon, GNOME with AppIndicator +/// extension) show it; pure-GNOME without the extension does not. +#[tauri::command] +pub(crate) fn set_tray_tooltip( + app: tauri::AppHandle, + tray_state: tauri::State, + tooltip_cache: tauri::State, + playback_state_cache: tauri::State, + tooltip: String, + playback_state: Option, +) -> Result<(), String> { + let has_track_input = !tooltip.is_empty(); + let state = playback_state.as_deref().unwrap_or(if has_track_input { "play" } else { "stop" }); + let icon = tray_state_icon(state); + let icon_prefix_len = format!("{icon} ").chars().count(); + let max_text_chars = 127usize.saturating_sub(icon_prefix_len); + let ellipsis_reserve = 3usize; + let truncated = if tooltip.chars().count() > max_text_chars { + let take = max_text_chars.saturating_sub(ellipsis_reserve); + tooltip.chars().take(take).collect::() + "..." + } else { + tooltip + }; + let has_track = !truncated.is_empty(); + let effective = if has_track { truncated.clone() } else { "Psysonic".to_string() }; + #[cfg(target_os = "windows")] + let effective_with_icon = format!("{icon} {effective}"); + + *tooltip_cache.lock().unwrap() = truncated.clone(); + *playback_state_cache.0.lock().unwrap() = state.to_string(); + + if let Some(tray) = tray_state.lock().unwrap().as_ref() { + #[cfg(target_os = "windows")] + tray.set_tooltip(Some(&effective_with_icon)).map_err(|e| e.to_string())?; + #[cfg(not(target_os = "windows"))] + tray.set_tooltip(Some(&effective)).map_err(|e| e.to_string())?; + } + + #[cfg(target_os = "linux")] + { + if let Some(state) = app.try_state::() { + if let Some(items) = state.lock().unwrap().as_ref() { + if let Some(np) = items.now_playing.as_ref() { + let label = if has_track { + format!("{icon} {effective}") + } else { + let nothing = app.try_state::() + .map(|s| s.lock().unwrap().nothing_playing.clone()) + .unwrap_or_else(|| "Nothing playing".to_string()); + format!("{icon} {nothing}") + }; + let _ = np.set_text(&label); + } + } + } + } + #[cfg(not(target_os = "linux"))] + let _ = &app; + + Ok(()) +} + +/// Pushes localized labels into the tray menu. Called from the frontend on +/// startup and whenever the i18n language changes. Updates are applied +/// immediately to live menu items via `set_text` (no tray rebuild required) +/// and cached so the labels survive a tray hide/show cycle. +#[tauri::command] +pub(crate) fn set_tray_menu_labels( + app: tauri::AppHandle, + labels_state: tauri::State, + items_state: tauri::State, + tooltip_cache: tauri::State, + play_pause: String, + next: String, + previous: String, + show_hide: String, + quit: String, + nothing_playing: String, +) -> Result<(), String> { + let new_labels = TrayMenuLabels { + play_pause, + next, + previous, + show_hide, + quit, + nothing_playing, + }; + *labels_state.lock().unwrap() = new_labels.clone(); + + if let Some(items) = items_state.lock().unwrap().as_ref() { + let _ = items.play_pause.set_text(&new_labels.play_pause); + let _ = items.next.set_text(&new_labels.next); + let _ = items.previous.set_text(&new_labels.previous); + let _ = items.show_hide.set_text(&new_labels.show_hide); + let _ = items.quit.set_text(&new_labels.quit); + + // Linux now-playing item: only refresh the placeholder. The track + // text itself is owned by `set_tray_tooltip` and shouldn't be + // overwritten by an unrelated language change. + #[cfg(target_os = "linux")] + if let Some(np) = items.now_playing.as_ref() { + let has_track = !tooltip_cache.lock().unwrap().is_empty(); + if !has_track { + let state = app + .try_state::() + .map(|s| s.0.lock().unwrap().clone()) + .unwrap_or_else(|| "stop".to_string()); + let label = format!("{} {}", tray_state_icon(&state), new_labels.nothing_playing); + let _ = np.set_text(&label); + } + } + } + + let _ = (&app, &tooltip_cache); + Ok(()) +} + +/// Show (`true`) or fully remove (`false`) the system-tray icon. +/// +/// The command is strictly idempotent: +/// - `show=true` when the icon is already present → no-op (prevents duplicate icons). +/// - `show=false` when the icon is already absent → no-op. +/// +/// For removal, `set_visible(false)` is called explicitly before the handle is +/// dropped because some platforms (Windows notification area, certain Linux DEs) +/// process the OS removal asynchronously — hiding first prevents a brief "ghost" +/// icon from appearing alongside a freshly created one. +#[tauri::command] +pub(crate) fn toggle_tray_icon( + app: tauri::AppHandle, + tray_state: tauri::State, + show: bool, +) -> Result<(), String> { + let mut guard = tray_state.lock().unwrap(); + + if show { + // Early-return when already shown — never build a second icon. + if guard.is_some() { + return Ok(()); + } + let Some(tray) = try_build_tray_icon(&app) else { + return Err( + "Tray icon could not be created (missing system libraries on Linux).".into(), + ); + }; + *guard = Some(tray); + } else if let Some(tray) = guard.take() { + // Hide synchronously before dropping so the OS processes the removal + // before any subsequent show=true call can create a new icon. + let _ = tray.set_visible(false); + // `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem). + } + + Ok(()) +} + +/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`). +/// Called before process exit on macOS to ensure audio stops immediately. +pub(crate) fn stop_audio_engine(app: &tauri::AppHandle) { + let engine = app.state::(); + engine.generation.fetch_add(1, Ordering::SeqCst); + *engine.chained_info.lock().unwrap() = None; + drop(engine.radio_state.lock().unwrap().take()); + let mut cur = engine.current.lock().unwrap(); + if let Some(sink) = cur.sink.take() { sink.stop(); } +} + +/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3, +/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables +/// set by the compositor / DE. +#[cfg(target_os = "linux")] +pub(crate) fn is_tiling_wm() -> bool { + // Direct compositor signatures (most reliable). + let direct = [ + "HYPRLAND_INSTANCE_SIGNATURE", // Hyprland + "SWAYSOCK", // Sway + "I3SOCK", // i3 + ] + .iter() + .any(|&var| std::env::var_os(var).is_some()); + + if direct { + return true; + } + + // Check XDG_CURRENT_DESKTOP for known tiling WMs. + if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { + let desktop = desktop.to_lowercase(); + let tiling_wms = [ + "hyprland", "sway", "i3", "bspwm", "awesome", "openbox", + "xmonad", "dwm", "qtile", "herbstluftwm", "leftwm", + ]; + if tiling_wms.iter().any(|&wm| desktop.contains(wm)) { + return true; + } + } + + false +} + +/// Tauri command: returns true when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set. +/// The frontend uses this to apply a CSS class that swaps out GPU-only effects +/// (backdrop-filter, CSS filter, mask-image) for software-friendly equivalents. +#[tauri::command] +pub(crate) fn no_compositing_mode() -> bool { + std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE") + .map(|v| v == "1") + .unwrap_or(false) +} + +/// Tauri command: lets the frontend know whether we're running under a tiling +/// WM so it can decide whether to render the custom TitleBar component. +#[tauri::command] +pub(crate) fn is_tiling_wm_cmd() -> bool { + is_tiling_wm() +} diff --git a/src-tauri/src/lib_commands/ui/bandsintown.rs b/src-tauri/src/lib_commands/ui/bandsintown.rs new file mode 100644 index 00000000..9ddf0a61 --- /dev/null +++ b/src-tauri/src/lib_commands/ui/bandsintown.rs @@ -0,0 +1,79 @@ +use super::*; + +// ── Bandsintown ────────────────────────────────────────────────────────────── +// Public REST API: https://rest.bandsintown.com/artists/{name}/events?app_id=… +// Bandsintown whitelists app IDs — arbitrary strings now return 403 Forbidden. +// `js_app_id` is the ID their own embeddable widget uses and is broadly accepted. +pub(crate) const BANDSINTOWN_APP_ID: &str = "js_app_id"; + +#[derive(serde::Serialize, Default)] +pub(crate) struct BandsintownEvent { + datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00") + venue_name: String, + venue_city: String, + venue_region: String, + venue_country: String, + url: String, + on_sale_datetime: String, + lineup: Vec, +} + +/// Fetch upcoming Bandsintown events for an artist by name. +/// Returns an empty list on any failure (404, network, parse) — the UI +/// just hides the section in that case. +#[tauri::command] +pub(crate) async fn fetch_bandsintown_events(artist_name: String) -> Result, String> { + let trimmed = artist_name.trim(); + if trimmed.is_empty() { + return Ok(vec![]); + } + // Bandsintown expects the artist name URL-encoded; their API treats `/` as a + // path separator (so e.g. AC/DC must be encoded as AC%252FDC). + let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect(); + let url = format!( + "https://rest.bandsintown.com/artists/{}/events?app_id={}", + encoded, BANDSINTOWN_APP_ID + ); + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + { + Ok(c) => c, + Err(_) => return Ok(vec![]), + }; + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(_) => return Ok(vec![]), + }; + if !resp.status().is_success() { + return Ok(vec![]); + } + let raw: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(_) => return Ok(vec![]), + }; + let arr = match raw.as_array() { + Some(a) => a, + None => return Ok(vec![]), + }; + let mut out: Vec = Vec::with_capacity(arr.len().min(20)); + for item in arr.iter().take(20) { + let venue = item.get("venue").cloned().unwrap_or(serde_json::Value::Null); + let lineup = item + .get("lineup") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()) + .unwrap_or_default(); + out.push(BandsintownEvent { + datetime: item.get("datetime").and_then(|v| v.as_str()).unwrap_or("").to_string(), + venue_name: venue.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(), + venue_city: venue.get("city").and_then(|v| v.as_str()).unwrap_or("").to_string(), + venue_region: venue.get("region").and_then(|v| v.as_str()).unwrap_or("").to_string(), + venue_country: venue.get("country").and_then(|v| v.as_str()).unwrap_or("").to_string(), + url: item.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(), + on_sale_datetime: item.get("on_sale_datetime").and_then(|v| v.as_str()).unwrap_or("").to_string(), + lineup, + }); + } + Ok(out) +} diff --git a/src-tauri/src/lib_commands/ui/mini.rs b/src-tauri/src/lib_commands/ui/mini.rs new file mode 100644 index 00000000..07cde697 --- /dev/null +++ b/src-tauri/src/lib_commands/ui/mini.rs @@ -0,0 +1,413 @@ +use super::*; + +use super::*; + +// ── Mini Player window ────────────────────────────────────────────────────── +// Secondary always-on-top window with minimal playback controls. Uses the +// same frontend bundle as the main window; disambiguated by window label +// "mini". On tiling WMs (Hyprland, Sway, i3, …) always-on-top is ignored, so +// we fall back to a regular window there. + +/// Persisted geometry for the mini player. Stored in +/// `/mini_player_pos.json` and rewritten (throttled) on +/// every `WindowEvent::Moved` so the window reopens where the user last +/// left it. Coordinates are physical pixels — that's what `set_position` +/// and the move event both report, so we don't need to round-trip through +/// scale factors that may differ across monitors. +#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug)] +pub(crate) struct MiniPlayerPosition { + x: i32, + y: i32, +} + +pub(crate) fn mini_pos_file(app: &tauri::AppHandle) -> Option { + app.path().app_config_dir().ok().map(|p| p.join("mini_player_pos.json")) +} + +pub(crate) fn read_mini_pos(app: &tauri::AppHandle) -> Option { + let path = mini_pos_file(app)?; + let raw = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&raw).ok() +} + +pub(crate) fn write_mini_pos(app: &tauri::AppHandle, pos: MiniPlayerPosition) { + let Some(path) = mini_pos_file(app) else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(&pos) { + let _ = std::fs::write(path, json); + } +} + +/// Tracks when we last set the mini player position programmatically. +/// `WindowEvent::Moved` fires for both user drags AND our own `show()` / +/// `set_position` calls — without this guard the WM's "centre on show" +/// behaviour would silently overwrite the user's saved position. +pub(crate) fn last_programmatic_pos_set() -> &'static Mutex { + static LAST: OnceLock> = OnceLock::new(); + LAST.get_or_init(|| Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10))) +} + +pub(crate) fn mark_mini_pos_programmatic() { + *last_programmatic_pos_set().lock().unwrap() = std::time::Instant::now(); +} + +pub(crate) fn is_mini_pos_programmatic() -> bool { + last_programmatic_pos_set().lock().unwrap().elapsed() + < std::time::Duration::from_millis(1000) +} + +/// Throttle disk writes during a drag — `WindowEvent::Moved` fires on +/// every pointer step. 250 ms keeps the file fresh enough that any close +/// or release lands a recent position, without hammering the disk. +/// Programmatic moves (during `show()` / `set_position`) are skipped so +/// WM re-centring on re-show doesn't clobber the saved position. +pub(crate) fn persist_mini_pos_throttled(app: &tauri::AppHandle, x: i32, y: i32) { + if is_mini_pos_programmatic() { + return; + } + static LAST_WRITE: OnceLock> = OnceLock::new(); + let mu = LAST_WRITE.get_or_init(|| { + Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10)) + }); + { + let mut last = mu.lock().unwrap(); + if last.elapsed() < std::time::Duration::from_millis(250) { + return; + } + *last = std::time::Instant::now(); + } + write_mini_pos(app, MiniPlayerPosition { x, y }); +} + +/// Returns true when the saved top-left lands inside an available monitor +/// with enough room (≥ 80 px in each axis) to leave a draggable corner of +/// the window on-screen. Used to drop persisted positions that point at a +/// monitor that is no longer enumerated (unplugged, hot-plug detection +/// race during early boot, resolution change, monitor reorder). +pub(crate) fn mini_position_visible(app: &tauri::AppHandle, x: i32, y: i32) -> bool { + const MIN_VISIBLE: i32 = 80; + let monitors = match app.available_monitors() { + Ok(m) if !m.is_empty() => m, + _ => return false, + }; + monitors.iter().any(|m| { + let mp = m.position(); + let ms = m.size(); + x >= mp.x + && y >= mp.y + && x + MIN_VISIBLE <= mp.x + ms.width as i32 + && y + MIN_VISIBLE <= mp.y + ms.height as i32 + }) +} + +/// Default position when nothing is persisted: bottom-right of the monitor +/// the main window sits on (falls back to primary). A 24 px logical margin +/// keeps it off the screen edge; +56 px on the bottom margin avoids most +/// taskbars/docks since Tauri does not expose work-area rects. +pub(crate) fn default_mini_position(app: &tauri::AppHandle) -> Option> { + let monitor = app + .get_webview_window("main") + .and_then(|w| w.current_monitor().ok().flatten()) + .or_else(|| app.primary_monitor().ok().flatten())?; + + let scale = monitor.scale_factor(); + let m_pos = monitor.position(); + let m_size = monitor.size(); + + let win_w = (340.0 * scale).round() as i32; + let win_h = (260.0 * scale).round() as i32; + let margin_x = (24.0 * scale).round() as i32; + let margin_y = (56.0 * scale).round() as i32; + + Some(tauri::PhysicalPosition::new( + m_pos.x + (m_size.width as i32) - win_w - margin_x, + m_pos.y + (m_size.height as i32) - win_h - margin_y, + )) +} + +/// JS snippet to inject into a hidden webview to reduce compositor work while +/// the host window is invisible. +/// +/// WebView2 on Windows can keep a GPU-backed compositor active even when the +/// native window is hidden. This script does **not** stop arbitrary JS timers +/// or every `requestAnimationFrame` loop — it sets a flag the app reads, zeros +/// `--psy-anim-speed` (for CSS that opts into it), and pauses **@keyframes** +/// animations via `animation-play-state` (not CSS transitions). +/// +/// Also sets `data-psy-native-hidden` on `` so global CSS can pause every +/// animation including `::before`/`::after` and portal content under `` +/// when `document.hidden` stays false on some WebView2 builds after `win.hide()`. +pub(crate) const PAUSE_RENDERING_JS: &str = r#" +window.__psyHidden = true; +document.documentElement.setAttribute('data-psy-native-hidden', 'true'); +document.documentElement.style.setProperty('--psy-anim-speed', '0'); +(function () { + const root = document.getElementById('root'); + if (!root) return; + root.querySelectorAll('*').forEach(function (el) { + el.style.animationPlayState = 'paused'; + }); +})(); +"#; + +/// JS snippet to resume rendering when the window becomes visible again. +pub(crate) const RESUME_RENDERING_JS: &str = r#" +window.__psyHidden = false; +document.documentElement.removeAttribute('data-psy-native-hidden'); +document.documentElement.style.removeProperty('--psy-anim-speed'); +(function () { + const root = document.getElementById('root'); + if (!root) return; + root.querySelectorAll('*').forEach(function (el) { + el.style.animationPlayState = ''; + }); +})(); +"#; + +/// Build the mini player webview window. Caller decides `visible` so the +/// same code path serves both pre-creation (Windows, hidden at app start) +/// and lazy creation (other platforms, shown on demand). +pub(crate) fn build_mini_player_window( + app: &tauri::AppHandle, + visible: bool, +) -> Result { + let use_always_on_top = { + #[cfg(target_os = "linux")] + { !is_tiling_wm() } + #[cfg(not(target_os = "linux"))] + { true } + }; + + // Tiling WMs manage window sizes themselves — enforcing a max width + // there fights the compositor. Everywhere else we cap the width so + // a horizontal drag can't stretch the layout across a whole monitor. + let cap_width = { + #[cfg(target_os = "linux")] + { !is_tiling_wm() } + #[cfg(not(target_os = "linux"))] + { true } + }; + + // Resolve target position BEFORE building so the WM places the window + // correctly from creation. Calling `set_position` after `build()` is + // unreliable on several Linux WMs which re-centre hidden windows. + // Drop the persisted position if it would land on a monitor that is + // no longer enumerated (unplugged second monitor, hot-plug race during + // early boot, resolution change) — fall back to the default placement. + let target_physical = read_mini_pos(app) + .filter(|p| mini_position_visible(app, p.x, p.y)) + .map(|p| tauri::PhysicalPosition::new(p.x, p.y)) + .or_else(|| default_mini_position(app)); + let scale = app + .primary_monitor() + .ok() + .flatten() + .map(|m| m.scale_factor()) + .unwrap_or(1.0); + + // macOS + Windows keep the native titlebar (traffic lights / caption + // buttons + system look). Linux uses a custom in-page titlebar so the + // mini fits a tighter visual style across all WMs (incl. tiling). + let use_decorations = !cfg!(target_os = "linux"); + + let mut builder = tauri::WebviewWindowBuilder::new( + app, + "mini", + tauri::WebviewUrl::App("index.html".into()), + ) + .title("Psysonic Mini") + .inner_size(340.0, 260.0) + .min_inner_size(320.0, 240.0) + .resizable(true) + .decorations(use_decorations) + .always_on_top(use_always_on_top) + .skip_taskbar(false) + .visible(visible); + + // Cap width so horizontal drag can't stretch the layout across a whole + // monitor. Height is intentionally left effectively unlimited so users + // can grow the queue list as tall as they want. Skipped on tiling WMs + // since those manage window sizing themselves. + if cap_width { + builder = builder.max_inner_size(400.0, 4096.0); + } + + if let Some(pos) = target_physical { + builder = builder.position(pos.x as f64 / scale, pos.y as f64 / scale); + } + + // Suppress Moved-event echo for the initial show — Linux WMs sometimes + // fire stray Moved events with default coords during the first paint. + mark_mini_pos_programmatic(); + + let win = builder + .build() + .map_err(|e| format!("failed to build mini player window: {e}"))?; + + // Inject pause script immediately when the window is created hidden. + // On Windows WebView2 keeps the GPU context alive even with + // `SetIsVisible(false)` — this JS stops all rendering work. + if !visible { + let _ = win.eval(PAUSE_RENDERING_JS); + } + + Ok(win) +} + +/// Pre-build the mini player window hidden, so the first `open_mini_player` +/// call becomes a pure show/hide and the user sees content instantly. On +/// Windows this already happens unconditionally in `.setup()` as a hang +/// workaround; this command is used by Linux/macOS when the user opts into +/// the `preloadMiniPlayer` setting. Idempotent — no-op if the window exists. +#[tauri::command] +pub(crate) fn preload_mini_player(app: tauri::AppHandle) -> Result<(), String> { + if app.get_webview_window("mini").is_some() { + return Ok(()); + } + build_mini_player_window(&app, false).map(|_| ()) +} + +/// Open (or toggle) the mini player window. On platforms where the window +/// was pre-created at startup (Windows), this is a pure show/hide. On +/// other platforms the window is created lazily on first call. +/// Opening the mini player minimizes the main window; hiding the mini +/// player restores the main window. +#[tauri::command] +pub(crate) fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { + let win = match app.get_webview_window("mini") { + Some(w) => w, + None => build_mini_player_window(&app, false)?, + }; + + let visible = win.is_visible().unwrap_or(false); + if visible { + // Pause before hide so `__psyHidden` is set while the webview is still + // guaranteed schedulable (mirrors tray / main close ordering). + let _ = win.eval(PAUSE_RENDERING_JS); + win.hide().map_err(|e| e.to_string())?; + if let Some(main) = app.get_webview_window("main") { + let _ = main.unminimize(); + let _ = main.show(); + let _ = main.set_focus(); + } + } else { + // Resume rendering before showing — the window needs to be ready + // to paint as soon as it becomes visible. + let _ = win.eval(RESUME_RENDERING_JS); + // Re-applying the saved position after show() — many Linux WMs + // (Mutter, KWin) re-centre hidden windows when they're shown + // again, ignoring any earlier set_position. Mark the move as + // programmatic so the Moved-event handler doesn't echo the + // intermediate centre coords back to disk. + // Drop the persisted position if its monitor is gone and fall + // back to the default placement so we don't open off-screen. + let target = read_mini_pos(&app) + .filter(|p| mini_position_visible(&app, p.x, p.y)) + .map(|p| tauri::PhysicalPosition::new(p.x, p.y)) + .or_else(|| default_mini_position(&app)); + mark_mini_pos_programmatic(); + win.show().map_err(|e| e.to_string())?; + let _ = win.set_focus(); + if let Some(p) = target { + let _ = win.set_position(p); + } + if let Some(main) = app.get_webview_window("main") { + let _ = main.minimize(); + } + } + Ok(()) +} + +/// Hide the mini player window if it exists and restore the main window. +/// Does not destroy the mini window so its state is preserved for next open. +#[tauri::command] +pub(crate) fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> { + if let Some(win) = app.get_webview_window("mini") { + let _ = win.eval(PAUSE_RENDERING_JS); + win.hide().map_err(|e| e.to_string())?; + } + if let Some(main) = app.get_webview_window("main") { + let _ = main.unminimize(); + let _ = main.show(); + let _ = main.set_focus(); + } + Ok(()) +} + +/// Unminimize + show + focus the main window. Called from the mini player's +/// "expand" button. Can't rely on a JS event bridge here because the main +/// window's JS is paused while minimized on WebKitGTK. Also hides the mini +/// window so the two don't sit on screen at the same time. +#[tauri::command] +pub(crate) fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(mini) = app.get_webview_window("mini") { + let _ = mini.eval(PAUSE_RENDERING_JS); + let _ = mini.hide(); + } + if let Some(main) = app.get_webview_window("main") { + let _ = main.eval(RESUME_RENDERING_JS); + main.unminimize().map_err(|e| e.to_string())?; + main.show().map_err(|e| e.to_string())?; + main.set_focus().map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Inject the pause script into this webview (CSS @keyframes pause + `__psyHidden`). +#[tauri::command] +pub(crate) fn pause_rendering(window: tauri::WebviewWindow) -> Result<(), String> { + window.eval(PAUSE_RENDERING_JS).map_err(|e| e.to_string()) +} + +/// Resume rendering work in the current webview. Called when the window +/// becomes visible again. +#[tauri::command] +pub(crate) fn resume_rendering(window: tauri::WebviewWindow) -> Result<(), String> { + window.eval(RESUME_RENDERING_JS).map_err(|e| e.to_string()) +} + +/// Toggle always-on-top on the mini player window. +/// +/// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland) +/// silently ignore `set_always_on_top(true)` when the internal flag is +/// already `true` — which happens whenever the window was hidden and +/// re-shown, or focus was lost and the WM dropped the constraint. We +/// always force a `false → true` cycle so the WM re-evaluates the layer. +#[tauri::command] +pub(crate) fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> { + if let Some(win) = app.get_webview_window("mini") { + if on_top { + let _ = win.set_always_on_top(false); + } + win.set_always_on_top(on_top).map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Resize the mini player window (logical pixels). Used when toggling the +/// queue panel to expand/collapse without a capability dance. Optional +/// `minWidth` / `minHeight` adjust the window's resize floor so the user +/// can't shrink past the layout's minimum (e.g. 2 visible queue rows when +/// the queue panel is open). +#[tauri::command] +pub(crate) fn resize_mini_player( + app: tauri::AppHandle, + width: f64, + height: f64, + min_width: Option, + min_height: Option, +) -> Result<(), String> { + if let Some(win) = app.get_webview_window("mini") { + // Lower the floor first; otherwise set_size to a value below the + // existing min would silently clamp. + if let (Some(mw), Some(mh)) = (min_width, min_height) { + win.set_min_size(Some(tauri::LogicalSize::new(mw, mh))) + .map_err(|e| e.to_string())?; + } + win.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; + } + Ok(()) +} + diff --git a/src-tauri/src/lib_commands/ui/mod.rs b/src-tauri/src/lib_commands/ui/mod.rs new file mode 100644 index 00000000..247e1aaa --- /dev/null +++ b/src-tauri/src/lib_commands/ui/mod.rs @@ -0,0 +1,7 @@ +use super::*; + +mod mini; +mod bandsintown; + +pub(crate) use mini::*; +pub(crate) use bandsintown::*;