diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 50c88e87..b07161ff 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3611,6 +3611,7 @@ dependencies = [ "psysonic-analysis", "psysonic-audio", "psysonic-core", + "psysonic-syncfs", "reqwest", "ringbuf", "rodio", @@ -3694,6 +3695,28 @@ dependencies = [ "tauri", ] +[[package]] +name = "psysonic-syncfs" +version = "1.46.0-dev" +dependencies = [ + "futures-util", + "id3", + "lofty", + "md5", + "psysonic-analysis", + "psysonic-audio", + "psysonic-core", + "reqwest", + "serde", + "serde_json", + "sysinfo", + "tauri", + "tauri-plugin-process", + "tauri-plugin-shell", + "tokio", + "url", +] + [[package]] name = "pxfm" version = "0.1.29" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 60ffda8c..6e876909 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ tauri-build = { version = "2", features = [] } psysonic-core = { path = "crates/psysonic-core" } psysonic-analysis = { path = "crates/psysonic-analysis" } psysonic-audio = { path = "crates/psysonic-audio" } +psysonic-syncfs = { path = "crates/psysonic-syncfs" } tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-plugin-single-instance = "2" tauri-plugin-shell = "2" diff --git a/src-tauri/crates/psysonic-syncfs/Cargo.toml b/src-tauri/crates/psysonic-syncfs/Cargo.toml new file mode 100644 index 00000000..c3e8b4d1 --- /dev/null +++ b/src-tauri/crates/psysonic-syncfs/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "psysonic-syncfs" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +psysonic-core = { path = "../psysonic-core" } +psysonic-analysis = { path = "../psysonic-analysis" } +psysonic-audio = { path = "../psysonic-audio" } + +tauri = { version = "2" } +tauri-plugin-shell = "2" +tauri-plugin-process = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt", "time", "sync"] } +reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking"] } +futures-util = "0.3" +sysinfo = { version = "0.38", default-features = false, features = ["disk"] } +url = "2" +md5 = "0.8" +lofty = "0.24" +id3 = "1.16.4" diff --git a/src-tauri/src/lib_commands/cache/downloads.rs b/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs similarity index 94% rename from src-tauri/src/lib_commands/cache/downloads.rs rename to src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs index 06594a45..73bddf8f 100644 --- a/src-tauri/src/lib_commands/cache/downloads.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs @@ -1,8 +1,8 @@ use tauri::{Emitter, Manager}; -use crate::subsonic_wire_user_agent; +use psysonic_core::user_agent::subsonic_wire_user_agent; -pub(crate) fn resolve_hot_cache_root( +pub fn resolve_hot_cache_root( custom_dir: Option, app: &tauri::AppHandle, ) -> Result { @@ -24,7 +24,7 @@ pub(crate) fn resolve_hot_cache_root( /// 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 { +pub fn check_arch_linux() -> bool { #[cfg(target_os = "linux")] { if std::path::Path::new("/etc/arch-release").exists() { @@ -46,7 +46,7 @@ pub(crate) fn check_arch_linux() -> bool { /// Progress payload emitted during an update binary download. #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct UpdateDownloadProgress { +pub struct UpdateDownloadProgress { bytes: u64, total: Option, } @@ -55,7 +55,7 @@ pub(crate) struct UpdateDownloadProgress { /// 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 { +pub 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; @@ -128,7 +128,7 @@ pub(crate) async fn download_update(url: String, filename: String, app: tauri::A /// 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> { +pub 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)) @@ -181,7 +181,7 @@ pub(crate) async fn fetch_netease_lyrics(artist: String, title: String) -> Resul /// 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 { +pub fn get_embedded_lyrics(path: String) -> Option { use lofty::file::FileType; use lofty::prelude::*; use lofty::probe::Probe; @@ -198,7 +198,7 @@ pub(crate) fn get_embedded_lyrics(path: String) -> Option { 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. + // lofty's MpegFile::id3v2_tag field is pub — 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}; @@ -280,7 +280,7 @@ pub(crate) fn get_embedded_lyrics(path: String) -> Option { /// 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> { +pub fn open_folder(path: String) -> Result<(), String> { #[cfg(target_os = "windows")] { std::process::Command::new("explorer.exe") @@ -310,7 +310,7 @@ pub(crate) fn open_folder(path: String) -> Result<(), String> { /// (Navidrome on-the-fly ZIPs). #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ZipProgress { +pub struct ZipProgress { id: String, bytes: u64, total: Option, @@ -321,7 +321,7 @@ pub(crate) struct ZipProgress { /// 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( +pub async fn download_zip( id: String, url: String, dest_path: String, @@ -398,7 +398,7 @@ pub(crate) async fn download_zip( #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct HotCacheDownloadResult { - pub(crate) path: String, - pub(crate) size: u64, +pub struct HotCacheDownloadResult { + pub path: String, + pub size: u64, } diff --git a/src-tauri/src/lib_commands/cache/fs_utils.rs b/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs similarity index 92% rename from src-tauri/src/lib_commands/cache/fs_utils.rs rename to src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs index 640ee82d..77544d81 100644 --- a/src-tauri/src/lib_commands/cache/fs_utils.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs @@ -2,7 +2,7 @@ use std::path::Path; /// Recursively sums the size of all files under `root`. /// Missing roots, unreadable directories, and unreadable files are silently skipped. -pub(crate) fn dir_size_recursive(root: &Path) -> u64 { +pub fn dir_size_recursive(root: &Path) -> u64 { if !root.exists() { return 0; } @@ -31,7 +31,7 @@ pub(crate) fn dir_size_recursive(root: &Path) -> u64 { /// /// `boundary` is never removed and is treated as a hard stop. If `start_dir` is /// not under `boundary`, the function is a no-op. -pub(crate) fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) { +pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) { let mut current = Some(start_dir.to_path_buf()); while let Some(dir) = current { if dir == boundary || !dir.starts_with(boundary) { diff --git a/src-tauri/src/lib_commands/cache/hot.rs b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs similarity index 92% rename from src-tauri/src/lib_commands/cache/hot.rs rename to src-tauri/crates/psysonic-syncfs/src/cache/hot.rs index bd6dabc9..69e76724 100644 --- a/src-tauri/src/lib_commands/cache/hot.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs @@ -1,13 +1,13 @@ -use crate::analysis_runtime::enqueue_analysis_seed; -use crate::audio; -use crate::subsonic_wire_user_agent; +use psysonic_analysis::analysis_runtime::enqueue_analysis_seed; +use psysonic_audio as audio; +use psysonic_core::user_agent::subsonic_wire_user_agent; -use super::super::file_transfer::stream_to_file; +use crate::file_transfer::stream_to_file; use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult}; use super::offline::enqueue_analysis_seed_from_file; #[tauri::command] -pub(crate) async fn download_track_hot_cache( +pub async fn download_track_hot_cache( track_id: String, server_id: String, url: String, @@ -103,7 +103,7 @@ pub(crate) async fn download_track_hot_cache( /// 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( +pub async fn promote_stream_cache_to_hot_cache( track_id: String, server_id: String, url: String, @@ -177,14 +177,14 @@ pub(crate) async fn promote_stream_cache_to_hot_cache( } #[tauri::command] -pub(crate) async fn get_hot_cache_size(custom_dir: Option, app: tauri::AppHandle) -> u64 { +pub async fn get_hot_cache_size(custom_dir: Option, app: tauri::AppHandle) -> u64 { resolve_hot_cache_root(custom_dir, &app) .map(|root| super::fs_utils::dir_size_recursive(&root)) .unwrap_or(0) } #[tauri::command] -pub(crate) async fn delete_hot_cache_track( +pub async fn delete_hot_cache_track( local_path: String, custom_dir: Option, app: tauri::AppHandle, @@ -215,7 +215,7 @@ pub(crate) async fn delete_hot_cache_track( /// 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> { +pub 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(()); diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs b/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs new file mode 100644 index 00000000..c8f0f071 --- /dev/null +++ b/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs @@ -0,0 +1,4 @@ +mod fs_utils; +pub mod offline; +pub mod downloads; +pub mod hot; diff --git a/src-tauri/src/lib_commands/cache/offline.rs b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs similarity index 91% rename from src-tauri/src/lib_commands/cache/offline.rs rename to src-tauri/crates/psysonic-syncfs/src/cache/offline.rs index f25685b0..e8c633b3 100644 --- a/src-tauri/src/lib_commands/cache/offline.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs @@ -1,14 +1,14 @@ use tauri::Manager; -use crate::analysis_cache; -use crate::analysis_runtime::enqueue_analysis_seed; +use psysonic_analysis::analysis_cache; +use psysonic_analysis::analysis_runtime::enqueue_analysis_seed; use crate::DownloadSemaphore; -use super::super::file_transfer::{finalize_streamed_download, subsonic_http_client}; +use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; // ─── Offline Track Cache ────────────────────────────────────────────────────── -pub(crate) async fn enqueue_analysis_seed_from_file( +pub async fn enqueue_analysis_seed_from_file( app: &tauri::AppHandle, track_id: &str, file_path: &std::path::Path, @@ -32,7 +32,7 @@ pub(crate) async fn enqueue_analysis_seed_from_file( /// 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( +pub async fn download_track_offline( track_id: String, server_id: String, url: String, @@ -90,7 +90,7 @@ pub(crate) async fn download_track_offline( /// 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 { +pub async fn get_offline_cache_size(custom_dir: Option, app: tauri::AppHandle) -> u64 { let default_dir = match app.path().app_data_dir() { Ok(d) => d.join("psysonic-offline"), Err(_) => return 0, @@ -111,7 +111,7 @@ pub(crate) async fn get_offline_cache_size(custom_dir: Option, app: taur /// 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( +pub async fn delete_offline_track( local_path: String, base_dir: Option, app: tauri::AppHandle, diff --git a/src-tauri/src/lib_commands/file_transfer.rs b/src-tauri/crates/psysonic-syncfs/src/file_transfer.rs similarity index 89% rename from src-tauri/src/lib_commands/file_transfer.rs rename to src-tauri/crates/psysonic-syncfs/src/file_transfer.rs index a9fdda3b..40132d8a 100644 --- a/src-tauri/src/lib_commands/file_transfer.rs +++ b/src-tauri/crates/psysonic-syncfs/src/file_transfer.rs @@ -4,9 +4,9 @@ use std::time::Duration; /// Build a reqwest client with the standard Subsonic UA and a single overall timeout. /// For flows that need separate connect + read timeouts (long-running update/zip /// downloads with progress events), build the client inline. -pub(crate) fn subsonic_http_client(timeout: Duration) -> Result { +pub fn subsonic_http_client(timeout: Duration) -> Result { reqwest::Client::builder() - .user_agent(crate::subsonic_wire_user_agent()) + .user_agent(psysonic_core::user_agent::subsonic_wire_user_agent()) .timeout(timeout) .build() .map_err(|e| e.to_string()) @@ -14,7 +14,7 @@ pub(crate) fn subsonic_http_client(timeout: Duration) -> Result Result<(), String> { @@ -40,7 +40,7 @@ pub(crate) async fn stream_to_file( /// Note vs. previous inline implementations: the offline/device single-track /// flows used to leave a `.part` orphan if the final rename failed. This helper /// always cleans up, matching the batch-sync flow that already did. -pub(crate) async fn finalize_streamed_download( +pub async fn finalize_streamed_download( response: reqwest::Response, dest_path: &Path, part_path: &Path, diff --git a/src-tauri/crates/psysonic-syncfs/src/lib.rs b/src-tauri/crates/psysonic-syncfs/src/lib.rs new file mode 100644 index 00000000..57826695 --- /dev/null +++ b/src-tauri/crates/psysonic-syncfs/src/lib.rs @@ -0,0 +1,28 @@ +//! `psysonic-syncfs` — offline / hot-cache, device sync, and the shared HTTP +//! download helpers used by both. +//! +//! This crate hosts the Tauri commands that read/write the on-disk caches +//! (`offline_*`, `hot_cache_*`) and that copy tracks to mounted USB / SD-card +//! devices (`sync_*`). + +use std::collections::HashMap; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex, OnceLock}; + +// Re-export logging facade so submodules can keep `crate::app_eprintln!()`. +pub use psysonic_core::{app_deprintln, app_eprintln, logging}; + +pub mod cache; +pub mod file_transfer; +pub mod sync; + +/// Shared semaphore that caps simultaneous `download_track_offline` executions. +pub type DownloadSemaphore = Arc; + +/// Per-job cancellation flags for `sync_batch_to_device`. +/// Each running sync registers an `Arc` here; `cancel_device_sync` +/// flips it. +pub fn sync_cancel_flags() -> &'static Mutex>> { + static FLAGS: OnceLock>>> = OnceLock::new(); + FLAGS.get_or_init(|| Mutex::new(HashMap::new())) +} diff --git a/src-tauri/src/lib_commands/sync/batch.rs b/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs similarity index 96% rename from src-tauri/src/lib_commands/sync/batch.rs rename to src-tauri/crates/psysonic-syncfs/src/sync/batch.rs index ce9568fd..81d0323c 100644 --- a/src-tauri/src/lib_commands/sync/batch.rs +++ b/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs @@ -5,14 +5,14 @@ use tauri::Emitter; use crate::sync_cancel_flags; -use super::super::file_transfer::{finalize_streamed_download, subsonic_http_client}; +use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; use super::device::{ build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult, TrackSyncInfo, }; #[tauri::command] -pub(crate) async fn list_device_dir_files(dir: String) -> Result, String> { +pub 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()); @@ -45,7 +45,7 @@ pub(crate) async fn list_device_dir_files(dir: String) -> Result, St /// 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> { +pub 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())?; @@ -55,7 +55,7 @@ pub(crate) async fn delete_device_file(path: String) -> Result<(), String> { } /// 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) { +pub 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 }; @@ -73,7 +73,7 @@ pub(crate) async fn prune_empty_parents(file_path: &std::path::Path, levels: usi #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct SubsonicAuthPayload { +pub struct SubsonicAuthPayload { base_url: String, u: String, t: String, @@ -84,7 +84,7 @@ pub(crate) struct SubsonicAuthPayload { } #[derive(serde::Deserialize, Clone)] -pub(crate) struct DeviceSyncSourcePayload { +pub struct DeviceSyncSourcePayload { #[serde(rename = "type")] source_type: String, id: String, @@ -96,7 +96,7 @@ pub(crate) struct DeviceSyncSourcePayload { #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct SyncDeltaResult { +pub struct SyncDeltaResult { add_bytes: u64, add_count: u32, del_bytes: u64, @@ -105,7 +105,7 @@ pub(crate) struct SyncDeltaResult { tracks: Vec, } -pub(crate) async fn fetch_subsonic_songs( +pub async fn fetch_subsonic_songs( client: &reqwest::Client, auth: &SubsonicAuthPayload, endpoint: &str, @@ -142,7 +142,7 @@ pub(crate) async fn fetch_subsonic_songs( } #[tauri::command] -pub(crate) async fn calculate_sync_payload( +pub async fn calculate_sync_payload( sources: Vec, deletion_ids: Vec, auth: SubsonicAuthPayload, @@ -314,7 +314,7 @@ pub(crate) async fn calculate_sync_payload( /// 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) { +pub 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); @@ -328,7 +328,7 @@ pub(crate) fn cancel_device_sync(job_id: String, app: tauri::AppHandle) { /// 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( +pub async fn sync_batch_to_device( tracks: Vec, dest_dir: String, job_id: String, @@ -517,7 +517,7 @@ pub(crate) async fn sync_batch_to_device( /// 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 { +pub async fn delete_device_files(paths: Vec) -> Result { let mut deleted: u32 = 0; for path in &paths { let p = std::path::PathBuf::from(path); diff --git a/src-tauri/src/lib_commands/sync/device.rs b/src-tauri/crates/psysonic-syncfs/src/sync/device.rs similarity index 89% rename from src-tauri/src/lib_commands/sync/device.rs rename to src-tauri/crates/psysonic-syncfs/src/sync/device.rs index 4301eb00..97532be0 100644 --- a/src-tauri/src/lib_commands/sync/device.rs +++ b/src-tauri/crates/psysonic-syncfs/src/sync/device.rs @@ -1,25 +1,25 @@ use tauri::Emitter; -use super::super::file_transfer::{finalize_streamed_download, subsonic_http_client}; +use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; // ─── 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, +pub struct RemovableDrive { + pub name: String, + pub mount_point: String, + pub available_space: u64, + pub total_space: u64, + pub file_system: String, + pub 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 { +pub fn get_removable_drives() -> Vec { use sysinfo::Disks; let disks = Disks::new_with_refreshed_list(); disks @@ -41,7 +41,7 @@ pub(crate) fn get_removable_drives() -> Vec { /// 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> { +pub 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. @@ -57,7 +57,7 @@ pub(crate) fn write_device_manifest(dest_dir: String, sources: serde_json::Value /// 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 { +pub 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() @@ -65,7 +65,7 @@ pub(crate) fn read_device_manifest(dest_dir: String) -> Option, ) -> Result, String> { @@ -170,7 +170,7 @@ pub(crate) fn rename_device_files( /// 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( +pub fn write_playlist_m3u8( dest_dir: String, playlist_name: String, tracks: Vec, @@ -199,7 +199,7 @@ pub(crate) fn write_playlist_m3u8( /// 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 { +pub 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() { @@ -232,53 +232,53 @@ pub(crate) fn is_path_on_mounted_volume(path: &std::path::Path) -> bool { } #[derive(serde::Deserialize, Clone)] -pub(crate) struct TrackSyncInfo { - pub(crate) id: String, - pub(crate) url: String, - pub(crate) suffix: String, +pub struct TrackSyncInfo { + pub id: String, + pub url: String, + pub 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, + pub 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, + pub album_artist: String, + pub album: String, + pub title: String, #[serde(rename = "trackNumber")] - pub(crate) track_number: Option, + pub track_number: Option, /// Duration in seconds — needed for Extended M3U (#EXTINF) playlist entries. #[serde(default)] - pub(crate) duration: Option, + pub 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, + pub playlist_name: Option, #[serde(default, rename = "playlistIndex")] - pub(crate) playlist_index: Option, + pub 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, +pub struct SyncBatchResult { + pub done: u32, + pub skipped: u32, + pub failed: u32, } #[derive(serde::Serialize)] -pub(crate) struct SyncTrackResult { - pub(crate) path: String, - pub(crate) skipped: bool, +pub struct SyncTrackResult { + pub path: String, + pub 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 { +pub fn sanitize_path_component(s: &str) -> String { const INVALID: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|']; let sanitized: String = s .chars() @@ -289,7 +289,7 @@ pub(crate) fn sanitize_path_component(s: &str) -> 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 { +pub fn sanitize_or(s: &str, fallback: &str) -> String { let cleaned = sanitize_path_component(s); if cleaned.is_empty() { fallback.to_string() } else { cleaned } } @@ -299,7 +299,7 @@ pub(crate) fn sanitize_or(s: &str, fallback: &str) -> String { /// /// 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 { +pub 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"); @@ -323,7 +323,7 @@ pub(crate) fn build_track_path(track: &TrackSyncInfo) -> String { /// 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( +pub async fn sync_track_to_device( track: TrackSyncInfo, dest_dir: String, job_id: String, @@ -375,7 +375,7 @@ pub(crate) async fn sync_track_to_device( /// 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 { +pub 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); diff --git a/src-tauri/crates/psysonic-syncfs/src/sync/mod.rs b/src-tauri/crates/psysonic-syncfs/src/sync/mod.rs new file mode 100644 index 00000000..99e51062 --- /dev/null +++ b/src-tauri/crates/psysonic-syncfs/src/sync/mod.rs @@ -0,0 +1,2 @@ +pub mod batch; +pub mod device; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09d7bbbe..e104cb9f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ pub use psysonic_core::user_agent::{ }; pub use psysonic_analysis::{analysis_cache, analysis_runtime}; pub use psysonic_audio as audio; +pub use psysonic_syncfs::{sync_cancel_flags, DownloadSemaphore}; #[cfg(target_os = "windows")] mod taskbar_win; mod tray_runtime; @@ -19,8 +20,7 @@ mod tray_runtime; pub(crate) use tray_runtime::*; use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; -use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; use tauri::{Emitter, Manager}; use lib_commands::*; @@ -33,16 +33,6 @@ type ShortcutMap = Mutex>; /// The frontend queues more tasks than this; Rust is the real throttle. const MAX_DL_CONCURRENCY: usize = 4; -/// Shared semaphore that caps simultaneous `download_track_offline` executions. -type DownloadSemaphore = Arc; - -/// Per-job cancellation flags for `sync_batch_to_device`. -/// Each running sync registers an `Arc` here; `cancel_device_sync` flips it. -fn sync_cancel_flags() -> &'static Mutex>> { - static FLAGS: OnceLock>>> = OnceLock::new(); - FLAGS.get_or_init(|| Mutex::new(HashMap::new())) -} - /// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows). /// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux). type MprisControls = Mutex>; @@ -350,7 +340,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ greet, - calculate_sync_payload, + psysonic_syncfs::sync::batch::calculate_sync_payload, exit_app, cli_publish_player_snapshot, cli_publish_library_list, @@ -437,36 +427,36 @@ pub fn run() { analysis_delete_all_waveforms, analysis_enqueue_seed_from_url, analysis_prune_pending_to_track_ids, - download_track_offline, - delete_offline_track, - get_offline_cache_size, - download_track_hot_cache, - promote_stream_cache_to_hot_cache, - get_hot_cache_size, - delete_hot_cache_track, - purge_hot_cache, - sync_track_to_device, - sync_batch_to_device, - cancel_device_sync, - compute_sync_paths, - list_device_dir_files, - delete_device_file, - delete_device_files, - get_removable_drives, - write_device_manifest, - read_device_manifest, - write_playlist_m3u8, - rename_device_files, + psysonic_syncfs::cache::offline::download_track_offline, + psysonic_syncfs::cache::offline::delete_offline_track, + psysonic_syncfs::cache::offline::get_offline_cache_size, + psysonic_syncfs::cache::hot::download_track_hot_cache, + psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache, + psysonic_syncfs::cache::hot::get_hot_cache_size, + psysonic_syncfs::cache::hot::delete_hot_cache_track, + psysonic_syncfs::cache::hot::purge_hot_cache, + psysonic_syncfs::sync::device::sync_track_to_device, + psysonic_syncfs::sync::batch::sync_batch_to_device, + psysonic_syncfs::sync::batch::cancel_device_sync, + psysonic_syncfs::sync::device::compute_sync_paths, + psysonic_syncfs::sync::batch::list_device_dir_files, + psysonic_syncfs::sync::batch::delete_device_file, + psysonic_syncfs::sync::batch::delete_device_files, + psysonic_syncfs::sync::device::get_removable_drives, + psysonic_syncfs::sync::device::write_device_manifest, + psysonic_syncfs::sync::device::read_device_manifest, + psysonic_syncfs::sync::device::write_playlist_m3u8, + psysonic_syncfs::sync::device::rename_device_files, toggle_tray_icon, set_tray_tooltip, set_tray_menu_labels, check_dir_accessible, - download_zip, - check_arch_linux, - download_update, - open_folder, - get_embedded_lyrics, - fetch_netease_lyrics, + psysonic_syncfs::cache::downloads::download_zip, + psysonic_syncfs::cache::downloads::check_arch_linux, + psysonic_syncfs::cache::downloads::download_update, + psysonic_syncfs::cache::downloads::open_folder, + psysonic_syncfs::cache::downloads::get_embedded_lyrics, + psysonic_syncfs::cache::downloads::fetch_netease_lyrics, fetch_bandsintown_events, #[cfg(target_os = "windows")] taskbar_win::update_taskbar_icon, diff --git a/src-tauri/src/lib_commands/cache/mod.rs b/src-tauri/src/lib_commands/cache/mod.rs deleted file mode 100644 index a7626778..00000000 --- a/src-tauri/src/lib_commands/cache/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod fs_utils; -mod offline; -mod downloads; -mod hot; - -// Tauri commands re-exported for the lib.rs invoke_handler: -pub(crate) use offline::{ - delete_offline_track, download_track_offline, get_offline_cache_size, -}; -pub(crate) use hot::{ - delete_hot_cache_track, download_track_hot_cache, get_hot_cache_size, - promote_stream_cache_to_hot_cache, purge_hot_cache, -}; -pub(crate) use downloads::{ - check_arch_linux, download_update, download_zip, fetch_netease_lyrics, get_embedded_lyrics, - open_folder, -}; diff --git a/src-tauri/src/lib_commands/mod.rs b/src-tauri/src/lib_commands/mod.rs index f9020055..9e9fd897 100644 --- a/src-tauri/src/lib_commands/mod.rs +++ b/src-tauri/src/lib_commands/mod.rs @@ -1,6 +1,4 @@ pub(crate) mod app_api; -pub(crate) mod cache; -pub(crate) mod file_transfer; pub(crate) mod sync; pub(crate) mod ui; @@ -8,6 +6,11 @@ pub(crate) mod ui; // more level here so `lib.rs`'s `use lib_commands::*;` lands every // invoke-handler-registered name at lib.rs scope. pub(crate) use app_api::*; -pub(crate) use cache::*; pub(crate) use sync::*; pub(crate) use ui::*; + +// Cache + file_transfer + sync commands now live in psysonic_syncfs. +// invoke_handler! in lib.rs registers them with their full paths +// (`psysonic_syncfs::cache::*` / `psysonic_syncfs::sync::*`) so Tauri's +// `__cmd__*` magic macros resolve across the crate boundary. Nothing to +// re-export at this level. diff --git a/src-tauri/src/lib_commands/sync/mod.rs b/src-tauri/src/lib_commands/sync/mod.rs index 7622ab10..ad63553d 100644 --- a/src-tauri/src/lib_commands/sync/mod.rs +++ b/src-tauri/src/lib_commands/sync/mod.rs @@ -1,19 +1,13 @@ -mod device; -mod batch; mod tray; -// Tauri commands re-exported for the lib.rs invoke_handler: -pub(crate) use device::{ - get_removable_drives, read_device_manifest, rename_device_files, sync_track_to_device, - write_device_manifest, write_playlist_m3u8, compute_sync_paths, -}; -pub(crate) use batch::{ - calculate_sync_payload, cancel_device_sync, delete_device_file, delete_device_files, - list_device_dir_files, sync_batch_to_device, -}; pub(crate) use tray::{ is_tiling_wm_cmd, no_compositing_mode, set_tray_menu_labels, set_tray_tooltip, toggle_tray_icon, }; -// Internal helpers consumed elsewhere in the crate: +// Internal helpers consumed elsewhere in the shell crate: pub(crate) use tray::{is_tiling_wm, stop_audio_engine, try_build_tray_icon}; + +// Sync commands now live in `psysonic_syncfs::sync::*` — invoke_handler! +// in lib.rs registers them with the full path so Tauri's `__cmd__*` magic +// macros resolve correctly across the crate boundary; nothing to re-export +// here.