mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(tauri): decompose lib command surface into domain modules
Split the large lib command module into nested app_api, cache, sync, and ui submodules while preserving command signatures and behavior. This keeps command responsibilities isolated and makes further extraction safer.
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
use super::*;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_device_dir_files(dir: String) -> Result<Vec<String>, 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<String>,
|
||||
}
|
||||
|
||||
#[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<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_subsonic_songs(
|
||||
client: &reqwest::Client,
|
||||
auth: &SubsonicAuthPayload,
|
||||
endpoint: &str,
|
||||
id: &str,
|
||||
) -> Result<Vec<serde_json::Value>, 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<DeviceSyncSourcePayload>,
|
||||
deletion_ids: Vec<String>,
|
||||
auth: SubsonicAuthPayload,
|
||||
target_dir: String,
|
||||
) -> Result<SyncDeltaResult, String> {
|
||||
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<serde_json::Value>>)> = 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::<serde_json::Value>().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<TrackSyncInfo>,
|
||||
dest_dir: String,
|
||||
job_id: String,
|
||||
expected_bytes: u64,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<SyncBatchResult, String> {
|
||||
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<String>) -> Result<u32, String> {
|
||||
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)
|
||||
}
|
||||
@@ -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<RemovableDrive> {
|
||||
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<serde_json::Value> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<Vec<RenameResult>, 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<std::path::PathBuf> = 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<TrackSyncInfo>,
|
||||
) -> 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<u32>,
|
||||
/// Duration in seconds — needed for Extended M3U (#EXTINF) playlist entries.
|
||||
#[serde(default)]
|
||||
pub(crate) duration: Option<u32>,
|
||||
/// 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<String>,
|
||||
#[serde(default, rename = "playlistIndex")]
|
||||
pub(crate) playlist_index: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<SyncTrackResult, String> {
|
||||
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<TrackSyncInfo>, dest_dir: String) -> Vec<String> {
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use super::*;
|
||||
|
||||
mod device;
|
||||
mod batch;
|
||||
mod tray;
|
||||
|
||||
pub(crate) use device::*;
|
||||
pub(crate) use batch::*;
|
||||
pub(crate) use tray::*;
|
||||
@@ -0,0 +1,398 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
|
||||
let labels = app
|
||||
.try_state::<TrayMenuLabelsState>()
|
||||
.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::<TrayTooltip>()
|
||||
.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::<TrayPlaybackState>()
|
||||
.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::<TrayMenuItemsState>() {
|
||||
*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<TrayIcon> {
|
||||
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<TrayState>,
|
||||
tooltip_cache: tauri::State<TrayTooltip>,
|
||||
playback_state_cache: tauri::State<TrayPlaybackState>,
|
||||
tooltip: String,
|
||||
playback_state: Option<String>,
|
||||
) -> 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::<String>() + "..."
|
||||
} 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::<TrayMenuItemsState>() {
|
||||
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::<TrayMenuLabelsState>()
|
||||
.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<TrayMenuLabelsState>,
|
||||
items_state: tauri::State<TrayMenuItemsState>,
|
||||
tooltip_cache: tauri::State<TrayTooltip>,
|
||||
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::<TrayPlaybackState>()
|
||||
.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<TrayState>,
|
||||
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::<audio::AudioEngine>();
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user