refactor: extract psysonic-syncfs crate (M4/7)

Moves all on-disk cache + device-sync code out of the top crate:

  crates/psysonic-syncfs/                      new lib crate
    src/cache/{offline,hot,downloads,fs_utils} unchanged behaviour
    src/sync/{batch,device}                    (no tray.rs — that's UI)
    src/file_transfer.rs                       shared HTTP helpers
    src/lib.rs                                 + DownloadSemaphore type
                                               + sync_cancel_flags fn

The shell crate keeps `lib_commands/sync/tray.rs` (OS tray icon — UI
concern, will move to shell-tauri at M7) but drops the rest of
`lib_commands/cache/` and the syncfs sibling files.

Tauri command quirk surfaced and resolved: `#[tauri::command]` puts its
`__cmd__*` and `__tauri_command_name_*` helper macros at the *exact*
module of the function, and `pub use` doesn't carry them across module
boundaries. invoke_handler! in lib.rs now references each syncfs
command via its full deepest path
(`psysonic_syncfs::cache::offline::download_track_offline`, etc.) so
Tauri's macros resolve at the right scope. Same approach the audio
crate already uses (`audio::commands::audio_play`).

Cross-crate ref migrations applied via batch sed:
  crate::audio::*               → psysonic_audio::*
  crate::analysis_runtime::*    → psysonic_analysis::analysis_runtime::*
  crate::analysis_cache::*      → psysonic_analysis::analysis_cache::*
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
  super::super::file_transfer:: → crate::file_transfer::

Behaviour preserving. Cargo check + clippy --workspace clean.
This commit is contained in:
Psychotoxical
2026-05-09 13:45:53 +02:00
parent 41e75663f1
commit 9417d522f3
17 changed files with 211 additions and 158 deletions
+23
View File
@@ -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"
+1
View File
@@ -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"
@@ -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"
@@ -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<String>,
app: &tauri::AppHandle,
) -> Result<std::path::PathBuf, String> {
@@ -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<u64>,
}
@@ -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<String, String> {
pub async fn download_update(url: String, filename: String, app: tauri::AppHandle) -> Result<String, String> {
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<Option<String>, String> {
pub async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Option<String>, 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<String> {
pub fn get_embedded_lyrics(path: String) -> Option<String> {
use lofty::file::FileType;
use lofty::prelude::*;
use lofty::probe::Probe;
@@ -198,7 +198,7 @@ pub(crate) fn get_embedded_lyrics(path: String) -> Option<String> {
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<String> {
/// 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<u64>,
@@ -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,
}
@@ -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) {
@@ -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<String>, app: tauri::AppHandle) -> u64 {
pub async fn get_hot_cache_size(custom_dir: Option<String>, 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<String>,
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<String>, app: tauri::AppHandle) -> Result<(), String> {
pub async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> Result<(), String> {
let root = resolve_hot_cache_root(custom_dir, &app)?;
if !root.exists() {
return Ok(());
+4
View File
@@ -0,0 +1,4 @@
mod fs_utils;
pub mod offline;
pub mod downloads;
pub mod hot;
@@ -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://<path>` 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<String>, app: tauri::AppHandle) -> u64 {
pub async fn get_offline_cache_size(custom_dir: Option<String>, 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<String>, 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<String>,
app: tauri::AppHandle,
@@ -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<reqwest::Client, String> {
pub fn subsonic_http_client(timeout: Duration) -> Result<reqwest::Client, String> {
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<reqwest::Client,
/// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full
/// file in memory — keeps RAM flat regardless of file size.
pub(crate) async fn stream_to_file(
pub async fn stream_to_file(
response: reqwest::Response,
dest_path: &Path,
) -> 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,
@@ -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<tokio::sync::Semaphore>;
/// Per-job cancellation flags for `sync_batch_to_device`.
/// Each running sync registers an `Arc<AtomicBool>` here; `cancel_device_sync`
/// flips it.
pub fn sync_cancel_flags() -> &'static Mutex<HashMap<String, Arc<AtomicBool>>> {
static FLAGS: OnceLock<Mutex<HashMap<String, Arc<AtomicBool>>>> = OnceLock::new();
FLAGS.get_or_init(|| Mutex::new(HashMap::new()))
}
@@ -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<Vec<String>, String> {
pub 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());
@@ -45,7 +45,7 @@ pub(crate) async fn list_device_dir_files(dir: String) -> Result<Vec<String>, 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<serde_json::Value>,
}
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<DeviceSyncSourcePayload>,
deletion_ids: Vec<String>,
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<TrackSyncInfo>,
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<String>) -> Result<u32, String> {
pub 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);
@@ -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<RemovableDrive> {
pub fn get_removable_drives() -> Vec<RemovableDrive> {
use sysinfo::Disks;
let disks = Disks::new_with_refreshed_list();
disks
@@ -41,7 +41,7 @@ pub(crate) fn get_removable_drives() -> Vec<RemovableDrive> {
/// 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<serde_json::Value> {
pub 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()
@@ -65,7 +65,7 @@ pub(crate) fn read_device_manifest(dest_dir: String) -> Option<serde_json::Value
/// Per-entry result for `rename_device_files`.
#[derive(serde::Serialize)]
pub(crate) struct RenameResult {
pub struct RenameResult {
#[serde(rename = "oldPath")]
old_path: String,
#[serde(rename = "newPath")]
@@ -85,7 +85,7 @@ pub(crate) struct RenameResult {
/// 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(
pub fn rename_device_files(
target_dir: String,
pairs: Vec<(String, String)>,
) -> Result<Vec<RenameResult>, 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<TrackSyncInfo>,
@@ -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<u32>,
pub track_number: Option<u32>,
/// Duration in seconds — needed for Extended M3U (#EXTINF) playlist entries.
#[serde(default)]
pub(crate) duration: Option<u32>,
pub 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>,
pub playlist_name: Option<String>,
#[serde(default, rename = "playlistIndex")]
pub(crate) playlist_index: Option<u32>,
pub 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,
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<TrackSyncInfo>, dest_dir: String) -> Vec<String> {
pub 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);
@@ -0,0 +1,2 @@
pub mod batch;
pub mod device;
+29 -39
View File
@@ -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<HashMap<String, String>>;
/// 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<tokio::sync::Semaphore>;
/// Per-job cancellation flags for `sync_batch_to_device`.
/// Each running sync registers an `Arc<AtomicBool>` here; `cancel_device_sync` flips it.
fn sync_cancel_flags() -> &'static Mutex<HashMap<String, Arc<AtomicBool>>> {
static FLAGS: OnceLock<Mutex<HashMap<String, Arc<AtomicBool>>>> = 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<Option<souvlaki::MediaControls>>;
@@ -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,
-17
View File
@@ -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,
};
+6 -3
View File
@@ -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.
+6 -12
View File
@@ -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.