mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
Generated
+23
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
+14
-14
@@ -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
-2
@@ -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) {
|
||||
Vendored
+9
-9
@@ -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(());
|
||||
@@ -0,0 +1,4 @@
|
||||
mod fs_utils;
|
||||
pub mod offline;
|
||||
pub mod downloads;
|
||||
pub mod hot;
|
||||
Vendored
+7
-7
@@ -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
-4
@@ -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()))
|
||||
}
|
||||
+12
-12
@@ -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);
|
||||
+39
-39
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user